diff --git a/.github/workflows/build-dev-image.yaml b/.github/workflows/build-dev-image.yaml
index 062a72d01..623a9bf99 100644
--- a/.github/workflows/build-dev-image.yaml
+++ b/.github/workflows/build-dev-image.yaml
@@ -7,23 +7,42 @@ on:
jobs:
build-dev-image:
runs-on: ubuntu-latest
- # 如果是tag则跳过
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
+ permissions:
+ contents: read
steps:
- name: Checkout
- uses: actions/checkout@v2
+ uses: actions/checkout@v4
with:
persist-credentials: false
- - name: Generate Tag
- id: generate_tag
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Generate image metadata
+ id: image
+ shell: bash
run: |
- # 获取分支名称,把/替换为-
- echo ${{ github.ref }} | sed 's/refs\/heads\///g' | sed 's/\//-/g'
- echo ::set-output name=tag::$(echo ${{ github.ref }} | sed 's/refs\/heads\///g' | sed 's/\//-/g')
- - name: Login to Registry
- run: docker login --username=${{ secrets.DOCKER_USERNAME }} --password ${{ secrets.DOCKER_PASSWORD }}
- - name: Build Docker Image
- run: |
- docker buildx create --name mybuilder --use
- docker build -t rockchin/langbot:${{ steps.generate_tag.outputs.tag }} . --push
+ set -euo pipefail
+ branch_tag="${GITHUB_REF#refs/heads/}"
+ branch_tag="${branch_tag//\//-}"
+ echo "branch_tag=${branch_tag}" >> "$GITHUB_OUTPUT"
+ echo "sha_tag=sha-${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
+
+ - name: Login to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKER_USERNAME }}
+ password: ${{ secrets.DOCKER_PASSWORD }}
+
+ - name: Build and push immutable Core image
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ push: true
+ tags: |
+ rockchin/langbot:${{ steps.image.outputs.branch_tag }}
+ rockchin/langbot:${{ steps.image.outputs.sha_tag }}
+ labels: |
+ org.opencontainers.image.revision=${{ github.sha }}
+ org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml
deleted file mode 100644
index c8500c4e2..000000000
--- a/.github/workflows/deploy-prod.yml
+++ /dev/null
@@ -1,59 +0,0 @@
-name: Build and deploy production
-
-on:
- push:
- branches: [deploy/prod]
- workflow_dispatch:
-
-permissions:
- contents: read
-
-concurrency:
- group: langbot-production
- cancel-in-progress: false
-
-env:
- CORE_IMAGE: ${{ secrets.DOCKER_USERNAME }}/langbot
- CLOUD_IMAGE: ${{ secrets.DOCKER_USERNAME }}/langbot-cloud-core
- SPACE_REF: 58253c53933f95d81b035fbe2efedb55b6c1a82b
-
-jobs:
- build-and-deploy:
- runs-on: ubuntu-latest
- environment: production
- steps:
- - uses: actions/checkout@v4
- - uses: docker/setup-buildx-action@v3
- - uses: docker/login-action@v3
- with:
- username: ${{ secrets.DOCKER_USERNAME }}
- password: ${{ secrets.DOCKER_PASSWORD }}
- - name: Build exact Core image
- uses: docker/build-push-action@v6
- with:
- context: .
- push: true
- tags: |
- ${{ env.CORE_IMAGE }}:prod-${{ github.sha }}
- ${{ env.CORE_IMAGE }}:deploy-prod
- cache-from: type=gha,scope=core-prod
- cache-to: type=gha,mode=max,scope=core-prod
- - name: Checkout production Cloud adapter
- uses: actions/checkout@v4
- with:
- repository: langbot-app/langbot-space
- ref: ${{ env.SPACE_REF }}
- token: ${{ secrets.CLA_PAT }}
- path: .space
- - name: Build exact Cloud Core image
- uses: docker/build-push-action@v6
- with:
- context: .space
- file: .space/Dockerfile.cloud
- push: true
- build-args: LANGBOT_CORE_IMAGE=${{ env.CORE_IMAGE }}:prod-${{ github.sha }}
- tags: |
- ${{ env.CLOUD_IMAGE }}:prod-${{ github.sha }}
- ${{ env.CLOUD_IMAGE }}:deploy-prod
- cache-from: type=gha,scope=cloud-core-prod
- cache-to: type=gha,mode=max,scope=cloud-core-prod
diff --git a/Dockerfile b/Dockerfile
index fb88c9151..1d1351c72 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM node:22-alpine AS node
+FROM --platform=$BUILDPLATFORM node:22-alpine AS node
WORKDIR /app
@@ -62,7 +62,7 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -f /tmp/nodesource_setup.sh \
&& python -m pip install --no-cache-dir uv \
- && uv sync \
+ && uv sync --extra seekdb \
&& apt-get purge -y --auto-remove curl git gnupg \
&& rm -rf /var/lib/apt/lists/* \
&& touch /.dockerenv
diff --git a/README.md b/README.md
index 84f02caf1..f6eeb0b22 100644
--- a/README.md
+++ b/README.md
@@ -83,6 +83,7 @@ cd LangBot/docker
docker compose --profile all up -d
```
+
### One-Click Cloud Deploy
[](https://zeabur.com/en-US/templates/ZKTBDH)
diff --git a/README_CN.md b/README_CN.md
index 2bf7d9e86..77b448f87 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -83,6 +83,7 @@ cd LangBot/docker
docker compose --profile all up -d
```
+
### 一键云部署
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
diff --git a/README_ES.md b/README_ES.md
index eac5aaaca..2f7ec0ce1 100644
--- a/README_ES.md
+++ b/README_ES.md
@@ -82,6 +82,7 @@ cd LangBot/docker
docker compose --profile all up -d
```
+
### Despliegue en la Nube con un Clic
[](https://zeabur.com/en-US/templates/ZKTBDH)
diff --git a/README_FR.md b/README_FR.md
index fe766d084..66d1f9bb6 100644
--- a/README_FR.md
+++ b/README_FR.md
@@ -82,6 +82,7 @@ cd LangBot/docker
docker compose --profile all up -d
```
+
### Déploiement Cloud en un Clic
[](https://zeabur.com/en-US/templates/ZKTBDH)
diff --git a/README_JP.md b/README_JP.md
index 1a9585635..efc6e23b2 100644
--- a/README_JP.md
+++ b/README_JP.md
@@ -82,6 +82,7 @@ cd LangBot/docker
docker compose --profile all up -d
```
+
### ワンクリッククラウドデプロイ
[](https://zeabur.com/en-US/templates/ZKTBDH)
diff --git a/README_KO.md b/README_KO.md
index 0e72547e0..7e0284788 100644
--- a/README_KO.md
+++ b/README_KO.md
@@ -82,6 +82,7 @@ cd LangBot/docker
docker compose --profile all up -d
```
+
### 원클릭 클라우드 배포
[](https://zeabur.com/en-US/templates/ZKTBDH)
diff --git a/README_RU.md b/README_RU.md
index 3a3ddeba0..f779c0c46 100644
--- a/README_RU.md
+++ b/README_RU.md
@@ -82,6 +82,7 @@ cd LangBot/docker
docker compose --profile all up -d
```
+
### Облачное развертывание одним кликом
[](https://zeabur.com/en-US/templates/ZKTBDH)
diff --git a/README_TW.md b/README_TW.md
index ed69b3168..9abb32976 100644
--- a/README_TW.md
+++ b/README_TW.md
@@ -84,6 +84,7 @@ cd LangBot/docker
docker compose --profile all up -d
```
+
### 一鍵雲端部署
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
diff --git a/README_VI.md b/README_VI.md
index e29963599..ca9ef667d 100644
--- a/README_VI.md
+++ b/README_VI.md
@@ -82,6 +82,7 @@ cd LangBot/docker
docker compose --profile all up -d
```
+
### Triển khai đám mây một cú nhấp
[](https://zeabur.com/en-US/templates/ZKTBDH)
diff --git a/deploy/prod/deploy.sh b/deploy/prod/deploy.sh
deleted file mode 100755
index dc51f89a5..000000000
--- a/deploy/prod/deploy.sh
+++ /dev/null
@@ -1,97 +0,0 @@
-#!/usr/bin/env bash
-set -Eeuo pipefail
-
-cd /opt/langbot-cloud-prod
-TAG=${1:?usage: deploy.sh prod-<40-char-sha>}
-[[ "$TAG" =~ ^prod-[0-9a-f]{40}$ ]] || { echo 'invalid immutable image tag' >&2; exit 2; }
-[[ -s .env ]] || { echo '/opt/langbot-cloud-prod/.env is missing' >&2; exit 3; }
-
-rendered_compose=$(docker compose config)
-grep -Fq 'LANGBOT_SPACE_CONTROL_PLANE_URL: https://space.langbot.app' <<<"$rendered_compose" || {
- echo 'Cloud control-plane URL must be https://space.langbot.app' >&2
- exit 4
-}
-grep -Fq 'SPACE__URL: https://space.langbot.app' <<<"$rendered_compose" || {
- echo 'Cloud user-facing Space URL must be https://space.langbot.app' >&2
- exit 5
-}
-grep -Eq 'LANGBOT_TELEMETRY_INGEST_TOKEN: .+' <<<"$rendered_compose" || {
- echo 'Cloud telemetry ingest token must be configured' >&2
- exit 6
-}
-
-update_env() {
- local key=$1 value=$2
- python3 - "$key" "$value" <<'PY'
-from pathlib import Path
-import os
-import sys
-
-path = Path('.env')
-key, value = sys.argv[1:]
-lines = path.read_text().splitlines()
-updated = False
-for index, line in enumerate(lines):
- if line.startswith(f'{key}='):
- lines[index] = f'{key}={value}'
- updated = True
- break
-if not updated:
- lines.append(f'{key}={value}')
-temporary = Path('.env.tmp')
-temporary.write_text('\n'.join(lines) + '\n')
-os.chmod(temporary, 0o600)
-temporary.replace(path)
-PY
-}
-update_env LANGBOT_IMAGE_TAG "$TAG"
-set -a
-. ./.env
-set +a
-: "${CLOUD_V2_CONTROL_PLANE_TOKEN:?CLOUD_V2_CONTROL_PLANE_TOKEN is required}"
-
-for attempt in 1 2 3 4 5; do
- if docker compose pull postgres redis migrate plugin-runtime core; then
- break
- fi
- if [ "$attempt" -eq 5 ]; then
- echo "docker compose pull failed after $attempt attempts" >&2
- exit 1
- fi
- delay=$((attempt * 10))
- echo "docker compose pull failed (attempt $attempt/5); retrying in ${delay}s" >&2
- sleep "$delay"
-done
-docker compose up -d postgres redis
-for _ in $(seq 1 60); do
- if docker compose exec -T postgres pg_isready -U langbot_operator -d langbot >/dev/null 2>&1; then break; fi
- sleep 2
-done
-docker compose exec -T postgres pg_isready -U langbot_operator -d langbot >/dev/null
-
-docker compose exec -T postgres psql -v ON_ERROR_STOP=1 -U langbot_operator -d langbot \
- -v runtime_password="$POSTGRES_RUNTIME_PASSWORD" <<'SQL'
-SELECT format('CREATE ROLE langbot_runtime LOGIN PASSWORD %L', :'runtime_password')
-WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'langbot_runtime')\gexec
-ALTER ROLE langbot_runtime PASSWORD :'runtime_password';
-GRANT CONNECT ON DATABASE langbot TO langbot_runtime;
-REVOKE CREATE ON SCHEMA public FROM PUBLIC, langbot_runtime;
-REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM langbot_runtime;
-REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public FROM langbot_runtime;
-ALTER DEFAULT PRIVILEGES FOR ROLE langbot_operator IN SCHEMA public REVOKE ALL ON TABLES FROM langbot_runtime;
-ALTER DEFAULT PRIVILEGES FOR ROLE langbot_operator IN SCHEMA public REVOKE ALL ON SEQUENCES FROM langbot_runtime;
-GRANT USAGE ON SCHEMA public TO langbot_runtime;
-SQL
-
-docker compose --profile tools run --rm migrate
-
-docker compose up -d --remove-orphans plugin-runtime core
-for _ in $(seq 1 90); do
- if docker compose exec -T core python -c 'import urllib.request; urllib.request.urlopen("http://127.0.0.1:5300/healthz", timeout=3)' >/dev/null 2>&1; then
- docker compose ps
- exit 0
- fi
- sleep 2
-done
-docker compose logs --tail=200 core plugin-runtime >&2
-exit 1
diff --git a/deploy/prod/docker-compose.yml b/deploy/prod/docker-compose.yml
deleted file mode 100644
index db224b4f0..000000000
--- a/deploy/prod/docker-compose.yml
+++ /dev/null
@@ -1,162 +0,0 @@
-services:
- postgres:
- image: pgvector/pgvector:pg17
- container_name: langbot-cloud-postgres
- restart: unless-stopped
- environment:
- POSTGRES_DB: langbot
- POSTGRES_USER: langbot_operator
- POSTGRES_PASSWORD: ${POSTGRES_OPERATOR_PASSWORD}
- volumes:
- - postgres-data:/var/lib/postgresql/data
- healthcheck:
- test: [CMD-SHELL, "pg_isready -U langbot_operator -d langbot"]
- interval: 5s
- timeout: 5s
- retries: 30
- networks: [internal]
-
- redis:
- image: redis:7.4-alpine
- container_name: langbot-cloud-redis
- restart: unless-stopped
- command: [redis-server, --appendonly, "yes", --requirepass, "${REDIS_PASSWORD}"]
- volumes:
- - redis-data:/data
- healthcheck:
- test: [CMD-SHELL, "redis-cli -a \"$${REDIS_PASSWORD}\" ping | grep PONG"]
- interval: 5s
- timeout: 5s
- retries: 20
- environment:
- REDIS_PASSWORD: ${REDIS_PASSWORD}
- networks: [internal]
-
- migrate:
- image: rockchin/langbot-cloud-core:${LANGBOT_IMAGE_TAG}
- profiles: [tools]
- command: [uv, run, langbot, migrate, --cloud]
- environment: &core-env
- TZ: Asia/Shanghai
- SYSTEM__INSTANCE_ID: ${CLOUD_V2_INSTANCE_UUID}
- SYSTEM__EDITION: cloud
- SYSTEM__RECOVERY_KEY: ${SYSTEM_RECOVERY_KEY}
- SYSTEM__JWT__SECRET: ${JWT_SECRET}
- SYSTEM__LIMITATION__MAX_BOTS: "2"
- SYSTEM__LIMITATION__MAX_PIPELINES: "3"
- SYSTEM__LIMITATION__MAX_EXTENSIONS: "3"
- SYSTEM__LIMITATION__MAX_KNOWLEDGE_BASES: "2"
- API__WEBHOOK_PREFIX: https://cloud.langbot.app
- API__WEBUI_URL: https://cloud.langbot.app
- WORKSPACE__INVITATIONS__PUBLIC_WEB_URL: https://cloud.langbot.app
- DATABASE__USE: postgresql
- DATABASE__POSTGRESQL__URL: postgresql+asyncpg://langbot_runtime:${POSTGRES_RUNTIME_PASSWORD}@postgres:5432/langbot
- DATABASE__CLOUD_MIGRATION__OPERATOR_DSN_ENV: LANGBOT_CLOUD_MIGRATION_DSN
- LANGBOT_CLOUD_MIGRATION_DSN: postgresql://langbot_operator:${POSTGRES_OPERATOR_PASSWORD}@postgres:5432/langbot
- VDB__USE: pgvector
- VDB__PGVECTOR__USE_BUSINESS_DATABASE: "true"
- VDB__PGVECTOR__ALLOWED_DIMENSIONS: "384,512,768,1024,1536"
- PLUGIN__ENABLE: "true"
- PLUGIN__RUNTIME_WS_URL: ws://plugin-runtime:5400/control/ws
- PLUGIN__DISPLAY_PLUGIN_DEBUG_URL: wss://cloud.langbot.app/plugin/debug/ws
- PLUGIN__WORKER__MAX_CPUS: "0.25"
- PLUGIN__WORKER__MAX_MEMORY_MB: "256"
- PLUGIN__WORKER__MAX_PIDS: "128"
- PLUGIN__WORKER__MAX_WORKERS: "16"
- PLUGIN__WORKER__MAX_TOTAL_CPUS: "4.0"
- PLUGIN__WORKER__MAX_TOTAL_MEMORY_MB: "4096"
- PLUGIN__WORKER__REQUIRE_HARD_LIMITS: "true"
- LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN: ${PLUGIN_RUNTIME_CONTROL_TOKEN}
- # Cloud v2 currently grants no managed Box capability. Keep the shared
- # runtime deployed but disable Core integration until a hard-quota-capable
- # backend can satisfy the fail-closed Cloud readiness contract.
- BOX__ENABLED: "false"
- BOX__BACKEND: nsjail
- BOX__RUNTIME__ENDPOINT: ws://box:5410
- BOX__ADMISSION__REQUIRED: "true"
- BOX__ADMISSION__LOGICAL_SESSION_ID: global
- BOX__ADMISSION__REQUIRED_BACKEND: nsjail
- BOX__ADMISSION__MAX_SESSIONS: "1"
- BOX__ADMISSION__MAX_MANAGED_PROCESSES: "0"
- BOX__ADMISSION__CPUS: "0.25"
- BOX__ADMISSION__MEMORY_MB: "256"
- BOX__ADMISSION__WORKSPACE_QUOTA_MB: "256"
- BOX__LOCAL__HOST_ROOT: /app/data/box
- BOX__LOCAL__DEFAULT_WORKSPACE: /app/data/box
- BOX__LOCAL__ALLOWED_MOUNT_ROOTS: /app/data/box
- LANGBOT_BOX_CONTROL_TOKEN: ${BOX_CONTROL_TOKEN}
- MCP__STDIO__ENABLED: "false"
- LANGBOT_SPACE_CONTROL_PLANE_URL: https://space.langbot.app
- LANGBOT_SPACE_CONTROL_PLANE_TOKEN: ${CLOUD_V2_CONTROL_PLANE_TOKEN}
- LANGBOT_TELEMETRY_INGEST_TOKEN: ${CLOUD_V2_CONTROL_PLANE_TOKEN}
- LANGBOT_SPACE_CONTROL_PLANE_PUBLIC_KEY: ${CLOUD_V2_MANIFEST_PUBLIC_KEY}
- LANGBOT_SPACE_CONTROL_PLANE_KEY_ID: ${CLOUD_V2_MANIFEST_KEY_ID}
- SPACE__URL: https://space.langbot.app
- depends_on:
- postgres: {condition: service_healthy}
- networks: [internal]
-
- plugin-runtime:
- image: rockchin/langbot:${LANGBOT_IMAGE_TAG}
- container_name: langbot-cloud-plugin-runtime
- restart: unless-stopped
- command: [uv, run, python, -m, langbot_plugin.cli.__init__, rt]
- environment:
- LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN: ${PLUGIN_RUNTIME_CONTROL_TOKEN}
- volumes:
- - plugin-data:/app/data
- - /sys/fs/cgroup:/sys/fs/cgroup:rw
- cgroup: host
- privileged: true
- expose: ["5400"]
- networks: [internal]
-
- box:
- image: rockchin/langbot:${LANGBOT_IMAGE_TAG}
- container_name: langbot-cloud-box
- restart: unless-stopped
- command: [uv, run, lbp, box, --host, 0.0.0.0, --ws-control-port, "5410"]
- environment:
- LANGBOT_BOX_CONTROL_TOKEN: ${BOX_CONTROL_TOKEN}
- LANGBOT_BOX_ROOT: /app/data/box
- volumes:
- - box-data:/app/data/box
- - /sys/fs/cgroup:/sys/fs/cgroup:rw
- cgroup: host
- privileged: true
- expose: ["5410"]
- networks: [internal]
-
- core:
- image: rockchin/langbot-cloud-core:${LANGBOT_IMAGE_TAG}
- container_name: langbot-cloud-core
- restart: unless-stopped
- environment: *core-env
- volumes:
- - core-data:/app/data
- - box-data:/app/data/box
- depends_on:
- postgres: {condition: service_healthy}
- redis: {condition: service_healthy}
- plugin-runtime: {condition: service_started}
- box: {condition: service_started}
- expose: ["5300"]
- healthcheck:
- test: [CMD-SHELL, "python -c 'import urllib.request; urllib.request.urlopen(\"http://127.0.0.1:5300/healthz\", timeout=3)'" ]
- interval: 10s
- timeout: 5s
- retries: 30
- start_period: 30s
- networks: [internal, shared-network]
-
-networks:
- internal:
- shared-network:
- external: true
-
-volumes:
- postgres-data:
- redis-data:
- plugin-data:
- box-data:
- core-data:
diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml
index cb9e18db0..c2c276ac5 100644
--- a/docker/docker-compose.yaml
+++ b/docker/docker-compose.yaml
@@ -47,11 +47,10 @@ services:
restart: on-failure
environment:
- TZ=Asia/Shanghai
- # Shared control-plane secret used to authenticate both the RPC socket
- # and managed-process relay. Generate once (for example with
- # ``openssl rand -hex 32``) and export it before enabling this profile.
- # An empty value is accepted by Compose so Box can remain optional, but
- # the Box runtime itself fails closed when the profile is started.
+ # Optional shared control-plane secret used to authenticate both the RPC
+ # socket and managed-process relay. Leave unset on both OSS services, or
+ # generate one with ``openssl rand -hex 32`` and set the same value on
+ # both ends. Strongly recommended when the deployment is Internet-accessible.
- LANGBOT_BOX_CONTROL_TOKEN=${LANGBOT_BOX_CONTROL_TOKEN:-}
# Box has its own process-wide blocking-work budget.
- LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS=${LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS:-8}
@@ -79,8 +78,9 @@ services:
- TZ=Asia/Shanghai
# Optional. Leave unset on both OSS services, or match plugin Runtime.
- LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN=${LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN:-}
- # Must match the value supplied to langbot_box. The token is sent only
- # in WebSocket handshake headers, never in URLs or action payloads.
+ # When set, this must match langbot_box. If both ends leave it unset,
+ # OSS permits the connection without token authentication. The token is
+ # sent only in WebSocket handshake headers, never in URLs or payloads.
- LANGBOT_BOX_CONTROL_TOKEN=${LANGBOT_BOX_CONTROL_TOKEN:-}
# Core process-wide blocking-work admission. These are native config
# overrides and are persisted with the effective data/config.yaml.
diff --git a/docs/PYPI_INSTALLATION.md b/docs/PYPI_INSTALLATION.md
index 1144d5cb3..6b55aed59 100644
--- a/docs/PYPI_INSTALLATION.md
+++ b/docs/PYPI_INSTALLATION.md
@@ -10,6 +10,19 @@ uvx langbot
This will automatically download and run the latest version of LangBot.
+SeekDB support is optional and is not installed by the command above. If you
+want to use the SeekDB vector database or the built-in SeekDB embedding model,
+run LangBot with the `seekdb` extra:
+
+```bash
+uvx --from 'langbot[seekdb]@latest' langbot
+```
+
+The extra includes native dependencies whose supported operating systems may
+be narrower than LangBot's. In particular, the current Apple Silicon wheels
+require macOS 15 or later. The default Chroma backend does not have this
+requirement.
+
## Install with pip/uv
You can also install LangBot as a regular Python package:
@@ -20,6 +33,10 @@ pip install langbot
# Using uv
uv pip install langbot
+
+# Include optional SeekDB support
+pip install 'langbot[seekdb]'
+# or: uv pip install 'langbot[seekdb]'
```
Then run it:
@@ -101,7 +118,7 @@ uvx langbot
## System Requirements
-- Python 3.10.1 or higher
+- Python 3.11 or higher (lower than Python 4)
- Operating System: Linux, macOS, or Windows
## Differences from Source Installation
diff --git a/docs/SEEKDB_INTEGRATION.md b/docs/SEEKDB_INTEGRATION.md
index b5ae7f9c3..a38eb9f08 100644
--- a/docs/SEEKDB_INTEGRATION.md
+++ b/docs/SEEKDB_INTEGRATION.md
@@ -16,12 +16,20 @@ This document describes how to use OceanBase SeekDB as the vector database backe
## Installation
-SeekDB support is automatically included when you install LangBot. The required dependency `pyseekdb` is listed in `pyproject.toml`.
+SeekDB is an optional LangBot feature. A normal LangBot installation uses
+Chroma by default and does not install `pyseekdb` or its native bindings.
-If you need to install it manually:
+Choose the command that matches how you run LangBot:
```bash
-pip install pyseekdb
+# PyPI / uvx
+uvx --from 'langbot[seekdb]@latest' langbot
+
+# Installed package
+pip install 'langbot[seekdb]'
+
+# Source checkout
+uv sync --extra seekdb
```
## ⚠️ Platform Compatibility
@@ -30,31 +38,36 @@ pip install pyseekdb
| Platform | Status | Notes |
|----------|--------|-------|
-| Linux | ✅ Supported | Full embedded mode support via `pylibseekdb` |
-| macOS | ❌ Not Supported | `pylibseekdb` is Linux-only; use server mode instead |
-| Windows | ❌ Not Supported | `pylibseekdb` is Linux-only; use server mode instead |
+| Linux x86_64 / ARM64 | ✅ Supported | Full embedded mode support via `pylibseekdb` |
+| macOS 15+ on Apple Silicon | ✅ Supported | Requires the macOS ARM64 `pylibseekdb` wheel |
+| macOS 14 or earlier on Apple Silicon | ❌ Not currently supported | The published native wheel requires macOS 15+; follow [oceanbase/seekdb#1324](https://github.com/oceanbase/seekdb/issues/1324) |
+| macOS on Intel | ❌ Not currently supported | No embedded binding is selected by `pyseekdb` |
+| Windows | ❌ Not currently supported | No Windows `pylibseekdb` wheel is published |
-**Important**: Embedded mode requires the `pylibseekdb` library, which is only available on Linux. If you're on macOS or Windows, you must use server mode.
+**Important**: Embedded mode requires a compatible `pylibseekdb` wheel. Do not
+force-install or retag a wheel built for a newer macOS release: the bundled
+binaries also declare macOS 15 as their minimum deployment target.
### Server Mode (Docker)
| Platform | Status | Notes |
|----------|--------|-------|
| Linux | ✅ Supported | Full Docker support |
-| macOS | ⚠️ Known Issue | Docker container initialization failure - [See Issue #36](https://github.com/oceanbase/seekdb/issues/36) |
-| Windows | ⚠️ Untested | Should work but not yet tested |
-
-**macOS Users**: Currently, SeekDB Docker containers have an initialization issue on macOS ([oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36)). Until this is resolved, we recommend:
-- Using ChromaDB or Qdrant as alternatives
-- Connecting to a remote SeekDB server on Linux if available
+| macOS | ✅ Supported by Docker Desktop | The previous slow-disk startup issue was fixed upstream in [oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36) |
+| Windows | ⚠️ Depends on the container runtime | Use a Linux container and follow the upstream image documentation |
### Server Mode (Remote Connection)
| Platform | Status | Notes |
|----------|--------|-------|
-| All Platforms | ✅ Supported | Connect to SeekDB running on a remote Linux server |
+| Linux | ✅ Supported | Install the `seekdb` extra and connect to the remote server |
+| macOS 15+ on Apple Silicon | ✅ Supported | Install the `seekdb` extra and connect to the remote server |
+| macOS 14 or earlier on Apple Silicon | ⚠️ Blocked by upstream packaging | `pyseekdb` currently requires the unavailable native wheel even for server-only use; follow [#1324](https://github.com/oceanbase/seekdb/issues/1324) |
+| macOS on Intel / Windows | ✅ Server mode only | Embedded bindings are not available |
-**Recommendation for macOS/Windows users**: Deploy SeekDB on a Linux server and connect via server mode configuration.
+Remote server mode does not use embedded storage at runtime. However, whether
+the Python client can be installed still depends on `pyseekdb`'s package
+metadata for the current platform.
## Configuration
@@ -170,22 +183,23 @@ Key methods:
### Import Error
-If you see: `ImportError: pyseekdb is not installed`
+If you see: `SeekDB support is not installed`
Solution:
```bash
-pip install pyseekdb
+uv sync --extra seekdb
+# or: uvx --from 'langbot[seekdb]@latest' langbot
```
-### Embedded Mode Error on macOS/Windows
+### Embedded Mode Is Unavailable on the Current Platform
**Error**:
```
RuntimeError: Embedded Client is not available because pylibseekdb is not available.
-Please install pylibseekdb (Linux only) or use RemoteServerClient (host/port) instead.
```
-**Cause**: `pylibseekdb` is only available on Linux platforms.
+**Cause**: No compatible `pylibseekdb` wheel is installed for the current OS,
+CPU architecture, Python version, and macOS deployment target.
**Solution**: Use server mode instead:
1. Deploy SeekDB on a Linux server or VM
@@ -208,29 +222,6 @@ vdb:
use: chroma # or qdrant
```
-### Docker Container Fails on macOS
-
-**Symptoms**:
-```bash
-docker run -d -p 2881:2881 oceanbase/seekdb:latest
-# Container exits immediately with code 30
-```
-
-**Error in logs**:
-```
-[ERROR] Code: Agent.SeekDB.Not.Exists
-Message: initialize failed: init agent failed: SeekDB not exists in current directory.
-```
-
-**Cause**: This is a known issue with SeekDB Docker containers on macOS. See [oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36).
-
-**Status**: Under investigation by OceanBase team.
-
-**Workaround Options**:
-1. **Use alternatives**: ChromaDB or Qdrant work perfectly on macOS
-2. **Remote server**: Deploy SeekDB on a Linux server and connect remotely
-3. **Wait for fix**: Monitor the GitHub issue for updates
-
### Connection Error (Server Mode)
If SeekDB server is not reachable, check:
diff --git a/docs/multi-tenant/implementation-decisions.md b/docs/multi-tenant/implementation-decisions.md
index a74c13f32..bdbe93fbf 100644
--- a/docs/multi-tenant/implementation-decisions.md
+++ b/docs/multi-tenant/implementation-decisions.md
@@ -103,11 +103,11 @@ This log records implementation choices made while delivering the Workspace arch
- Decision: New Core JWTs require `iss=langbot-core`, an audience derived from the immutable instance UUID, and an expiry. Legacy community tokens are accepted only when they have the historical issuer, carry no audience, and the active policy is the OSS singleton policy.
- Reason: A token issued by one instance must not authenticate against another instance that happens to share a secret, and a compatibility decoder must not become an alternate path around the SaaS trust boundary.
-### Runtime control transports authenticate before protocol dispatch
+### Runtime control transports support opt-in shared-secret authentication
-- Decision: External Plugin Runtime and Box WebSocket control channels require independent strong shared secrets in handshake headers. Locally managed child processes receive ephemeral secrets through their environment; secrets are not placed in URLs, process arguments, request payloads, or logs. Box additionally binds the first authenticated control channel to one trusted instance. Plugin Runtime debug and control credentials remain separate.
-- Reason: Workspace context inside an RPC payload is not trustworthy until the transport peer itself is authenticated. Separating control and debug credentials also limits accidental privilege reuse.
-- Deployment consequence: Docker Compose and Kubernetes wire one shared secret to each host/runtime pair. An empty external-runtime secret fails startup instead of silently exposing an unauthenticated socket.
+- Decision: OSS external Plugin Runtime and Box WebSocket control channels preserve tokenless standalone compatibility when the corresponding control token is unset. When a Runtime configures a token, it validates the independent shared secret in the handshake before protocol dispatch. Locally managed child processes still receive ephemeral secrets through their environment; secrets are not placed in URLs, process arguments, request payloads, or logs. Box additionally pins the first control channel to one declared instance identity. Plugin Runtime debug and control credentials remain separate.
+- Reason: Local OSS development must remain backward compatible, while exposed or shared Runtime endpoints can opt into transport authentication. Separating control and debug credentials also limits accidental privilege reuse.
+- Deployment consequence: Docker Compose and Kubernetes should wire one strong shared secret to each host/runtime pair. Both sides must use the same value for protection to be effective; a Runtime configured with a token rejects clients that omit it or send a different value.
### Dashboard WebSocket sessions are tenant runtime objects
diff --git a/pyproject.toml b/pyproject.toml
index 125f4c58b..4692b7b94 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "langbot"
-version = "4.10.7"
+version = "4.10.8"
description = "Production-grade platform for building agentic IM bots"
readme = "README.md"
license-files = ["LICENSE"]
@@ -70,8 +70,7 @@ dependencies = [
"langchain-text-splitters>=1.1.2",
"chromadb>=1.0.0,<2.0.0",
"qdrant-client (>=1.15.1,<2.0.0)",
- "pyseekdb==1.1.0.post3",
- "langbot-plugin @ git+https://github.com/langbot-app/langbot-plugin-sdk.git@9d216208cdfb41f0cb7fcb64632e2a46816d6dc6",
+ "langbot-plugin==0.5.5",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"matrix-nio>=0.25.2",
@@ -108,6 +107,11 @@ classifiers = [
"Topic :: Communications :: Chat",
]
+[project.optional-dependencies]
+seekdb = [
+ "pyseekdb==1.1.0.post3",
+]
+
[project.urls]
Homepage = "https://langbot.app"
Documentation = "https://docs.langbot.app"
diff --git a/skills/skills/langbot-deploy/SKILL.md b/skills/skills/langbot-deploy/SKILL.md
index 9115b3cd4..e03182e01 100644
--- a/skills/skills/langbot-deploy/SKILL.md
+++ b/skills/skills/langbot-deploy/SKILL.md
@@ -27,10 +27,11 @@ The `all` / `box` profile starts three services:
- `langbot_box` — Box sandbox runtime (`:5410`). Uses the host Docker socket to
spawn sandbox containers, so the **Box root host path and in-container path
must be identical** (`BOX__LOCAL__HOST_ROOT=${LANGBOT_BOX_ROOT:-${PWD}/data/box}`).
- Its RPC and managed-process relay require a shared
- `LANGBOT_BOX_CONTROL_TOKEN` (at least 32 non-whitespace characters) in both
- the LangBot and Box containers. Generate it once with `openssl rand -hex 32`;
- never put it in `box.runtime.endpoint` or commit it to config.
+ OSS allows its RPC and managed-process relay to run without a token when both
+ sides leave `LANGBOT_BOX_CONTROL_TOKEN` unset. For an exposed endpoint, set
+ the same value of at least 32 non-whitespace characters in both the LangBot
+ and Box containers. Generate it once with `openssl rand -hex 32`; never put
+ it in `box.runtime.endpoint` or commit it to config.
A Compose deployment may optionally set
`LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN` on both `langbot` and
diff --git a/skills/skills/langbot-space-ops/SKILL.md b/skills/skills/langbot-space-ops/SKILL.md
index 196205a70..c360d4e3f 100644
--- a/skills/skills/langbot-space-ops/SKILL.md
+++ b/skills/skills/langbot-space-ops/SKILL.md
@@ -6,7 +6,8 @@ description: Browse and search the LangBot Space marketplaces (plugins, MCP serv
# LangBot Space MCP Operations
LangBot Space (space.langbot.app) exposes an **MCP server** so user-facing AI
-agents can browse and search the marketplaces (plugins, MCP servers, skills).
+agents can browse and search the marketplaces (plugins, MCP servers, skills) and
+rank live models for automated setup.
## Endpoint
@@ -46,10 +47,12 @@ Authorization: Bearer lbpat_...uests without a valid PAT get `401 Unauthorized`.
| `list_plugins` / `search_plugins` / `get_plugin` | Plugin marketplace |
| `list_mcp_servers` / `search_mcp_servers` / `get_mcp_server` | MCP-server marketplace |
| `list_skills` / `search_skills` / `get_skill` | Skill marketplace |
+| `select_models` | Live best-first model list for setup wizards; optional `category` filter |
`list_*` and `search_*` are paged (`page`, `page_size`). `get_*` takes
`author` + `name`. The tool surface mirrors the REST endpoints under
-`/api/v1/marketplace/*` and is read/browse only.
+`/api/v1/marketplace/*`; `select_models` mirrors `/api/v1/models/selection`.
+All tools are read-only.
## How to use
@@ -58,12 +61,16 @@ Authorization: Bearer lbpat_...uests without a valid PAT get `401 Unauthorized`.
3. Use `search_plugins` / `search_mcp_servers` / `search_skills` to find items,
then `get_*` for details (e.g. to obtain author/name for installation in
LangBot itself).
+4. For automatic local-agent setup, call `select_models` (optionally with
+ `category`) and choose the first compatible item. Ordering is latest probe
+ state (available, unprobed, unavailable), then Space recommendation. Each
+ item includes `availability.up`, `last_probed_at`, latency, and HTTP status.
## Implementation & maintenance (for Space developers)
- Server: `internal/controller/mcp/server.go` (official Go MCP SDK
`github.com/modelcontextprotocol/go-sdk`). Tools call the service layer
- (`PluginService`, `MCPService`, `SkillService`) directly.
+ (`PluginService`, `MCPService`, `SkillService`, `ModelStatusService`) directly.
- Mount: `internal/controller/api.go` at `/mcp` and `/mcp/*any`.
- Auth: PAT via `AccountService.ValidatePersonalAccessToken`.
- Docs: `docs/MCP_SERVER.md`.
diff --git a/src/langbot/libs/qq_official_api/api.py b/src/langbot/libs/qq_official_api/api.py
index dfca4378c..d9106c897 100644
--- a/src/langbot/libs/qq_official_api/api.py
+++ b/src/langbot/libs/qq_official_api/api.py
@@ -422,6 +422,69 @@ class QQOfficialClient:
await self.logger.error(f'Failed to send private message: {response_data}')
raise ValueError(response)
+ async def _send_markdown_msg(
+ self,
+ target_type: str,
+ target_id: str,
+ content: str,
+ msg_id: Optional[str] = None,
+ event_id: Optional[str] = None,
+ msg_seq: int = 1,
+ ) -> None:
+ """Send a Markdown message to a C2C user or QQ group."""
+ if not await self.check_access_token():
+ await self.get_access_token()
+
+ if target_type == 'c2c':
+ url = f'{self.base_url}/v2/users/{target_id}/messages'
+ elif target_type == 'group':
+ url = f'{self.base_url}/v2/groups/{target_id}/messages'
+ else:
+ raise ValueError(f'Unsupported Markdown target type: {target_type}')
+
+ data: dict[str, Any] = {
+ 'msg_type': 2,
+ 'markdown': {'content': content},
+ 'msg_seq': msg_seq,
+ }
+ if msg_id:
+ data['msg_id'] = msg_id
+ if event_id:
+ data['event_id'] = event_id
+
+ async with self._http_client_context() as client:
+ headers = {
+ 'Authorization': f'QQBot {self.access_token}',
+ 'Content-Type': 'application/json',
+ }
+ response = await client.post(url, headers=headers, json=data)
+ if response.status_code != 200:
+ response_data = await httpclient.parse_json_response(response)
+ await self.logger.error(f'Failed to send Markdown message: {response_data}')
+ raise ValueError(response)
+
+ async def send_private_markdown_msg(
+ self,
+ user_openid: str,
+ content: str,
+ msg_id: Optional[str] = None,
+ event_id: Optional[str] = None,
+ msg_seq: int = 1,
+ ) -> None:
+ """Send a Markdown C2C message."""
+ await self._send_markdown_msg('c2c', user_openid, content, msg_id, event_id, msg_seq)
+
+ async def send_group_markdown_msg(
+ self,
+ group_openid: str,
+ content: str,
+ msg_id: Optional[str] = None,
+ event_id: Optional[str] = None,
+ msg_seq: int = 1,
+ ) -> None:
+ """Send a Markdown QQ group message."""
+ await self._send_markdown_msg('group', group_openid, content, msg_id, event_id, msg_seq)
+
async def send_group_text_msg(
self,
group_openid: str,
diff --git a/src/langbot/libs/wecom_ai_bot_api/ws_client.py b/src/langbot/libs/wecom_ai_bot_api/ws_client.py
index 3fe985255..997338211 100644
--- a/src/langbot/libs/wecom_ai_bot_api/ws_client.py
+++ b/src/langbot/libs/wecom_ai_bot_api/ws_client.py
@@ -46,6 +46,14 @@ CMD_RESPOND_MSG = 'aibot_respond_msg'
CMD_RESPOND_WELCOME = 'aibot_respond_welcome_msg'
CMD_RESPOND_UPDATE = 'aibot_respond_update_msg'
CMD_SEND_MSG = 'aibot_send_msg'
+# Media upload protocol (3 steps: init -> chunk * N -> finish). The
+# command names below match the WeCom AI Bot long-connection protocol.
+CMD_UPLOAD_INIT = 'aibot_upload_media_init'
+CMD_UPLOAD_CHUNK = 'aibot_upload_media_chunk'
+CMD_UPLOAD_FINISH = 'aibot_upload_media_finish'
+
+# Default upload chunk size: 512 KB before base64 encoding.
+_UPLOAD_CHUNK_SIZE = 512 * 1024
_DEDUP_CACHE_MAX = 4096
_STREAM_CACHE_MAX = 1024
@@ -495,6 +503,145 @@ class WecomBotWsClient:
body['chatid'] = chat_id
return await self._send_reply(req_id, body, cmd=CMD_SEND_MSG)
+ # ------------------------------------------------------------------
+ # Media upload (image / voice / file)
+ # ------------------------------------------------------------------
+
+ async def upload_media(
+ self,
+ data: bytes,
+ filename: str = 'attachment',
+ media_type: str = 'file',
+ ) -> Optional[dict]:
+ """Upload *data* to the WeCom AI Bot CDN and return the parsed ACK.
+
+ Implements the three-step protocol documented for the WeCom
+ AI Bot:
+
+ 1. ``aibot_upload_media_init`` — declare media type, file name,
+ size, MD5 and chunk count; receive ``upload_id``.
+ 2. ``aibot_upload_media_chunk`` — send each chunk (base64-encoded
+ bytes) until done; receive per-chunk ACK.
+ 3. ``aibot_upload_media_finish`` — finalize the upload; receive
+ ``media_id``.
+
+ Returns a dict with the final ``media_id`` (and the raw
+ ``finish`` ACK) on success, or ``None`` on any failure. The
+ caller is expected to ignore the result and continue
+ gracefully — the framework will keep working without media
+ delivery.
+ """
+ import base64 as _b64
+ import hashlib as _hl
+
+ if not data:
+ return None
+
+ file_size = len(data)
+ file_md5 = _hl.md5(data).hexdigest()
+ total_chunks = (file_size + _UPLOAD_CHUNK_SIZE - 1) // _UPLOAD_CHUNK_SIZE
+ if total_chunks == 0:
+ total_chunks = 1
+
+ # Step 1: init.
+ init_req_id = _generate_req_id(CMD_UPLOAD_INIT)
+ init_body = {
+ 'type': media_type,
+ 'filename': filename,
+ 'total_size': file_size,
+ 'total_chunks': total_chunks,
+ 'md5': file_md5,
+ }
+ init_ack = await self._send_reply(
+ init_req_id,
+ init_body,
+ cmd=CMD_UPLOAD_INIT,
+ )
+ if not init_ack or init_ack.get('errcode', 0) != 0:
+ await self.logger.warning(f'upload_media init failed: ack={init_ack!r}')
+ return None
+ upload_id = (
+ init_ack.get('upload_id')
+ or init_ack.get('body', {}).get('upload_id')
+ or init_ack.get('data', {}).get('upload_id')
+ )
+ if not upload_id:
+ await self.logger.warning(f'upload_media init returned no upload_id: ack={init_ack!r}')
+ return None
+
+ # Step 2: chunks.
+ for index in range(total_chunks):
+ start = index * _UPLOAD_CHUNK_SIZE
+ end = min(start + _UPLOAD_CHUNK_SIZE, file_size)
+ chunk_bytes = data[start:end]
+ chunk_req_id = _generate_req_id(CMD_UPLOAD_CHUNK)
+ chunk_body = {
+ 'upload_id': upload_id,
+ 'chunk_index': index,
+ 'base64_data': _b64.b64encode(chunk_bytes).decode('ascii'),
+ }
+ chunk_ack = await self._send_reply(
+ chunk_req_id,
+ chunk_body,
+ cmd=CMD_UPLOAD_CHUNK,
+ )
+ if not chunk_ack or chunk_ack.get('errcode', 0) != 0:
+ await self.logger.warning(f'upload_media chunk {index} failed: ack={chunk_ack!r}')
+ return None
+
+ # Step 3: finish.
+ finish_req_id = _generate_req_id(CMD_UPLOAD_FINISH)
+ finish_body = {'upload_id': upload_id}
+ finish_ack = await self._send_reply(
+ finish_req_id,
+ finish_body,
+ cmd=CMD_UPLOAD_FINISH,
+ )
+ if not finish_ack or finish_ack.get('errcode', 0) != 0:
+ await self.logger.warning(f'upload_media finish failed: ack={finish_ack!r}')
+ return None
+
+ media_id = (
+ finish_ack.get('media_id')
+ or finish_ack.get('body', {}).get('media_id')
+ or finish_ack.get('data', {}).get('media_id')
+ )
+ if not media_id:
+ await self.logger.warning(f'upload_media finish returned no media_id: ack={finish_ack!r}')
+ return None
+ return {'media_id': media_id, 'ack': finish_ack}
+
+ async def _reply_media(
+ self,
+ req_id: str,
+ media_id: str,
+ kind: str,
+ ) -> Optional[dict]:
+ """Send a media reply (image / voice / file) referencing *media_id*.
+
+ ``kind`` is one of ``'image'``, ``'voice'``, ``'file'``. Uses
+ the standard ``aibot_respond_msg`` command with a per-kind
+ body key (matches the convention documented for the WeCom
+ AI Bot SDK).
+ """
+ if kind not in {'image', 'voice', 'file'}:
+ await self.logger.warning(f'_reply_media called with unknown kind={kind!r}')
+ return None
+ body = {
+ 'msgtype': kind,
+ kind: {'media_id': media_id},
+ }
+ return await self._send_reply(req_id, body, cmd=CMD_RESPOND_MSG)
+
+ async def reply_image(self, req_id: str, media_id: str) -> Optional[dict]:
+ return await self._reply_media(req_id, media_id, 'image')
+
+ async def reply_file(self, req_id: str, media_id: str) -> Optional[dict]:
+ return await self._reply_media(req_id, media_id, 'file')
+
+ async def reply_voice(self, req_id: str, media_id: str) -> Optional[dict]:
+ return await self._reply_media(req_id, media_id, 'voice')
+
async def push_stream_chunk(self, msg_id: str, content: str, is_final: bool = False) -> bool:
"""Push a streaming chunk for a given message ID.
diff --git a/src/langbot/pkg/api/http/controller/groups/plugins.py b/src/langbot/pkg/api/http/controller/groups/plugins.py
index 069fcb027..77d710693 100644
--- a/src/langbot/pkg/api/http/controller/groups/plugins.py
+++ b/src/langbot/pkg/api/http/controller/groups/plugins.py
@@ -398,7 +398,16 @@ class PluginsRouterGroup(group.RouterGroup):
# Get debug URL from config
plugin_config = self.ap.instance_config.data.get('plugin', {})
- debug_url = plugin_config.get('display_plugin_debug_url', 'http://localhost:5401')
+ debug_url = plugin_config.get(
+ 'display_plugin_debug_url',
+ 'ws://localhost:5401/plugin/debug/ws',
+ )
+ parsed_debug_url = urlparse(debug_url)
+ if parsed_debug_url.scheme in {'http', 'https'}:
+ debug_url = parsed_debug_url._replace(
+ scheme='wss' if parsed_debug_url.scheme == 'https' else 'ws',
+ path=parsed_debug_url.path or '/plugin/debug/ws',
+ ).geturl()
return self.success(
data={
diff --git a/src/langbot/pkg/api/http/controller/groups/user.py b/src/langbot/pkg/api/http/controller/groups/user.py
index 6cfd5f8a7..5568047ca 100644
--- a/src/langbot/pkg/api/http/controller/groups/user.py
+++ b/src/langbot/pkg/api/http/controller/groups/user.py
@@ -14,13 +14,6 @@ from ...service.user import ControlPlaneDirectoryRequiredError, PublicRegistrati
@group.group_class('user', '/api/v1/user')
class UserRouterGroup(group.RouterGroup):
- @staticmethod
- def _origin(value: str) -> tuple[str, str, int | None] | None:
- parsed = urlsplit(value)
- if parsed.scheme not in {'http', 'https'} or not parsed.hostname:
- return None
- return parsed.scheme, parsed.hostname.casefold(), parsed.port
-
def _validate_space_redirect_uri(self, redirect_uri: str, *, bind: bool) -> str:
parsed = urlsplit(redirect_uri)
if (
@@ -38,17 +31,8 @@ class UserRouterGroup(group.RouterGroup):
if query != {'mode': ['bind']}:
raise ValueError('Invalid Space binding redirect_uri')
elif query:
- raise ValueError('Invalid Space login redirect_uri')
+ raise ValueError('Invalid LangBot Account login redirect_uri')
- redirect_origin = self._origin(redirect_uri)
- api_config = self.ap.instance_config.data.get('api', {})
- trusted_origins = {
- self._origin(str(api_config.get(config_key, '') or '').strip())
- for config_key in ('webui_url', 'webhook_prefix')
- }
- trusted_origins.discard(None)
- if redirect_origin not in trusted_origins:
- raise ValueError('Untrusted redirect_uri origin')
return redirect_uri
async def initialize(self) -> None:
@@ -338,6 +322,7 @@ class UserRouterGroup(group.RouterGroup):
if cloud_mode:
capabilities['password_login_enabled'] = False
capabilities['authenticated_invitation_acceptance_enabled'] = cloud_mode
+ capabilities['invitation_registration_enabled'] = not cloud_mode
return self.success(data={'initialized': True, **capabilities})
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
@@ -416,7 +401,7 @@ class UserRouterGroup(group.RouterGroup):
'Bind the LangBot Account with the same email as this local Account',
)
except ValueError:
- return self.http_status(400, -1, 'Space account binding failed')
+ return self.http_status(400, -1, 'LangBot Account binding failed')
except Exception:
raise
diff --git a/src/langbot/pkg/api/http/service/user.py b/src/langbot/pkg/api/http/service/user.py
index 28c23b32e..5e3ca2082 100644
--- a/src/langbot/pkg/api/http/service/user.py
+++ b/src/langbot/pkg/api/http/service/user.py
@@ -114,7 +114,7 @@ class UserService:
if purpose == 'login' and account_uuid is not None:
raise ValueError('Login state cannot be bound to an Account')
if purpose != 'login' and launch_workspace_uuid is not None:
- raise ValueError('Launch Workspace state is only valid for Space login')
+ raise ValueError('Launch Workspace state is only valid for LangBot Account login')
if ttl_seconds <= 0:
raise ValueError('OAuth state lifetime must be positive')
@@ -327,7 +327,7 @@ class UserService:
normalized_email = normalize_email(user_email)
if self._uses_control_plane_directory():
raise ControlPlaneDirectoryRequiredError(
- 'Cloud invitation registration must use a Space account to preserve control-plane identity'
+ 'Cloud invitation registration must use a LangBot Account to preserve control-plane identity'
)
invitation, _ = await self.ap.workspace_collaboration_service.inspect_invitation(invitation_token)
if invitation.normalized_email != normalized_email:
@@ -394,7 +394,7 @@ class UserService:
# Check if this user has a local password set
if not user_obj.password:
- raise ValueError('请使用 Space 账户登录')
+ raise ValueError('请使用 LangBot 账号登录')
await self._verify_password(user_obj.password, password)
@@ -825,7 +825,7 @@ class UserService:
# Check if this Space account is already bound to another user
existing_space_user = await self.get_user_by_space_account_uuid(space_account_uuid)
if existing_space_user and existing_space_user.normalized_email != normalize_email(user_email):
- raise ValueError('This Space account is already bound to another user')
+ raise ValueError('This LangBot Account is already bound to another user')
# Update local account to Space account
normalized_email = normalize_email(user_email)
diff --git a/src/langbot/pkg/box/connector.py b/src/langbot/pkg/box/connector.py
index 2ef990d0c..e10e8b9ac 100644
--- a/src/langbot/pkg/box/connector.py
+++ b/src/langbot/pkg/box/connector.py
@@ -367,6 +367,12 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
def _ensure_control_token(self, *, allow_generate: bool) -> str:
if not self._control_token and allow_generate:
self._control_token = secrets.token_urlsafe(48)
+ if not self._control_token:
+ if getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud':
+ raise BoxRuntimeUnavailableError(
+ f'{BOX_CONTROL_TOKEN_ENV} must be configured with a strong shared secret for a Cloud Box runtime'
+ )
+ return ''
try:
self._control_token = validate_control_token(self._control_token)
except ValueError as exc:
@@ -376,19 +382,19 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
return self._control_token
def get_control_headers(self) -> dict[str, str]:
- """Headers for the instance-authenticated RPC control handshake."""
+ """Return instance-scoped RPC headers and the optional shared secret."""
self._ensure_control_token(allow_generate=False)
- return {
- BOX_CONTROL_TOKEN_HEADER: self._control_token,
- BOX_INSTANCE_HEADER: self._trusted_instance_uuid,
- }
+ headers = {BOX_INSTANCE_HEADER: self._trusted_instance_uuid}
+ if self._control_token:
+ headers[BOX_CONTROL_TOKEN_HEADER] = self._control_token
+ return headers
def get_relay_headers(
self,
action_context: ActionContext,
) -> dict[str, str]:
- """Return authenticated, placement-scoped relay handshake headers."""
+ """Return instance- and placement-scoped relay handshake headers."""
context = ActionContext.model_validate(action_context).without_installation()
if context.instance_uuid != self._trusted_instance_uuid:
diff --git a/src/langbot/pkg/box/service.py b/src/langbot/pkg/box/service.py
index 56c8ae59b..c8554cc52 100644
--- a/src/langbot/pkg/box/service.py
+++ b/src/langbot/pkg/box/service.py
@@ -1210,8 +1210,9 @@ class BoxService:
async def _read_outbox_via_exec(self, query: pipeline_query.Query) -> list[dict]:
"""Fallback: read the outbox over the exec channel (E2B / remote).
- Note: exec stdout is truncated by ``output_limit_chars``, so this path
- only reliably transfers small files. The host path is preferred.
+ Uses ``client.execute`` directly (bypassing ``_serialize_result``)
+ so stdout is NOT truncated by ``output_limit_chars`` - the raw
+ base64 payload can be far larger than the 4000-char display limit.
"""
import json as _json
@@ -1265,14 +1266,22 @@ class BoxService:
' break\n'
'print(json.dumps(out))\n'
)
- result = await self.execute_tool(
- {'command': f"python3 - <<'LBPY'\n{script}\nLBPY", 'timeout_sec': 120},
- query,
- )
- if not result.get('ok'):
+ spec_payload: dict = {
+ 'cmd': f"python3 - <<'LBPY'\n{script}\nLBPY",
+ 'timeout_sec': 120,
+ 'session_id': self.resolve_box_session_id(query),
+ }
+ if 'extra_mounts' not in spec_payload:
+ spec_payload['extra_mounts'] = self.build_skill_extra_mounts(query)
+ try:
+ spec = self.build_spec(spec_payload)
+ result = await self.client.execute(spec)
+ except Exception:
+ return []
+ if not result.ok:
return []
try:
- return _json.loads(str(result.get('stdout') or '').strip().splitlines()[-1])
+ return _json.loads(str(result.stdout or '').strip().splitlines()[-1])
except Exception:
return []
diff --git a/src/langbot/pkg/command/cmdmgr.py b/src/langbot/pkg/command/cmdmgr.py
index 7560f7c43..31f28dfdf 100644
--- a/src/langbot/pkg/command/cmdmgr.py
+++ b/src/langbot/pkg/command/cmdmgr.py
@@ -3,6 +3,7 @@ from __future__ import annotations
import typing
import inspect
+from ..api.http.context import ExecutionContext
from ..core import app
from . import operator
from ..utils import importutil
@@ -66,7 +67,14 @@ class CommandManager:
require_context = getattr(self.ap.plugin_connector, 'require_workspace_context', None)
if require_context is not None:
- result = require_context(context)
+ result = require_context(
+ ExecutionContext(
+ instance_uuid=context.instance_uuid,
+ workspace_uuid=context.workspace_uuid,
+ placement_generation=context.placement_generation,
+ query_uuid=context.query_uuid,
+ )
+ )
if inspect.isawaitable(result):
await result
diff --git a/src/langbot/pkg/core/app.py b/src/langbot/pkg/core/app.py
index 3f4a17329..f1bff1c5b 100644
--- a/src/langbot/pkg/core/app.py
+++ b/src/langbot/pkg/core/app.py
@@ -249,6 +249,10 @@ class Application:
{},
)
),
+ 'plugin_runtime_connected': bool(
+ self.plugin_connector is not None
+ and getattr(self.plugin_connector, '_runtime_available', lambda: False)()
+ ),
}
mcp_loader = getattr(self.tool_mgr, 'mcp_tool_loader', None)
runtime_stats.update(
@@ -297,11 +301,36 @@ class Application:
async def initialize(self):
pass
+ async def _initialize_plugin_runtime(self) -> None:
+ try:
+ await self.plugin_connector.initialize()
+ except asyncio.CancelledError:
+ raise
+ except Exception as exc:
+ self.logger.warning(f'Plugin runtime unavailable during startup; reconnecting in background: {exc}')
+ self.plugin_connector.schedule_reconnect()
+
+ def _start_plugin_runtime_initialization(self) -> asyncio.Task | None:
+ task = getattr(self, '_plugin_runtime_initialization_task', None)
+ if task is not None and not task.done():
+ return task
+ # This is application lifecycle work, not a request side effect. It must
+ # not wait on PersistenceManager's after-commit gate at boot.
+ task = asyncio.create_task(
+ self._initialize_plugin_runtime(),
+ name='plugin-runtime-initialization',
+ )
+ self._plugin_runtime_initialization_task = task
+ return task
+
async def run(self):
self.event_loop_monitor.start()
try:
- if self.directory_projection_service is not None:
- self.task_mgr.create_task(
+ if (
+ self.directory_projection_service is not None
+ and getattr(self, 'directory_projection_task', None) is None
+ ):
+ self.directory_projection_task = self.task_mgr.create_task(
self.directory_projection_service.run(),
name='cloud-directory-projection',
scopes=[core_entities.LifecycleControlScope.APPLICATION],
@@ -318,7 +347,6 @@ class Application:
name='cloud-manifest-refresh',
scopes=[core_entities.LifecycleControlScope.APPLICATION],
)
- await self.plugin_connector.initialize_plugins()
# 后续可能会允许动态重启其他任务
# 故为了防止程序在非 Ctrl-C 情况下退出,这里创建一个不会结束的协程
@@ -344,6 +372,7 @@ class Application:
name='http-api-controller',
scopes=[core_entities.LifecycleControlScope.APPLICATION],
)
+ self._start_plugin_runtime_initialization()
# Telemetry instance heartbeat (startup + daily); respects
# space.disable_telemetry via TelemetryManager.send().
@@ -525,6 +554,11 @@ class Application:
if self.task_mgr is not None:
self.task_mgr.cancel_by_scope(core_entities.LifecycleControlScope.APPLICATION)
+ plugin_runtime_task = getattr(self, '_plugin_runtime_initialization_task', None)
+ if plugin_runtime_task is not None and not plugin_runtime_task.done():
+ plugin_runtime_task.cancel()
+ with contextlib.suppress(asyncio.CancelledError):
+ await plugin_runtime_task
with contextlib.suppress(Exception):
await self.event_loop_monitor.stop()
mcp_mount = getattr(self.http_ctrl, 'mcp_mount', None)
diff --git a/src/langbot/pkg/core/stages/build_app.py b/src/langbot/pkg/core/stages/build_app.py
index 14d533dcc..7941a5553 100644
--- a/src/langbot/pkg/core/stages/build_app.py
+++ b/src/langbot/pkg/core/stages/build_app.py
@@ -1,6 +1,6 @@
from __future__ import annotations
-from .. import stage, app
+from .. import stage, app, entities as core_entities
from ...utils import version, proxy, constants
from ...pipeline import pool, controller, pipelinemgr
from ...pipeline import aggregator as message_aggregator
@@ -292,14 +292,17 @@ class BuildAppStage(stage.BootingStage):
async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None:
connector.schedule_reconnect()
+ if ap.directory_projection_service is not None:
+ # Keep the projection fresh while shared Runtime cold restore runs.
+ # BuildApp initializes the connector before Application.run() starts
+ # its long-lived tasks, so start the single refresh task here.
+ ap.directory_projection_task = ap.task_mgr.create_task(
+ ap.directory_projection_service.run(),
+ name='cloud-directory-projection',
+ scopes=[core_entities.LifecycleControlScope.APPLICATION],
+ )
+
plugin_connector_inst = plugin_connector.PluginRuntimeConnector(ap, runtime_disconnect_callback)
- try:
- await plugin_connector_inst.initialize()
- except Exception as exc:
- # Keep the API/UI available while an external or managed runtime is
- # starting, then recover in the background with bounded backoff.
- ap.logger.warning(f'Plugin runtime unavailable during startup; reconnecting in background: {exc}')
- plugin_connector_inst.schedule_reconnect()
ap.plugin_connector = plugin_connector_inst
workspace_service_inst.release_startup_execution_bindings()
diff --git a/src/langbot/pkg/entity/errors/account.py b/src/langbot/pkg/entity/errors/account.py
index a2d0f1e85..d6565713d 100644
--- a/src/langbot/pkg/entity/errors/account.py
+++ b/src/langbot/pkg/entity/errors/account.py
@@ -17,4 +17,4 @@ class SpaceAccountBindingRequiredError(AccountEmailMismatchError):
code = 'space_account_binding_required'
def __str__(self) -> str:
- return 'This local Account must bind Space from Account settings before Space login'
+ return 'This local account must bind a LangBot Account from Account settings before LangBot Account login'
diff --git a/src/langbot/pkg/persistence/mgr.py b/src/langbot/pkg/persistence/mgr.py
index e9062c066..b29d6b44e 100644
--- a/src/langbot/pkg/persistence/mgr.py
+++ b/src/langbot/pkg/persistence/mgr.py
@@ -177,7 +177,6 @@ class PersistenceManager:
await self._validate_cloud_runtime()
return
- self._enable_sqlite_foreign_keys()
if self.mode == PersistenceMode.RELEASE_MIGRATION:
async with self._release_migration_lock():
await self._initialize_managed_schema()
@@ -185,6 +184,7 @@ class PersistenceManager:
return
await self._initialize_managed_schema()
+ await self._enable_sqlite_foreign_keys_after_migration()
if self.mode == PersistenceMode.OSS_COMPAT:
await self.write_space_model_providers()
@@ -373,6 +373,17 @@ class PersistenceManager:
sqlalchemy.event.listen(self.get_db_engine().sync_engine, 'begin', set_oss_tenant_scope)
self._oss_tenant_scope_listener_installed = True
+ async def _enable_sqlite_foreign_keys_after_migration(self) -> None:
+ """Enable SQLite FK enforcement only after table-rebuilding migrations."""
+ engine = self.get_db_engine()
+ if engine.dialect.name != 'sqlite':
+ return
+ await engine.dispose()
+ self._enable_sqlite_foreign_keys()
+ # Dispose again so every runtime connection is opened through the new
+ # listener instead of reusing a pre-migration pooled connection.
+ await engine.dispose()
+
def _enable_sqlite_foreign_keys(self) -> None:
"""Enable SQLite FK enforcement for every pooled runtime connection."""
engine = self.get_db_engine()
diff --git a/src/langbot/pkg/persistence/sqlite_migration_backup.py b/src/langbot/pkg/persistence/sqlite_migration_backup.py
index 5e1f7e683..9004b7fcc 100644
--- a/src/langbot/pkg/persistence/sqlite_migration_backup.py
+++ b/src/langbot/pkg/persistence/sqlite_migration_backup.py
@@ -12,6 +12,7 @@ import re
import secrets
import sqlite3
import tempfile
+import time
import typing
from sqlalchemy.ext.asyncio import AsyncEngine
@@ -117,8 +118,19 @@ def _write_manifest(backup: SQLiteMigrationBackup, status: str, **extra: typing.
temporary_path.unlink(missing_ok=True)
-def _fsync_file(path: pathlib.Path) -> None:
- descriptor = os.open(path, os.O_RDONLY)
+def _fsync_file(path: pathlib.Path, *, reopen_attempts: int = 20) -> None:
+ """Sync a file, tolerating delayed visibility after replace on bind mounts."""
+
+ descriptor: int | None = None
+ for attempt in range(reopen_attempts):
+ try:
+ descriptor = os.open(path, os.O_RDONLY)
+ break
+ except FileNotFoundError:
+ if attempt + 1 >= reopen_attempts:
+ raise
+ time.sleep(0.05)
+ assert descriptor is not None
try:
os.fsync(descriptor)
finally:
diff --git a/src/langbot/pkg/pipeline/wrapper/wrapper.py b/src/langbot/pkg/pipeline/wrapper/wrapper.py
index 50db693d4..eff976bfa 100644
--- a/src/langbot/pkg/pipeline/wrapper/wrapper.py
+++ b/src/langbot/pkg/pipeline/wrapper/wrapper.py
@@ -158,6 +158,18 @@ class ResponseWrapper(stage.PipelineStage):
result_type=entities.ResultType.CONTINUE,
new_query=query,
)
+ elif (
+ isinstance(result, provider_message.MessageChunk) and result.is_final and not result.tool_calls
+ ):
+ # Final streaming chunk with no text content but
+ # possibly carrying sandbox outbox attachments.
+ reply_chain = platform_message.MessageChain([])
+ await self._append_outbound_attachments(query, reply_chain)
+ query.resp_message_chain.append(reply_chain)
+ yield entities.StageProcessResult(
+ result_type=entities.ResultType.CONTINUE,
+ new_query=query,
+ )
if result.tool_calls is not None and len(result.tool_calls) > 0: # 有函数调用
function_names = [tc.function.name for tc in result.tool_calls]
diff --git a/src/langbot/pkg/platform/sources/line.py b/src/langbot/pkg/platform/sources/line.py
index 496a5ed81..fc4a93019 100644
--- a/src/langbot/pkg/platform/sources/line.py
+++ b/src/langbot/pkg/platform/sources/line.py
@@ -101,7 +101,7 @@ class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter):
if event.source.type == 'user':
return platform_events.FriendMessage(
sender=platform_entities.Friend(
- id=event.message.id,
+ id=event.source.user_id,
nickname=event.source.user_id,
remark='',
),
@@ -110,13 +110,19 @@ class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter):
source_platform_object=event,
)
else:
+ # 'group' and 'room' sources carry the stable chat id under different
+ # field names; user_id may be absent for some members, so fall back
+ # to the group/room id rather than the per-message id.
+ group_id = event.source.group_id if event.source.type == 'group' else event.source.room_id
+ member_id = event.source.user_id or group_id
+
return platform_events.GroupMessage(
sender=platform_entities.GroupMember(
- id=event.event.sender.sender_id.open_id,
- member_name=event.event.sender.sender_id.union_id,
+ id=member_id,
+ member_name=member_id,
permission=platform_entities.Permission.Member,
group=platform_entities.Group(
- id=event.message.id,
+ id=group_id,
name='',
permission=platform_entities.Permission.Member,
),
diff --git a/src/langbot/pkg/platform/sources/qqofficial.py b/src/langbot/pkg/platform/sources/qqofficial.py
index 598061f6c..f65a9683e 100644
--- a/src/langbot/pkg/platform/sources/qqofficial.py
+++ b/src/langbot/pkg/platform/sources/qqofficial.py
@@ -329,17 +329,12 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
content_type = content.get('type', 'text')
if content_type == 'text':
- if target_type == 'c2c':
- await self.bot.send_private_text_msg(
+ if target_type in {'c2c', 'group'}:
+ await self._send_c2c_or_group_text_reply(
+ target_type,
target_id,
content['content'],
- qq_official_event.d_id,
- )
- elif target_type == 'group':
- await self.bot.send_group_text_msg(
- target_id,
- content['content'],
- qq_official_event.d_id,
+ msg_id=qq_official_event.d_id,
)
elif content_type == 'image':
@@ -383,6 +378,39 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
pass
+ async def _send_c2c_or_group_text_reply(
+ self,
+ target_type: str,
+ target_id: str,
+ content: str,
+ *,
+ msg_id: typing.Optional[str] = None,
+ event_id: typing.Optional[str] = None,
+ msg_seq: int = 1,
+ ) -> None:
+ """Send a text reply using the configured C2C/group render mode."""
+ use_markdown = self.config.get('enable-markdown-rendering', False)
+ if target_type == 'c2c':
+ send = self.bot.send_private_markdown_msg if use_markdown else self.bot.send_private_text_msg
+ await send(
+ user_openid=target_id,
+ content=content,
+ msg_id=msg_id,
+ event_id=event_id,
+ msg_seq=msg_seq,
+ )
+ elif target_type == 'group':
+ send = self.bot.send_group_markdown_msg if use_markdown else self.bot.send_group_text_msg
+ await send(
+ group_openid=target_id,
+ content=content,
+ msg_id=msg_id,
+ event_id=event_id,
+ msg_seq=msg_seq,
+ )
+ else:
+ raise ValueError(f'Unsupported QQ Official text reply target: {target_type}')
+
def register_listener(
self,
event_type: typing.Type[platform_events.Event],
@@ -650,13 +678,13 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
# 用第一个 chunk 的文本建立会话(不发 "..." 避免污染前缀)
ctx['session_started'] = True
- # 发送内容 = 全量累积文本
- # QQ API 的 replace 模式不允许修改已下发前缀,所以:
- # - 首次:发送全部文本,建立会话
- # - 后续:只能发送新增部分(append 行为)
- content_to_send = ctx['accumulated_text'][ctx['sent_length'] :]
- if not content_to_send and not is_final:
+ # `replace` mode requires every update to contain the previously
+ # delivered content as its prefix. `sent_length` only tells us whether
+ # a non-final snapshot has new content; it must not truncate the
+ # content sent to QQ.
+ if len(ctx['accumulated_text']) <= ctx['sent_length'] and not is_final:
return
+ content_to_send = ctx['accumulated_text']
input_state = 10 if is_final else 1
@@ -778,20 +806,13 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
return
try:
- if target_type == 'c2c':
- await self.bot.send_private_text_msg(
- user_openid=target_id,
- content=text,
- event_id=event_id,
- msg_seq=msg_seq,
- )
- elif target_type == 'group':
- await self.bot.send_group_text_msg(
- group_openid=target_id,
- content=text,
- event_id=event_id,
- msg_seq=msg_seq,
- )
+ await self._send_c2c_or_group_text_reply(
+ target_type,
+ target_id,
+ text,
+ event_id=event_id,
+ msg_seq=msg_seq,
+ )
except Exception:
await self.logger.error(f'QQ Official: synthetic reply delivery failed: {traceback.format_exc()}')
diff --git a/src/langbot/pkg/platform/sources/qqofficial.yaml b/src/langbot/pkg/platform/sources/qqofficial.yaml
index c3f193be9..41d326b83 100644
--- a/src/langbot/pkg/platform/sources/qqofficial.yaml
+++ b/src/langbot/pkg/platform/sources/qqofficial.yaml
@@ -95,6 +95,18 @@ spec:
type: boolean
required: true
default: false
+ - name: enable-markdown-rendering
+ label:
+ en_US: Enable Markdown Rendering
+ zh_Hans: 启用 Markdown 渲染
+ zh_Hant: 啟用 Markdown 渲染
+ description:
+ en_US: Render non-stream C2C and QQ group text replies as Markdown. Channel messages always use plain text and are not affected by this setting.
+ zh_Hans: 将非流式 C2C 私聊和 QQ 群聊文本回复渲染为 Markdown。频道消息始终以纯文本发送,不受此设置影响。
+ zh_Hant: 將非串流 C2C 私聊與 QQ 群聊文字回覆渲染為 Markdown。頻道訊息一律以純文字傳送,不受此設定影響。
+ type: boolean
+ required: true
+ default: false
- name: webhook_url
label:
en_US: Webhook Callback URL
diff --git a/src/langbot/pkg/platform/sources/wecombot.py b/src/langbot/pkg/platform/sources/wecombot.py
index 0990773ad..a6bf0cc8d 100644
--- a/src/langbot/pkg/platform/sources/wecombot.py
+++ b/src/langbot/pkg/platform/sources/wecombot.py
@@ -3,8 +3,10 @@ import typing
import asyncio
import time
import traceback
+import base64
import datetime
+
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.platform.events as platform_events
@@ -24,11 +26,24 @@ from langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient
class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@staticmethod
async def yiri2target(message_chain: platform_message.MessageChain):
- content = ''
+ """Convert a MessageChain into a list of component dicts.
+
+ Each dict has a ``type`` key (``'text'``, ``'image'``,
+ ``'voice'``, ``'file'``). Text items carry ``text``; media
+ items carry ``base64`` (may include a ``data:...;base64,``
+ prefix) and optionally ``name``.
+ """
+ items: list[dict] = []
for msg in message_chain:
if type(msg) is platform_message.Plain:
- content += msg.text
- return content
+ items.append({'type': 'text', 'text': msg.text})
+ elif type(msg) is platform_message.Image:
+ items.append({'type': 'image', 'base64': msg.base64 or ''})
+ elif type(msg) is platform_message.Voice:
+ items.append({'type': 'voice', 'base64': msg.base64 or ''})
+ elif type(msg) is platform_message.File:
+ items.append({'type': 'file', 'base64': msg.base64 or '', 'name': msg.name or ''})
+ return items
@staticmethod
async def target2yiri(event: WecomBotEvent, bot_name: str = ''):
@@ -362,13 +377,76 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
}
)
+ @staticmethod
+ def _join_text_components(items: list[dict]) -> str:
+ """Concatenate ``text`` items in order, leaving media items alone."""
+ return ''.join(item['text'] for item in items if item.get('type') == 'text')
+
+ @staticmethod
+ def _iter_media_components(items: list[dict]):
+ """Yield non-text items in order."""
+ for item in items:
+ if item.get('type') in {'image', 'voice', 'file'}:
+ yield item
+
+ @staticmethod
+ async def _send_media(
+ bot,
+ req_id: str,
+ item: dict,
+ ) -> bool:
+ """Upload *item* to the WeCom AI Bot CDN and send it as a media reply.
+
+ Returns True on success. Falls back to a no-op (with a warning log)
+ if the SDK does not yet implement ``upload_media`` /
+ ``reply_image`` / ``reply_file`` / ``reply_voice`` — the framework
+ will keep working, just without image delivery.
+ """
+ kind = item.get('type')
+ upload = getattr(bot, 'upload_media', None)
+ if upload is None:
+ return False
+ b64_text = item.get('base64') or ''
+ if not b64_text:
+ return False
+ if b64_text.startswith('data:') and ',' in b64_text:
+ b64_text = b64_text.split(',', 1)[1]
+ try:
+ data = base64.b64decode(b64_text, validate=False)
+ except Exception:
+ return False
+ if not data:
+ return False
+ try:
+ upload_result = await upload(data, item.get('name') or f'attachment.{kind}', media_type=kind)
+ except Exception:
+ return False
+ media_id = getattr(upload_result, 'media_id', None) or (
+ isinstance(upload_result, dict) and upload_result.get('media_id')
+ )
+ if not media_id:
+ return False
+ reply_fn = {
+ 'image': getattr(bot, 'reply_image', None),
+ 'file': getattr(bot, 'reply_file', None),
+ 'voice': getattr(bot, 'reply_voice', None),
+ }.get(kind)
+ if reply_fn is None:
+ return False
+ try:
+ await reply_fn(req_id, media_id)
+ return True
+ except Exception:
+ return False
+
async def reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
quote_origin: bool = False,
):
- content = await self.message_converter.yiri2target(message)
+ items = await self.message_converter.yiri2target(message)
+ text = self._join_text_components(items)
_ws_mode = not self.config.get('enable-webhook', False)
event = message_source.source_platform_object
@@ -382,7 +460,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
else:
chat_id = str(message_source.sender.id)
try:
- await self.bot.send_message(chat_id, content)
+ await self.bot.send_message(chat_id, text)
except Exception:
await self.logger.error(
f'WeComBot: proactive reply for synthetic event failed: {traceback.format_exc()}'
@@ -396,12 +474,15 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
if _ws_mode:
req_id = event.get('req_id', '') if isinstance(event, dict) else getattr(event, 'req_id', '')
- if req_id:
- await self.bot.reply_text(req_id, content)
- else:
- await self.bot.set_message(event.message_id, content)
+ if text:
+ if req_id:
+ await self.bot.reply_text(req_id, text)
+ else:
+ await self.bot.set_message(event.message_id, text)
+ for item in self._iter_media_components(items):
+ await self._send_media(self.bot, req_id, item)
else:
- await self.bot.set_message(event.message_id, content)
+ await self.bot.set_message(event.message_id, text)
async def reply_message_chunk(
self,
@@ -411,7 +492,8 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
quote_origin: bool = False,
is_final: bool = False,
):
- content = await self.message_converter.yiri2target(message)
+ items = await self.message_converter.yiri2target(message)
+ text = self._join_text_components(items)
_ws_mode = not self.config.get('enable-webhook', False)
# Synthetic events (e.g. button-click triggered form resume) have
@@ -420,7 +502,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
# of the stream/reply path.
spo = message_source.source_platform_object
if spo is None:
- return await self._handle_synthetic_chunk(message_source, bot_message, content, is_final, _ws_mode)
+ return await self._handle_synthetic_chunk(message_source, bot_message, text, is_final, _ws_mode)
msg_id = spo.message_id
@@ -452,7 +534,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
form_data.get('actions', []) or [],
)
except Exception:
- fallback = content or '(人工输入)'
+ fallback = text or '(人工输入)'
if _ws_mode:
event = message_source.source_platform_object
req_id = event.get('req_id', '') if isinstance(event, dict) else getattr(event, 'req_id', '')
@@ -463,17 +545,22 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
return {'stream': False, 'form': True, 'fallback': True}
if _ws_mode:
- success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final)
+ success = await self.bot.push_stream_chunk(msg_id, text, is_final=is_final)
if not success and is_final:
event = message_source.source_platform_object
req_id = event.get('req_id', '')
if req_id:
- await self.bot.reply_text(req_id, content)
+ await self.bot.reply_text(req_id, text)
+ if is_final:
+ event = message_source.source_platform_object
+ req_id = event.get('req_id', '')
+ for item in self._iter_media_components(items):
+ await self._send_media(self.bot, req_id, item)
return {'stream': success}
else:
- success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final)
+ success = await self.bot.push_stream_chunk(msg_id, text, is_final=is_final)
if not success and is_final:
- await self.bot.set_message(msg_id, content)
+ await self.bot.set_message(msg_id, text)
return {'stream': success}
async def is_stream_output_supported(self) -> bool:
@@ -627,8 +714,9 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
async def send_message(self, target_type, target_id, message):
_ws_mode = not self.config.get('enable-webhook', False)
if _ws_mode:
- content = await self.message_converter.yiri2target(message)
- await self.bot.send_message(target_id, content)
+ items = await self.message_converter.yiri2target(message)
+ text = self._join_text_components(items)
+ await self.bot.send_message(target_id, text)
else:
pass
diff --git a/src/langbot/pkg/plugin/connector.py b/src/langbot/pkg/plugin/connector.py
index f2dbff418..03893cf4c 100644
--- a/src/langbot/pkg/plugin/connector.py
+++ b/src/langbot/pkg/plugin/connector.py
@@ -264,6 +264,11 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
if not self._control_token and allow_generate:
self._control_token = secrets.token_urlsafe(48)
if not self._control_token:
+ if self.runtime_profile == 'shared':
+ raise PluginRuntimeNotConnectedError(
+ f'{PLUGIN_RUNTIME_CONTROL_TOKEN_ENV} must be configured with a strong shared secret '
+ 'for a Cloud Plugin Runtime'
+ )
return {}
try:
self._control_token = validate_runtime_secret(
@@ -696,7 +701,13 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
}
self._known_desired_states.update({state.binding.installation_uuid: state for state in desired_states})
- result = await runtime_handler.reconcile_plugin_installations(tuple(self._known_desired_states.values()))
+ reconcile_timeout_seconds = max(
+ 300.0, self._runtime_connect_timeout(self.ap.instance_config.data.get('plugin', {}))
+ )
+ result = await runtime_handler.reconcile_plugin_installations(
+ tuple(self._known_desired_states.values()),
+ timeout=reconcile_timeout_seconds,
+ )
await self._repair_reconcile_missing_artifacts(self._known_desired_states, result)
self._record_reconcile_failures(self._known_desired_states, result)
@@ -731,7 +742,13 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
if state.binding.installation_uuid in all_states:
raise ValueError('Duplicate plugin installation UUID across projected Workspaces')
all_states[state.binding.installation_uuid] = state
- result = await runtime_handler.reconcile_plugin_installations(tuple(all_states.values()))
+ reconcile_timeout_seconds = max(
+ 300.0, self._runtime_connect_timeout(self.ap.instance_config.data.get('plugin', {}))
+ )
+ result = await runtime_handler.reconcile_plugin_installations(
+ tuple(all_states.values()),
+ timeout=reconcile_timeout_seconds,
+ )
await self._repair_reconcile_missing_artifacts(all_states, result)
self._record_reconcile_failures(all_states, result)
for installation_uuid, previous in tuple(self._known_desired_states.items()):
diff --git a/src/langbot/pkg/plugin/handler.py b/src/langbot/pkg/plugin/handler.py
index d642b10ea..8463750f0 100644
--- a/src/langbot/pkg/plugin/handler.py
+++ b/src/langbot/pkg/plugin/handler.py
@@ -11,6 +11,8 @@ import traceback
from dataclasses import dataclass
import sqlalchemy
+import sqlalchemy.dialects.postgresql
+import sqlalchemy.dialects.sqlite
from langbot_plugin.runtime.io import handler
from langbot_plugin.runtime.io.connection import Connection
@@ -431,6 +433,19 @@ class RuntimeConnectionHandler(handler.Handler):
return f'{identity.plugin_author}/{identity.plugin_name}'
raise ValueError(f'Unsupported binary storage owner_type {owner_type!r}')
+ @staticmethod
+ def _legacy_binary_storage_key(
+ action_context: ActionContext,
+ *,
+ owner_type: str,
+ owner: str,
+ key: str,
+ ) -> str:
+ """Return the pre-tenancy key shape for a row already scoped to this Workspace."""
+
+ legacy_owner = action_context.workspace_uuid if owner_type == 'workspace' else owner
+ return f'{owner_type}:{legacy_owner}:{key}'
+
@classmethod
def _binary_storage_key(
cls,
@@ -896,25 +911,82 @@ class RuntimeConnectionHandler(handler.Handler):
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
)
+ storage = result.first()
+ if storage is None:
+ legacy_key = self._legacy_binary_storage_key(
+ action_context,
+ owner_type=owner_type,
+ owner=owner,
+ key=key,
+ )
+ result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(persistence_bstorage.BinaryStorage)
+ .where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
+ .where(persistence_bstorage.BinaryStorage.unique_key == legacy_key)
+ .where(persistence_bstorage.BinaryStorage.key == key)
+ .where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
+ .where(persistence_bstorage.BinaryStorage.owner == owner)
+ )
+ storage = result.first()
+ if storage is not None:
+ update_result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.update(persistence_bstorage.BinaryStorage)
+ .where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
+ .where(persistence_bstorage.BinaryStorage.unique_key == legacy_key)
+ .where(persistence_bstorage.BinaryStorage.key == key)
+ .where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
+ .where(persistence_bstorage.BinaryStorage.owner == owner)
+ .values(unique_key=unique_key, value=value)
+ )
+ if update_result.rowcount:
+ return handler.ActionResponse.success(data={})
+ canonical_update = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.update(persistence_bstorage.BinaryStorage)
+ .where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
+ .where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
+ .where(persistence_bstorage.BinaryStorage.key == key)
+ .where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
+ .where(persistence_bstorage.BinaryStorage.owner == owner)
+ .values(value=value)
+ )
+ if canonical_update.rowcount:
+ return handler.ActionResponse.success(data={})
+ storage = None
- if result.first() is not None:
+ if storage is not None:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_bstorage.BinaryStorage)
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
+ .where(persistence_bstorage.BinaryStorage.key == key)
+ .where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
+ .where(persistence_bstorage.BinaryStorage.owner == owner)
.values(value=value)
)
- else:
- await self.ap.persistence_mgr.execute_async(
- sqlalchemy.insert(persistence_bstorage.BinaryStorage).values(
- workspace_uuid=action_context.workspace_uuid,
- unique_key=unique_key,
- key=key,
- owner_type=owner_type,
- owner=owner,
- value=value,
- )
+ return handler.ActionResponse.success(data={})
+
+ dialect_name = self.ap.persistence_mgr.get_db_engine().dialect.name
+ insert = {
+ 'postgresql': sqlalchemy.dialects.postgresql.insert,
+ 'sqlite': sqlalchemy.dialects.sqlite.insert,
+ }.get(dialect_name)
+ if insert is None:
+ return handler.ActionResponse.error(message=f'Unsupported storage database dialect: {dialect_name}')
+ await self.ap.persistence_mgr.execute_async(
+ insert(persistence_bstorage.BinaryStorage)
+ .values(
+ workspace_uuid=action_context.workspace_uuid,
+ unique_key=unique_key,
+ key=key,
+ owner_type=owner_type,
+ owner=owner,
+ value=value,
)
+ .on_conflict_do_update(
+ index_elements=['workspace_uuid', 'unique_key'],
+ set_={'value': value},
+ )
+ )
return handler.ActionResponse.success(
data={},
@@ -946,6 +1018,29 @@ class RuntimeConnectionHandler(handler.Handler):
)
storage = result.first()
+ if storage is None:
+ legacy_key = self._legacy_binary_storage_key(
+ action_context,
+ owner_type=owner_type,
+ owner=owner,
+ key=key,
+ )
+ result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(persistence_bstorage.BinaryStorage)
+ .where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
+ .where(persistence_bstorage.BinaryStorage.unique_key == legacy_key)
+ .where(persistence_bstorage.BinaryStorage.key == key)
+ .where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
+ .where(persistence_bstorage.BinaryStorage.owner == owner)
+ )
+ storage = result.first()
+ if storage is None:
+ retry_result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(persistence_bstorage.BinaryStorage)
+ .where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
+ .where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
+ )
+ storage = retry_result.first()
if storage is None:
return handler.ActionResponse.error(
message=f'Storage with key {key} not found',
@@ -981,10 +1076,19 @@ class RuntimeConnectionHandler(handler.Handler):
message=str(e),
)
+ legacy_key = self._legacy_binary_storage_key(
+ action_context,
+ owner_type=owner_type,
+ owner=owner,
+ key=key,
+ )
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_bstorage.BinaryStorage)
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
- .where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
+ .where(persistence_bstorage.BinaryStorage.unique_key.in_((unique_key, legacy_key)))
+ .where(persistence_bstorage.BinaryStorage.key == key)
+ .where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
+ .where(persistence_bstorage.BinaryStorage.owner == owner)
)
return handler.ActionResponse.success(
@@ -1012,7 +1116,7 @@ class RuntimeConnectionHandler(handler.Handler):
return handler.ActionResponse.success(
data={
- 'keys': result.scalars().all(),
+ 'keys': list(dict.fromkeys(result.scalars().all())),
},
)
@@ -1573,13 +1677,15 @@ class RuntimeConnectionHandler(handler.Handler):
async def reconcile_plugin_installations(
self,
installations: tuple[PluginInstallationDesiredState, ...],
+ *,
+ timeout: float = 300,
) -> dict[str, Any]:
request = ReconcilePluginInstallationsRequest(installations=installations)
with self.installation_scope(None):
return await self.call_action(
LangBotToRuntimeAction.RECONCILE_PLUGIN_INSTALLATIONS,
request.model_dump(),
- timeout=120,
+ timeout=timeout,
)
async def apply_plugin_installation(
@@ -1962,11 +2068,16 @@ class RuntimeConnectionHandler(handler.Handler):
async def get_debug_info(self, execution_context: ExecutionContext) -> dict[str, Any]:
"""Get debug information including debug key and WS URL"""
+ action_context = ActionContext(
+ instance_uuid=execution_context.instance_uuid,
+ workspace_uuid=execution_context.workspace_uuid,
+ placement_generation=execution_context.placement_generation,
+ )
result = await self.call_action(
LangBotToRuntimeAction.GET_DEBUG_INFO,
{},
timeout=10,
- action_context=execution_context,
+ action_context=action_context,
)
return result
diff --git a/src/langbot/pkg/provider/modelmgr/requesters/seekdbembed.py b/src/langbot/pkg/provider/modelmgr/requesters/seekdbembed.py
index 4b881dbfe..ff7d1d642 100644
--- a/src/langbot/pkg/provider/modelmgr/requesters/seekdbembed.py
+++ b/src/langbot/pkg/provider/modelmgr/requesters/seekdbembed.py
@@ -24,7 +24,10 @@ class SeekDBEmbedding(requester.ProviderAPIRequester):
try:
import pyseekdb
except ImportError:
- raise ImportError('pyseekdb is not installed. Install it with: pip install pyseekdb')
+ raise ImportError(
+ "SeekDB support is not installed. Install LangBot with the 'seekdb' extra: "
+ "uv sync --extra seekdb (source) or uvx --from 'langbot[seekdb]@latest' langbot (PyPI)."
+ )
self._embedding_function = pyseekdb.get_default_embedding_function()
diff --git a/src/langbot/pkg/vector/vdbs/seekdb.py b/src/langbot/pkg/vector/vdbs/seekdb.py
index fc82298e0..5be28b458 100644
--- a/src/langbot/pkg/vector/vdbs/seekdb.py
+++ b/src/langbot/pkg/vector/vdbs/seekdb.py
@@ -42,7 +42,10 @@ class SeekDBVectorDatabase(VectorDatabase):
def __init__(self, ap: app.Application):
if not SEEKDB_AVAILABLE:
- raise ImportError('pyseekdb is not installed. Install it with: pip install pyseekdb')
+ raise ImportError(
+ "SeekDB support is not installed. Install LangBot with the 'seekdb' extra: "
+ "uv sync --extra seekdb (source) or uvx --from 'langbot[seekdb]@latest' langbot (PyPI)."
+ )
self.ap = ap
config = self.ap.instance_config.data['vdb']['seekdb']
diff --git a/src/langbot/pkg/workspace/invitation_delivery.py b/src/langbot/pkg/workspace/invitation_delivery.py
index a3e2e8c86..c9f4f888c 100644
--- a/src/langbot/pkg/workspace/invitation_delivery.py
+++ b/src/langbot/pkg/workspace/invitation_delivery.py
@@ -240,7 +240,7 @@ class InvitationDeliveryService:
@staticmethod
def _plain_text(workspace_name: str, invitation_link: str) -> str:
return (
- 'You have been invited to LangBot Cloud\n\n'
+ 'You have been invited to join a Workspace in LangBot\n\n'
f'Join the Workspace “{workspace_name}” to collaborate with your team.\n\n'
f'Accept invitation: {invitation_link}\n\n'
'This secure invitation expires in 7 days and can only be accepted by the email address '
@@ -258,30 +258,77 @@ class InvitationDeliveryService:
- Join {escaped_workspace} on LangBot Cloud
+
+ Join {escaped_workspace} in LangBot
-
- You have been invited to join {escaped_workspace} on LangBot Cloud.
-
-
-
- |
- LangBot Cloud
- You’re invited
- |
- |
- You have been invited to collaborate in this Workspace:
- {escaped_workspace}
-
- This invitation expires in 7 days and is bound to the email address that received it.
- If the button does not work, copy and paste this URL into your browser:
- {escaped_link}
- |
- | If you were not expecting this invitation, you can safely ignore this email. |
-
- |
+
+ You have been invited to join {escaped_workspace} in LangBot.
+
+
+
+
+
+
+
+ LangBot
+ |
+
+
+
+
+
+ |
+ Workspace invitation
+ You’re invited to collaborate
+ Join your team in LangBot and start building together in this Workspace.
+
+
+
+ |
+ Workspace
+ {escaped_workspace}
+ |
+
+
+
+
+
+
+
+
+
+
+
+ |
+ For your security, this invitation expires in 7 days and only works for the email address that received it.
+ Open invitation link →
+ |
+
+
+ |
+
+
+ |
+
+
+
+ Sent by LangBot
+ If you were not expecting this invitation, you can safely ignore this email.
+ |
+
+
+ |
+
'''
diff --git a/src/langbot/templates/config.yaml b/src/langbot/templates/config.yaml
index a9d25086f..ab98d7af8 100644
--- a/src/langbot/templates/config.yaml
+++ b/src/langbot/templates/config.yaml
@@ -181,6 +181,11 @@ vdb:
host: localhost
port: 6333
api_key: ''
+ # SeekDB is optional. Native/package installs need the `seekdb` extra:
+ # `uv sync --extra seekdb` (source) or
+ # `uvx --from 'langbot[seekdb]@latest' langbot` (PyPI).
+ # The official Docker image already includes it.
+ # Embedded-mode platform support depends on the native pylibseekdb wheels.
seekdb:
mode: embedded # 'embedded' or 'server'
# Embedded mode options:
@@ -328,8 +333,9 @@ box:
enabled: true
backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', or 'e2b'. Can be written via BOX__BACKEND.
runtime:
- # External WebSocket runtimes also require LANGBOT_BOX_CONTROL_TOKEN in
- # both LangBot and Box. Keep the shared secret out of this config file.
+ # LANGBOT_BOX_CONTROL_TOKEN is optional for OSS external WebSocket
+ # runtimes. To protect an exposed endpoint, set the same strong secret
+ # in both LangBot and Box. Keep it out of this config file.
endpoint: '' # External Box Runtime base URL, e.g. 'ws://127.0.0.1:5410'. Leave empty for local auto-managed runtime.
limits:
max_sessions: 64
diff --git a/tests/integration/api/test_plugins_security.py b/tests/integration/api/test_plugins_security.py
index b42c25356..5f79f716a 100644
--- a/tests/integration/api/test_plugins_security.py
+++ b/tests/integration/api/test_plugins_security.py
@@ -235,13 +235,24 @@ async def test_debug_key_requires_resource_manage_permission(plugin_security_api
assert operator_denied.status_code == 403
assert allowed.status_code == 200
assert (await allowed.get_json())['data'] == {
- 'debug_url': 'http://localhost:5401',
+ 'debug_url': 'ws://localhost:5401/plugin/debug/ws',
'plugin_debug_key': 'runtime-debug-secret',
'expires_at': '2026-08-04T12:00:00Z',
}
application.plugin_connector.get_debug_info.assert_awaited_once()
+@pytest.mark.asyncio
+async def test_debug_info_uses_websocket_endpoint_for_legacy_config(plugin_security_api):
+ application, client, _ = plugin_security_api
+ application.instance_config.data['plugin'].pop('display_plugin_debug_url')
+
+ response = await client.get('/api/v1/plugins/debug-info', headers=_headers('manager-token'))
+
+ assert response.status_code == 200
+ assert (await response.get_json())['data']['debug_url'] == 'ws://localhost:5401/plugin/debug/ws'
+
+
@pytest.mark.asyncio
async def test_viewer_cannot_read_plugin_runtime_logs(plugin_security_api):
application, client, _ = plugin_security_api
diff --git a/tests/integration/api/test_smoke.py b/tests/integration/api/test_smoke.py
index dfd5054f2..642efaf2b 100644
--- a/tests/integration/api/test_smoke.py
+++ b/tests/integration/api/test_smoke.py
@@ -307,6 +307,7 @@ class TestUserInitEndpoint:
assert data['data'] == {
'initialized': True,
'authenticated_invitation_acceptance_enabled': False,
+ 'invitation_registration_enabled': True,
'password_login_enabled': True,
'space_login_enabled': False,
}
@@ -330,6 +331,28 @@ class TestUserInitEndpoint:
assert data['data'] == {
'initialized': True,
'authenticated_invitation_acceptance_enabled': True,
+ 'invitation_registration_enabled': False,
+ 'password_login_enabled': False,
+ 'space_login_enabled': True,
+ }
+
+ @pytest.mark.asyncio
+ async def test_account_info_enables_local_invitation_registration_for_oauth_only_oss(
+ self, quart_test_client, fake_api_app
+ ):
+ fake_api_app.user_service.is_initialized.return_value = True
+ fake_api_app.user_service.get_login_capabilities = AsyncMock(
+ return_value={'password_login_enabled': False, 'space_login_enabled': True}
+ )
+
+ response = await quart_test_client.get('/api/v1/user/account-info')
+
+ assert response.status_code == 200
+ data = await response.get_json()
+ assert data['data'] == {
+ 'initialized': True,
+ 'authenticated_invitation_acceptance_enabled': False,
+ 'invitation_registration_enabled': True,
'password_login_enabled': False,
'space_login_enabled': True,
}
diff --git a/tests/integration/api/test_user_space_oauth.py b/tests/integration/api/test_user_space_oauth.py
index 6fc1dfe07..be0a9f021 100644
--- a/tests/integration/api/test_user_space_oauth.py
+++ b/tests/integration/api/test_user_space_oauth.py
@@ -165,34 +165,51 @@ async def test_bind_state_is_account_bound_and_requires_authentication(space_oau
@pytest.mark.asyncio
-async def test_redirect_origin_and_callback_path_are_restricted(space_oauth_api):
+async def test_redirect_allows_any_http_or_https_origin(space_oauth_api):
_, client = space_oauth_api
- wrong_origin = await client.get(
- '/api/v1/user/space/authorize-url',
- query_string={'redirect_uri': 'https://evil.example/auth/space/callback'},
- headers={'Origin': 'http://localhost'},
- )
- wrong_path = await client.get(
- '/api/v1/user/space/authorize-url',
- query_string={'redirect_uri': 'http://localhost/arbitrary'},
- headers={'Origin': 'http://localhost'},
- )
- forged_origin = await client.get(
- '/api/v1/user/space/authorize-url',
- query_string={'redirect_uri': 'https://evil.example/auth/space/callback'},
- headers={'Origin': 'https://evil.example'},
- )
- forged_host = await client.get(
- '/api/v1/user/space/authorize-url',
- query_string={'redirect_uri': 'https://evil.example/auth/space/callback'},
- headers={'Host': 'evil.example'},
- )
+ responses = [
+ await client.get(
+ '/api/v1/user/space/authorize-url',
+ query_string={'redirect_uri': redirect_uri},
+ headers={'Origin': 'https://irrelevant.example'},
+ )
+ for redirect_uri in (
+ 'https://langbot.example/auth/space/callback',
+ 'https://gateway.example:8443/auth/space/callback',
+ 'https://192.0.2.10/auth/space/callback',
+ 'http://localhost:5300/auth/space/callback',
+ 'http://127.0.0.1:5300/auth/space/callback',
+ 'http://[::1]:5300/auth/space/callback',
+ 'http://langbot.example/auth/space/callback',
+ 'http://192.0.2.10:5300/auth/space/callback',
+ )
+ ]
- assert (await wrong_origin.get_json())['code'] == 1
- assert (await wrong_path.get_json())['code'] == 1
- assert (await forged_origin.get_json())['code'] == 1
- assert (await forged_host.get_json())['code'] == 1
+ assert all(response.status_code == 200 for response in responses)
+ payloads = [await response.get_json() for response in responses]
+ assert all(payload['code'] == 0 for payload in payloads)
+
+
+@pytest.mark.asyncio
+async def test_redirect_rejects_invalid_callback_shape(space_oauth_api):
+ _, client = space_oauth_api
+
+ responses = [
+ await client.get(
+ '/api/v1/user/space/authorize-url',
+ query_string={'redirect_uri': redirect_uri},
+ )
+ for redirect_uri in (
+ 'https://langbot.example/arbitrary',
+ 'https://langbot.example/auth/space/callback?next=https://evil.example',
+ 'https://user@langbot.example/auth/space/callback',
+ 'https://langbot.example/auth/space/callback#fragment',
+ )
+ ]
+
+ payloads = [await response.get_json() for response in responses]
+ assert all(payload['code'] == 1 for payload in payloads)
@pytest.mark.asyncio
@@ -295,6 +312,29 @@ async def test_space_credits_are_resolved_from_workspace_owner(space_oauth_api):
application.space_service.get_credits.assert_awaited_once_with('owner@example.com')
+@pytest.mark.asyncio
+async def test_oss_local_only_owner_requires_space_binding_for_langbot_models(space_oauth_api):
+ application, client = space_oauth_api
+ application.user_service.get_workspace_owner = AsyncMock(
+ return_value=SimpleNamespace(user='owner@example.com', space_account_uuid=None)
+ )
+ application.space_service.get_credits = AsyncMock()
+
+ response = await client.get(
+ '/api/v1/user/space-credits',
+ headers={'Authorization': 'Bearer account-token', 'X-Workspace-Id': WORKSPACE_UUID},
+ )
+ payload = await response.get_json()
+
+ assert response.status_code == 200
+ assert payload['data'] == {
+ 'credits': None,
+ 'owner_space_bound': False,
+ 'is_workspace_owner': True,
+ }
+ application.space_service.get_credits.assert_not_awaited()
+
+
@pytest.mark.asyncio
async def test_cloud_workspace_owner_is_always_space_bound_after_login(space_oauth_api):
application, client = space_oauth_api
diff --git a/tests/integration/persistence/resource_migration_support.py b/tests/integration/persistence/resource_migration_support.py
index f3eea0f30..afb05ae15 100644
--- a/tests/integration/persistence/resource_migration_support.py
+++ b/tests/integration/persistence/resource_migration_support.py
@@ -81,6 +81,7 @@ async def create_legacy_resource_schema(engine, *, instance_uuid: str) -> None:
sa.Column('key', sa.String(255), nullable=False),
sa.Column('owner_type', sa.String(255), nullable=False),
sa.Column('owner', sa.String(255), nullable=False),
+ sa.Column('value', sa.LargeBinary, nullable=False),
)
mcp_servers = _uuid_table(
metadata,
@@ -210,7 +211,13 @@ async def create_legacy_resource_schema(engine, *, instance_uuid: str) -> None:
await conn.execute(bots.insert().values(uuid='bot-1', name='bot', updated_at=now))
await conn.execute(bot_admins.insert().values(bot_uuid='bot-1', launcher_type='person', launcher_id='owner'))
await conn.execute(
- binary_storages.insert().values(unique_key='plugin:demo:key', key='key', owner_type='plugin', owner='demo')
+ binary_storages.insert().values(
+ unique_key='plugin:demo:key',
+ key='key',
+ owner_type='plugin',
+ owner='demo',
+ value=b'legacy-plugin-value',
+ )
)
await conn.execute(mcp_servers.insert().values(uuid='mcp-1', name='shared-name', enable=True, updated_at=now))
await conn.execute(model_providers.insert().values(uuid='provider-1', name='provider', requester='openai'))
diff --git a/tests/integration/persistence/test_resource_tenancy_migration.py b/tests/integration/persistence/test_resource_tenancy_migration.py
index 6ae288061..8bc3797a1 100644
--- a/tests/integration/persistence/test_resource_tenancy_migration.py
+++ b/tests/integration/persistence/test_resource_tenancy_migration.py
@@ -76,6 +76,26 @@ async def test_legacy_sqlite_resources_are_backfilled_and_contracted(tmp_path):
)
assert legacy_kb['collection_id'] == 'collection-1'
assert legacy_kb['legacy_vector_collection'] == 1
+ legacy_binary_storage = (
+ (
+ await conn.execute(
+ sa.text(
+ 'SELECT workspace_uuid, unique_key, key, owner_type, owner, value '
+ "FROM binary_storages WHERE owner_type = 'plugin' AND owner = 'demo'"
+ )
+ )
+ )
+ .mappings()
+ .one()
+ )
+ assert legacy_binary_storage == {
+ 'workspace_uuid': workspace_uuid,
+ 'unique_key': 'plugin:demo:key',
+ 'key': 'key',
+ 'owner_type': 'plugin',
+ 'owner': 'demo',
+ 'value': b'legacy-plugin-value',
+ }
assert (
await conn.scalar(
sa.text(
@@ -209,8 +229,8 @@ async def test_sqlite_scoped_keys_allow_cross_workspace_but_reject_same_workspac
await conn.execute(
sa.text(
'INSERT INTO binary_storages '
- '(workspace_uuid, unique_key, key, owner_type, owner) '
- "VALUES (:workspace_uuid, 'plugin:demo:key', 'key', 'plugin', 'demo')"
+ '(workspace_uuid, unique_key, key, owner_type, owner, value) '
+ "VALUES (:workspace_uuid, 'plugin:demo:key', 'key', 'plugin', 'demo', X'')"
),
{'workspace_uuid': second_workspace_uuid},
)
diff --git a/tests/integration/persistence/test_sqlite_migration_backup.py b/tests/integration/persistence/test_sqlite_migration_backup.py
index dab50dd96..e808cb3fa 100644
--- a/tests/integration/persistence/test_sqlite_migration_backup.py
+++ b/tests/integration/persistence/test_sqlite_migration_backup.py
@@ -2,6 +2,7 @@ from __future__ import annotations
import json
import logging
+import os
import pathlib
import sqlite3
@@ -9,7 +10,7 @@ import pytest
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import create_async_engine
-from langbot.pkg.persistence import alembic_runner
+from langbot.pkg.persistence import alembic_runner, sqlite_migration_backup
from langbot.pkg.persistence.mgr import PersistenceManager
from .resource_migration_support import create_legacy_resource_schema
@@ -105,3 +106,31 @@ async def test_failed_tenancy_migration_restores_backup_and_revision(
assert await alembic_runner.get_alembic_current(engine) == alembic_runner.get_alembic_head()
finally:
await engine.dispose()
+
+
+async def test_backup_retries_transient_reopen_failure_after_replace(tmp_path, monkeypatch):
+ database_path = tmp_path / 'legacy-bind-mount.db'
+ engine = create_async_engine(f'sqlite+aiosqlite:///{database_path}')
+ real_open = os.open
+ transient_failures = 0
+
+ def transient_open(path, flags, *args, **kwargs):
+ nonlocal transient_failures
+ candidate = pathlib.Path(path)
+ if candidate.suffix == '.sqlite3' and candidate.parent.name == 'migration-backups' and transient_failures == 0:
+ transient_failures += 1
+ raise FileNotFoundError(2, 'simulated delayed bind-mount visibility', str(candidate))
+ return real_open(path, flags, *args, **kwargs)
+
+ try:
+ await create_legacy_resource_schema(engine, instance_uuid='backup-bind-mount')
+ await alembic_runner.run_alembic_stamp(engine, '0008_mcp_resource_prefs')
+ monkeypatch.setattr(sqlite_migration_backup.os, 'open', transient_open)
+
+ await _manager(engine)._run_alembic_migrations()
+
+ assert transient_failures == 1
+ assert await alembic_runner.get_alembic_current(engine) == alembic_runner.get_alembic_head()
+ assert len(_manifest_payloads(tmp_path / 'migration-backups')) == 2
+ finally:
+ await engine.dispose()
diff --git a/tests/integration/persistence/test_workspace_migration.py b/tests/integration/persistence/test_workspace_migration.py
index 70c46f89a..2ebb0f1e8 100644
--- a/tests/integration/persistence/test_workspace_migration.py
+++ b/tests/integration/persistence/test_workspace_migration.py
@@ -179,13 +179,17 @@ async def test_existing_oss_workspace_is_rekeyed_to_instance_identity(tmp_path):
)
async with engine.begin() as conn:
await conn.run_sync(schema.create_all)
- await conn.execute(sa.text("INSERT INTO metadata (key, value) VALUES ('instance_uuid', :value)"), {'value': instance_id})
await conn.execute(
- sa.text("INSERT INTO workspaces (uuid, instance_uuid, slug, source) VALUES (:uuid, :instance, 'default', 'local')"),
+ sa.text("INSERT INTO metadata (key, value) VALUES ('instance_uuid', :value)"), {'value': instance_id}
+ )
+ await conn.execute(
+ sa.text(
+ "INSERT INTO workspaces (uuid, instance_uuid, slug, source) VALUES (:uuid, :instance, 'default', 'local')"
+ ),
{'uuid': old_workspace_uuid, 'instance': instance_id},
)
await conn.execute(
- sa.text("INSERT INTO tenant_rows (id, workspace_uuid) VALUES (1, :uuid)"),
+ sa.text('INSERT INTO tenant_rows (id, workspace_uuid) VALUES (1, :uuid)'),
{'uuid': old_workspace_uuid},
)
await run_alembic_stamp(engine, '0016_support_admin_sessions')
@@ -193,8 +197,8 @@ async def test_existing_oss_workspace_is_rekeyed_to_instance_identity(tmp_path):
await run_alembic_upgrade(engine, 'head')
async with engine.connect() as conn:
- assert (await conn.execute(sa.text("SELECT uuid FROM workspaces"))).scalar_one() == canonical_uuid
- assert (await conn.execute(sa.text("SELECT workspace_uuid FROM tenant_rows"))).scalar_one() == canonical_uuid
+ assert (await conn.execute(sa.text('SELECT uuid FROM workspaces'))).scalar_one() == canonical_uuid
+ assert (await conn.execute(sa.text('SELECT workspace_uuid FROM tenant_rows'))).scalar_one() == canonical_uuid
await engine.dispose()
@@ -411,6 +415,45 @@ async def test_persistence_startup_defers_workspace_tables_until_account_upgrade
await engine.dispose()
+async def test_persistence_startup_preserves_legacy_workspace_membership_with_foreign_keys(
+ tmp_path,
+ monkeypatch,
+):
+ database_path = tmp_path / 'startup-foreign-keys.db'
+ engine = create_async_engine(f'sqlite+aiosqlite:///{database_path}')
+ try:
+ await _create_legacy_schema(engine)
+ await run_alembic_stamp(engine, '0008_mcp_resource_prefs')
+ finally:
+ await engine.dispose()
+
+ monkeypatch.setattr(constants, 'instance_id', 'instance_migration_test')
+ application = type('Application', (), {})()
+ application.logger = logging.getLogger('workspace-startup-foreign-keys-test')
+ application.instance_config = type(
+ 'InstanceConfig',
+ (),
+ {'data': {'database': {'use': 'sqlite', 'sqlite': {'path': str(database_path)}}}},
+ )()
+ manager = PersistenceManager(application)
+
+ await manager.initialize()
+ try:
+ async with manager.get_db_engine().connect() as conn:
+ workspace = (
+ (await conn.execute(sa.text("SELECT * FROM workspaces WHERE source = 'local'"))).mappings().one()
+ )
+ membership = (await conn.execute(sa.text('SELECT * FROM workspace_memberships'))).mappings().one()
+ foreign_keys = await conn.scalar(sa.text('PRAGMA foreign_keys'))
+
+ assert workspace['created_by_account_uuid'] == membership['account_uuid']
+ assert membership['role'] == 'owner'
+ assert membership['status'] == 'active'
+ assert foreign_keys == 1
+ finally:
+ await manager.shutdown()
+
+
async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-rekey.db"}')
try:
@@ -425,7 +468,7 @@ async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
assert instance_uuid
await conn.execute(
sa.text(
- "INSERT INTO workspace_metadata (workspace_uuid, key, value) "
+ 'INSERT INTO workspace_metadata (workspace_uuid, key, value) '
"VALUES (:workspace_uuid, 'migration_probe', 'present')"
),
{'workspace_uuid': old_uuid},
@@ -433,7 +476,7 @@ async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
await conn.execute(
sa.text(
"INSERT INTO metadata (key, value) VALUES ('oss_workspace_uuid', :workspace_uuid) "
- "ON CONFLICT(key) DO UPDATE SET value = excluded.value"
+ 'ON CONFLICT(key) DO UPDATE SET value = excluded.value'
),
{'workspace_uuid': old_uuid},
)
@@ -442,12 +485,16 @@ async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
expected_uuid = workspace_uuid_from_instance_id(instance_uuid)
async with engine.connect() as conn:
assert await conn.scalar(sa.text("SELECT uuid FROM workspaces WHERE source = 'local'")) == expected_uuid
- assert await conn.scalar(
- sa.text("SELECT workspace_uuid FROM workspace_metadata WHERE key = 'migration_probe'")
- ) == expected_uuid
- assert await conn.scalar(
- sa.text("SELECT value FROM metadata WHERE key = 'oss_workspace_uuid'")
- ) == expected_uuid
+ assert (
+ await conn.scalar(
+ sa.text("SELECT workspace_uuid FROM workspace_metadata WHERE key = 'migration_probe'")
+ )
+ == expected_uuid
+ )
+ assert (
+ await conn.scalar(sa.text("SELECT value FROM metadata WHERE key = 'oss_workspace_uuid'"))
+ == expected_uuid
+ )
finally:
await engine.dispose()
diff --git a/tests/unit_tests/api/service/test_user_service.py b/tests/unit_tests/api/service/test_user_service.py
index a3479725e..12b440b32 100644
--- a/tests/unit_tests/api/service/test_user_service.py
+++ b/tests/unit_tests/api/service/test_user_service.py
@@ -377,7 +377,7 @@ class TestUserServiceAuthenticate:
service = UserService(ap)
# Execute & Verify
- with pytest.raises(ValueError, match='请使用 Space 账户登录'):
+ with pytest.raises(ValueError, match='请使用 LangBot 账号登录'):
await service.authenticate('space@example.com', 'password')
@@ -726,7 +726,7 @@ class TestUserServiceCreateOrUpdateSpaceUser:
)
service = UserService(ap)
- with pytest.raises(ControlPlaneDirectoryRequiredError, match='Space account'):
+ with pytest.raises(ControlPlaneDirectoryRequiredError, match='LangBot Account'):
await service.register_invited_account('invite-token', 'member@example.com', 'password')
async def test_create_or_update_new_space_user_first_init(self):
diff --git a/tests/unit_tests/box/test_box_connector.py b/tests/unit_tests/box/test_box_connector.py
index 367b2e362..0949801c6 100644
--- a/tests/unit_tests/box/test_box_connector.py
+++ b/tests/unit_tests/box/test_box_connector.py
@@ -24,7 +24,7 @@ from langbot.pkg.box.connector import BoxRuntimeConnector
_CONTROL_TOKEN = 'box-control-token-that-is-longer-than-32-bytes'
-def make_app(logger: Mock, runtime_endpoint: str = ''):
+def make_app(logger: Mock, runtime_endpoint: str = '', *, cloud: bool = False):
return SimpleNamespace(
logger=logger,
workspace_service=SimpleNamespace(instance_uuid='instance-a'),
@@ -42,6 +42,7 @@ def make_app(logger: Mock, runtime_endpoint: str = ''):
}
}
),
+ deployment=SimpleNamespace(mode='cloud' if cloud else 'oss'),
)
@@ -306,10 +307,27 @@ def test_box_runtime_connector_rejects_relay_context_from_other_instance(
)
-def test_external_box_runtime_fails_closed_without_control_token(monkeypatch: pytest.MonkeyPatch):
+def test_external_box_runtime_control_headers_are_tokenless_when_secret_is_unset(
+ monkeypatch: pytest.MonkeyPatch,
+):
monkeypatch.delenv(BOX_CONTROL_TOKEN_ENV, raising=False)
connector = BoxRuntimeConnector(make_app(Mock(), runtime_endpoint='http://box-runtime:5410'))
+ assert connector.get_control_headers() == {BOX_INSTANCE_HEADER: 'instance-a'}
+
+
+def test_cloud_box_runtime_rejects_missing_control_secret(monkeypatch: pytest.MonkeyPatch):
+ monkeypatch.delenv(BOX_CONTROL_TOKEN_ENV, raising=False)
+ connector = BoxRuntimeConnector(make_app(Mock(), runtime_endpoint='http://box-runtime:5410', cloud=True))
+
+ with pytest.raises(BoxRuntimeUnavailableError, match=BOX_CONTROL_TOKEN_ENV):
+ connector.get_control_headers()
+
+
+def test_external_box_runtime_rejects_invalid_configured_control_token(monkeypatch: pytest.MonkeyPatch):
+ monkeypatch.setenv(BOX_CONTROL_TOKEN_ENV, 'too-short')
+ connector = BoxRuntimeConnector(make_app(Mock(), runtime_endpoint='http://box-runtime:5410'))
+
with pytest.raises(BoxRuntimeUnavailableError, match=BOX_CONTROL_TOKEN_ENV):
connector.get_control_headers()
diff --git a/tests/unit_tests/box/test_box_service.py b/tests/unit_tests/box/test_box_service.py
index 9c78f771c..ce6e7bc5f 100644
--- a/tests/unit_tests/box/test_box_service.py
+++ b/tests/unit_tests/box/test_box_service.py
@@ -2163,25 +2163,38 @@ class TestInboundOutboundRoundTrip:
calls = []
- async def fake_execute_tool(parameters, q):
- calls.append(parameters['command'])
- if 'os.scandir' in parameters['command']:
- return {
- 'ok': True,
- 'stdout': '[{"name": "out.png", "b64": "QUJD"}]',
- 'stderr': '',
- }
+ async def fake_client_execute(spec):
+ cmd = spec.cmd
+ calls.append(cmd)
+ if 'os.scandir' in cmd:
+ return BoxExecutionResult(
+ session_id='s',
+ backend_name='test',
+ status=BoxExecutionStatus.COMPLETED,
+ exit_code=0,
+ stdout='[{"name": "out.png", "b64": "QUJD"}]',
+ duration_ms=10,
+ )
# the rm -rf cleanup call
- return {'ok': True, 'stdout': '', 'stderr': ''}
+ return BoxExecutionResult(
+ session_id='s',
+ backend_name='test',
+ status=BoxExecutionStatus.COMPLETED,
+ exit_code=0,
+ stdout='',
+ duration_ms=10,
+ )
- service.execute_tool = AsyncMock(side_effect=fake_execute_tool)
+ service.client.execute = AsyncMock(side_effect=fake_client_execute)
+ service.execute_tool = AsyncMock(return_value={'ok': True, 'stdout': '', 'stderr': ''})
attachments = await service.collect_outbound_attachments(query)
assert len(attachments) == 1
assert attachments[0]['type'] == 'Image'
assert attachments[0]['name'] == 'out.png'
# cleanup (rm -rf) must have been issued after a successful collection
- assert any('rm -rf' in c for c in calls)
+ service.execute_tool.assert_awaited_once()
+ assert 'rm -rf' in service.execute_tool.await_args.args[0]['command']
@pytest.mark.asyncio
async def test_collect_outbound_empty_still_clears(self):
@@ -2193,16 +2206,33 @@ class TestInboundOutboundRoundTrip:
calls = []
- async def fake_execute_tool(parameters, q):
- calls.append(parameters['command'])
- if 'os.scandir' in parameters['command']:
- return {'ok': True, 'stdout': '[]', 'stderr': ''}
- return {'ok': True, 'stdout': '', 'stderr': ''}
+ async def fake_client_execute(spec):
+ cmd = spec.cmd
+ calls.append(cmd)
+ if 'os.scandir' in cmd:
+ return BoxExecutionResult(
+ session_id='s',
+ backend_name='test',
+ status=BoxExecutionStatus.COMPLETED,
+ exit_code=0,
+ stdout='[]',
+ duration_ms=10,
+ )
+ return BoxExecutionResult(
+ session_id='s',
+ backend_name='test',
+ status=BoxExecutionStatus.COMPLETED,
+ exit_code=0,
+ stdout='',
+ duration_ms=10,
+ )
- service.execute_tool = AsyncMock(side_effect=fake_execute_tool)
+ service.client.execute = AsyncMock(side_effect=fake_client_execute)
+ service.execute_tool = AsyncMock(return_value={'ok': True, 'stdout': '', 'stderr': ''})
assert await service.collect_outbound_attachments(query) == []
# cleanup (rm -rf) is issued unconditionally now
- assert any('rm -rf' in c for c in calls)
+ service.execute_tool.assert_awaited_once()
+ assert 'rm -rf' in service.execute_tool.await_args.args[0]['command']
@pytest.mark.asyncio
async def test_passthrough_noop_when_unavailable(self):
diff --git a/tests/unit_tests/command/test_cmdmgr.py b/tests/unit_tests/command/test_cmdmgr.py
index ade27cf48..afc251a13 100644
--- a/tests/unit_tests/command/test_cmdmgr.py
+++ b/tests/unit_tests/command/test_cmdmgr.py
@@ -11,6 +11,7 @@ from unittest.mock import AsyncMock, Mock
from langbot.pkg.command import operator
from langbot.pkg.command.cmdmgr import CommandManager
+from langbot.pkg.api.http.context import ExecutionContext
from tests.factories import FakeApp, command_query
import langbot_plugin.api.entities.builtin.provider.session as provider_session
@@ -393,6 +394,32 @@ class TestCommandManagerInternalExecute:
assert len(results) == 1
assert results[0].text == 'plugin response'
+ @pytest.mark.asyncio
+ async def test_execute_selects_workspace_with_trusted_context(self):
+ """Plugin command discovery receives the typed runtime scope."""
+
+ fake_app = FakeApp()
+ mgr = CommandManager(fake_app)
+ mgr.cmd_list = []
+ fake_app.plugin_connector.require_workspace_context = AsyncMock()
+ fake_app.plugin_connector.list_commands = AsyncMock(return_value=[])
+
+ ctx = self._create_context(command='help')
+ ctx.instance_uuid = 'instance-a'
+ ctx.workspace_uuid = 'workspace-a'
+ ctx.placement_generation = 4
+ ctx.query_uuid = 'query-a'
+
+ async for _ in mgr._execute(ctx, mgr.cmd_list):
+ pass
+
+ selected = fake_app.plugin_connector.require_workspace_context.await_args.args[0]
+ assert isinstance(selected, ExecutionContext)
+ assert selected.instance_uuid == 'instance-a'
+ assert selected.workspace_uuid == 'workspace-a'
+ assert selected.placement_generation == 4
+ assert selected.query_uuid == 'query-a'
+
@pytest.mark.asyncio
async def test_execute_with_bound_plugins(self):
"""_execute passes bound_plugins to plugin connector."""
diff --git a/tests/unit_tests/core/test_app_shutdown.py b/tests/unit_tests/core/test_app_shutdown.py
index e284f5000..2c953aaac 100644
--- a/tests/unit_tests/core/test_app_shutdown.py
+++ b/tests/unit_tests/core/test_app_shutdown.py
@@ -87,7 +87,10 @@ async def test_runtime_resource_stats_are_aggregate_and_constant_time() -> None:
app.platform_mgr = SimpleNamespace(_bots_by_key={})
app.pipeline_mgr = SimpleNamespace(_pipelines_by_key={})
app.rag_mgr = SimpleNamespace(knowledge_bases={})
- app.plugin_connector = SimpleNamespace(_known_desired_states={'installation': object()})
+ app.plugin_connector = SimpleNamespace(
+ _known_desired_states={'installation': object()},
+ _runtime_available=lambda: True,
+ )
app.persistence_mgr = SimpleNamespace(
get_resource_stats=lambda: {
'configured_capacity': 20,
@@ -140,3 +143,40 @@ async def test_runtime_resource_stats_are_aggregate_and_constant_time() -> None:
}
assert stats['models']['providers'] == 1
assert stats['runtimes']['plugin_installations'] == 1
+ assert stats['runtimes']['plugin_runtime_connected'] is True
+
+
+@pytest.mark.asyncio
+async def test_start_plugin_runtime_initialization_bypasses_after_commit_gate() -> None:
+ app = Application()
+ app.plugin_connector = SimpleNamespace(initialize=AsyncMock())
+ app.task_mgr = SimpleNamespace(create_task=AsyncMock())
+
+ task = app._start_plugin_runtime_initialization()
+ await task
+
+ app.plugin_connector.initialize.assert_awaited_once_with()
+ app.task_mgr.create_task.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_shutdown_cancels_plugin_runtime_initialization_task() -> None:
+ app = Application()
+ app._plugin_runtime_initialization_task = asyncio.create_task(asyncio.sleep(60))
+ app.task_mgr = SimpleNamespace(cancel_by_scope=lambda *_: None, tasks=[])
+ app.event_loop_monitor = SimpleNamespace(stop=AsyncMock())
+ app.http_ctrl = SimpleNamespace(mcp_mount=None)
+ app.platform_mgr = None
+ app.tool_mgr = None
+ app.model_mgr = None
+ app.box_service = None
+ app.plugin_connector = None
+ app.telemetry = None
+ app.vector_db_mgr = None
+ app.storage_mgr = None
+ app.persistence_mgr = SimpleNamespace(db=SimpleNamespace(engine=SimpleNamespace(dispose=AsyncMock())))
+ app.deployment = None
+
+ await app.shutdown()
+
+ assert app._plugin_runtime_initialization_task.cancelled()
diff --git a/tests/unit_tests/platform/test_line_session_identity.py b/tests/unit_tests/platform/test_line_session_identity.py
new file mode 100644
index 000000000..565acbe4b
--- /dev/null
+++ b/tests/unit_tests/platform/test_line_session_identity.py
@@ -0,0 +1,69 @@
+from __future__ import annotations
+
+import pytest
+from unittest.mock import MagicMock
+
+from linebot.v3.webhooks import TextMessageContent
+
+from langbot.pkg.platform import botmgr as _botmgr # noqa: F401
+from langbot.pkg.platform.sources import line
+
+
+def _make_event(*, source_type: str, user_id, group_id=None, room_id=None, message_id: str, text: str = 'hi'):
+ event = MagicMock()
+ event.timestamp = 1700000000000
+ event.message = MagicMock(spec=TextMessageContent)
+ event.message.id = message_id
+ event.message.text = text
+ event.message.webhook_event_id = f'webhook-{message_id}'
+ event.message.timestamp = event.timestamp
+
+ source = MagicMock()
+ source.type = source_type
+ source.user_id = user_id
+ if group_id is not None:
+ source.group_id = group_id
+ if room_id is not None:
+ source.room_id = room_id
+ event.source = source
+
+ return event
+
+
+@pytest.mark.asyncio
+async def test_user_message_launcher_id_stable_across_messages() -> None:
+ """Two distinct messages from the same LINE user must resolve to the same
+ sender id, otherwise every message starts a brand new session (context loss).
+ """
+ event1 = _make_event(source_type='user', user_id='U-stable-user', message_id='msg-1')
+ event2 = _make_event(source_type='user', user_id='U-stable-user', message_id='msg-2')
+
+ result1 = await line.LINEEventConverter.target2yiri(event1, bot_client=None)
+ result2 = await line.LINEEventConverter.target2yiri(event2, bot_client=None)
+
+ assert result1.sender.id == 'U-stable-user'
+ assert result1.sender.id == result2.sender.id
+ assert result1.sender.id != event1.message.id
+
+
+@pytest.mark.asyncio
+async def test_group_message_uses_group_id_not_message_id() -> None:
+ event1 = _make_event(source_type='group', user_id='U-member', group_id='G-stable-group', message_id='msg-1')
+ event2 = _make_event(source_type='group', user_id='U-member', group_id='G-stable-group', message_id='msg-2')
+
+ result1 = await line.LINEEventConverter.target2yiri(event1, bot_client=None)
+ result2 = await line.LINEEventConverter.target2yiri(event2, bot_client=None)
+
+ assert result1.sender.group.id == 'G-stable-group'
+ assert result1.sender.group.id == result2.sender.group.id
+ assert result1.sender.id == 'U-member'
+
+
+@pytest.mark.asyncio
+async def test_room_message_uses_room_id_and_falls_back_when_user_id_missing() -> None:
+ event = _make_event(source_type='room', user_id=None, room_id='R-stable-room', message_id='msg-1')
+
+ result = await line.LINEEventConverter.target2yiri(event, bot_client=None)
+
+ assert result.sender.group.id == 'R-stable-room'
+ assert result.sender.id == 'R-stable-room'
diff --git a/tests/unit_tests/platform/test_qqofficial_api.py b/tests/unit_tests/platform/test_qqofficial_api.py
index 704dd142c..c0dfdf84c 100644
--- a/tests/unit_tests/platform/test_qqofficial_api.py
+++ b/tests/unit_tests/platform/test_qqofficial_api.py
@@ -1,9 +1,11 @@
-"""Tests for QQ Official keyboard payload helpers."""
+"""Tests for QQ Official message and keyboard payload helpers."""
import asyncio
+import json
import time
from unittest.mock import AsyncMock, MagicMock, patch
+import httpx
import pytest
import langbot_plugin.api.entities.builtin.platform.message as platform_message
@@ -99,6 +101,12 @@ def _stream_test_adapter():
adapter.bot = MagicMock()
adapter.bot.send_stream_msg = AsyncMock(return_value={'id': 'stream-1'})
adapter.bot.send_markdown_keyboard = AsyncMock(return_value={'id': 'message-1'})
+ adapter.bot.send_private_text_msg = AsyncMock()
+ adapter.bot.send_group_text_msg = AsyncMock()
+ adapter.bot.send_private_markdown_msg = AsyncMock()
+ adapter.bot.send_group_markdown_msg = AsyncMock()
+ adapter.bot.send_channle_group_text_msg = AsyncMock()
+ adapter.bot.send_channle_private_text_msg = AsyncMock()
adapter.ap = None
adapter._stream_ctx = {}
adapter._stream_ctx_ts = {}
@@ -108,7 +116,7 @@ def _stream_test_adapter():
@pytest.mark.asyncio
-async def test_qq_stream_uses_cumulative_chunks_as_snapshots():
+async def test_qq_stream_replace_mode_sends_complete_snapshots():
adapter = _stream_test_adapter()
adapter._stream_ctx['message-1'] = {
'user_openid': 'user-1',
@@ -138,10 +146,109 @@ async def test_qq_stream_uses_cumulative_chunks_as_snapshots():
assert [call.kwargs['content'] for call in adapter.bot.send_stream_msg.await_args_list] == [
'one',
- ' two',
+ 'one two',
]
+@pytest.mark.asyncio
+async def test_qq_markdown_messages_use_markdown_payloads():
+ requests = []
+
+ def capture_request(request: httpx.Request) -> httpx.Response:
+ requests.append((str(request.url), json.loads(request.content)))
+ return httpx.Response(200, json={})
+
+ client = QQOfficialClient('secret', 'token', 'app-id', AsyncMock())
+ client.access_token = 'access-token'
+ client.access_token_expiry_time = time.time() + 3600
+ client._http_clients[None] = httpx.AsyncClient(transport=httpx.MockTransport(capture_request))
+
+ try:
+ await client.send_private_markdown_msg('user-1', '# Hello', msg_id='message-1', msg_seq=2)
+ await client.send_group_markdown_msg('group-1', '* Hello', event_id='event-1', msg_seq=3)
+ finally:
+ await client.close()
+
+ assert requests == [
+ (
+ 'https://api.sgroup.qq.com/v2/users/user-1/messages',
+ {'msg_type': 2, 'markdown': {'content': '# Hello'}, 'msg_seq': 2, 'msg_id': 'message-1'},
+ ),
+ (
+ 'https://api.sgroup.qq.com/v2/groups/group-1/messages',
+ {'msg_type': 2, 'markdown': {'content': '* Hello'}, 'msg_seq': 3, 'event_id': 'event-1'},
+ ),
+ ]
+
+
+@pytest.mark.asyncio
+async def test_qq_markdown_rendering_switches_c2c_and_group_text_replies():
+ adapter = _stream_test_adapter()
+ adapter.config = {'enable-markdown-rendering': True}
+
+ await adapter._send_c2c_or_group_text_reply('c2c', 'user-1', '# Hello', msg_id='message-1')
+ await adapter._send_c2c_or_group_text_reply('group', 'group-1', '* Hello', event_id='event-1')
+
+ adapter.bot.send_private_markdown_msg.assert_awaited_once_with(
+ user_openid='user-1',
+ content='# Hello',
+ msg_id='message-1',
+ event_id=None,
+ msg_seq=1,
+ )
+ adapter.bot.send_group_markdown_msg.assert_awaited_once_with(
+ group_openid='group-1',
+ content='* Hello',
+ msg_id=None,
+ event_id='event-1',
+ msg_seq=1,
+ )
+ adapter.bot.send_private_text_msg.assert_not_awaited()
+ adapter.bot.send_group_text_msg.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_qq_markdown_rendering_defaults_to_plain_text_replies():
+ adapter = _stream_test_adapter()
+ adapter.config = {}
+
+ await adapter._send_c2c_or_group_text_reply('c2c', 'user-1', 'Hello')
+ await adapter._send_c2c_or_group_text_reply('group', 'group-1', 'Hello')
+
+ adapter.bot.send_private_text_msg.assert_awaited_once()
+ adapter.bot.send_group_text_msg.assert_awaited_once()
+ adapter.bot.send_private_markdown_msg.assert_not_awaited()
+ adapter.bot.send_group_markdown_msg.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_qq_markdown_rendering_does_not_affect_channel_messages():
+ adapter = _stream_test_adapter()
+ adapter.config = {'enable-markdown-rendering': True}
+ message = platform_message.MessageChain([platform_message.Plain(text='# Hello')])
+
+ channel_source = MagicMock()
+ channel_source.t = 'AT_MESSAGE_CREATE'
+ channel_source.channel_id = 'channel-1'
+ channel_source.d_id = 'message-1'
+ channel_event = MagicMock()
+ channel_event.source_platform_object = channel_source
+ await adapter.reply_message(channel_event, message)
+
+ dm_source = MagicMock()
+ dm_source.t = 'DIRECT_MESSAGE_CREATE'
+ dm_source.guild_id = 'guild-1'
+ dm_source.d_id = 'message-2'
+ dm_event = MagicMock()
+ dm_event.source_platform_object = dm_source
+ await adapter.reply_message(dm_event, message)
+
+ adapter.bot.send_channle_group_text_msg.assert_awaited_once_with('channel-1', '# Hello', 'message-1')
+ adapter.bot.send_channle_private_text_msg.assert_awaited_once_with('guild-1', '# Hello', 'message-2')
+ adapter.bot.send_private_markdown_msg.assert_not_awaited()
+ adapter.bot.send_group_markdown_msg.assert_not_awaited()
+
+
@pytest.mark.asyncio
async def test_qq_non_streaming_fallback_keeps_latest_snapshot_only():
from langbot.pkg.platform.sources.qqofficial import QQOfficialAdapter
diff --git a/tests/unit_tests/platform/test_wecombot_media.py b/tests/unit_tests/platform/test_wecombot_media.py
new file mode 100644
index 000000000..8bbff53e1
--- /dev/null
+++ b/tests/unit_tests/platform/test_wecombot_media.py
@@ -0,0 +1,127 @@
+import base64
+
+import pytest
+
+import langbot.pkg.core.app # noqa: F401
+import langbot_plugin.api.entities.builtin.platform.message as platform_message
+from langbot.libs.wecom_ai_bot_api.ws_client import _UPLOAD_CHUNK_SIZE, WecomBotWsClient
+from langbot.pkg.platform.sources.wecombot import WecomBotAdapter, WecomBotMessageConverter
+
+
+class Logger:
+ def __init__(self):
+ self.warnings = []
+ self.errors = []
+
+ async def warning(self, message):
+ self.warnings.append(message)
+
+ async def error(self, message):
+ self.errors.append(message)
+
+ async def info(self, message):
+ return None
+
+
+class UploadClient(WecomBotWsClient):
+ def __init__(self):
+ super().__init__(bot_id='bot', secret='secret', logger=Logger())
+ self.frames = []
+
+ async def _send_reply(self, req_id: str, body: dict, cmd: str = 'aibot_respond_msg'):
+ self.frames.append((cmd, body))
+ if cmd == 'aibot_upload_media_init':
+ return {'errcode': 0, 'body': {'upload_id': 'upload-1'}}
+ if cmd == 'aibot_upload_media_finish':
+ return {'errcode': 0, 'body': {'media_id': 'media-1'}}
+ return {'errcode': 0}
+
+
+class Bot:
+ def __init__(self):
+ self.calls = []
+
+ async def upload_media(self, data, filename='attachment', media_type='file'):
+ self.calls.append(('upload_media', media_type, filename, data))
+ return {'media_id': 'media-1'}
+
+ async def reply_text(self, req_id, content):
+ self.calls.append(('reply_text', req_id, content))
+
+ async def reply_image(self, req_id, media_id):
+ self.calls.append(('reply_image', req_id, media_id))
+
+ async def send_message(self, target_id, content):
+ self.calls.append(('send_message', target_id, content))
+
+
+def make_adapter(bot):
+ return WecomBotAdapter.model_construct(
+ bot=bot,
+ config={'enable-webhook': False},
+ logger=Logger(),
+ message_converter=WecomBotMessageConverter(),
+ )
+
+
+@pytest.mark.asyncio
+async def test_ws_client_upload_media_uses_chunk_protocol():
+ client = UploadClient()
+ data = b'a' * (_UPLOAD_CHUNK_SIZE + 1)
+
+ upload_result = await client.upload_media(data, 'image.png', media_type='image')
+
+ assert upload_result['media_id'] == 'media-1'
+ assert [cmd for cmd, _ in client.frames] == [
+ 'aibot_upload_media_init',
+ 'aibot_upload_media_chunk',
+ 'aibot_upload_media_chunk',
+ 'aibot_upload_media_finish',
+ ]
+ init_body = client.frames[0][1]
+ assert init_body['type'] == 'image'
+ assert init_body['filename'] == 'image.png'
+ assert init_body['total_size'] == len(data)
+ assert init_body['total_chunks'] == 2
+ assert client.frames[1][1]['chunk_index'] == 0
+ assert base64.b64decode(client.frames[1][1]['base64_data']) == b'a' * _UPLOAD_CHUNK_SIZE
+ assert client.frames[2][1]['chunk_index'] == 1
+ assert base64.b64decode(client.frames[2][1]['base64_data']) == b'a'
+
+
+@pytest.mark.asyncio
+async def test_reply_message_uploads_and_replies_image_media():
+ bot = Bot()
+ adapter = make_adapter(bot)
+ png_data = b'\x89PNG\r\n\x1a\nimage'
+ image_b64 = base64.b64encode(png_data).decode('utf-8')
+ chain = platform_message.MessageChain([platform_message.Image(base64=f'data:image/png;base64,{image_b64}')])
+
+ items = await WecomBotMessageConverter.yiri2target(chain)
+ await adapter._send_media(bot, 'req-1', items[0])
+
+ assert bot.calls == [
+ ('upload_media', 'image', 'attachment.image', png_data),
+ ('reply_image', 'req-1', 'media-1'),
+ ]
+
+
+@pytest.mark.asyncio
+async def test_send_message_sends_text_and_skips_proactive_image():
+ bot = Bot()
+ adapter = make_adapter(bot)
+ jpg_data = b'\xff\xd8\xffimage'
+ image_b64 = base64.b64encode(jpg_data).decode('utf-8')
+ chain = platform_message.MessageChain(
+ [
+ platform_message.Plain(text='before'),
+ platform_message.Image(base64=f'data:image/jpeg;base64,{image_b64}'),
+ platform_message.Plain(text='after'),
+ ]
+ )
+
+ await adapter.send_message('group', 'chat-1', chain)
+
+ assert bot.calls == [
+ ('send_message', 'chat-1', 'beforeafter'),
+ ]
diff --git a/tests/unit_tests/plugin/test_connector_ping.py b/tests/unit_tests/plugin/test_connector_ping.py
index 3b3aee016..d44b1ca2b 100644
--- a/tests/unit_tests/plugin/test_connector_ping.py
+++ b/tests/unit_tests/plugin/test_connector_ping.py
@@ -15,7 +15,7 @@ from langbot_plugin.runtime.security import (
)
-def make_connector() -> PluginRuntimeConnector:
+def make_connector(*, cloud: bool = False) -> PluginRuntimeConnector:
app = SimpleNamespace(
logger=Mock(),
instance_config=SimpleNamespace(
@@ -34,6 +34,7 @@ def make_connector() -> PluginRuntimeConnector:
'space': {'url': ''},
}
),
+ deployment=SimpleNamespace(mode='cloud' if cloud else 'oss'),
)
return PluginRuntimeConnector(app, AsyncMock())
@@ -332,6 +333,14 @@ def test_external_runtime_control_headers_are_empty_when_secret_is_unset(monkeyp
assert connector._control_headers(allow_generate=False) == {}
+def test_cloud_runtime_rejects_missing_control_secret(monkeypatch):
+ monkeypatch.delenv(PLUGIN_RUNTIME_CONTROL_TOKEN_ENV, raising=False)
+ connector = make_connector(cloud=True)
+
+ with pytest.raises(PluginRuntimeNotConnectedError, match=PLUGIN_RUNTIME_CONTROL_TOKEN_ENV):
+ connector._control_headers(allow_generate=False)
+
+
def test_local_runtime_control_headers_generate_ephemeral_secret(monkeypatch):
monkeypatch.delenv(PLUGIN_RUNTIME_CONTROL_TOKEN_ENV, raising=False)
connector = make_connector()
diff --git a/tests/unit_tests/plugin/test_connector_reconcile.py b/tests/unit_tests/plugin/test_connector_reconcile.py
index 8759a537e..d4d9b6b5b 100644
--- a/tests/unit_tests/plugin/test_connector_reconcile.py
+++ b/tests/unit_tests/plugin/test_connector_reconcile.py
@@ -107,6 +107,19 @@ def shared_connector(
return connector
+@pytest.mark.asyncio
+async def test_shared_reconcile_uses_configured_cold_start_timeout():
+ binding = execution_binding("workspace-a")
+ setting = plugin_setting("01", "a" * 64)
+ connector = shared_connector([[binding]], {"workspace-a": [setting]})
+ connector.ap.instance_config.data["plugin"]["connect_timeout_seconds"] = 900
+ connector.handler = runtime_handler()
+
+ await connector._prepare_connected_runtime()
+
+ assert connector.handler.reconcile_plugin_installations.await_args.kwargs["timeout"] == 900
+
+
@pytest.mark.asyncio
async def test_shared_reconnect_replays_two_workspaces_and_removes_missing_projection():
binding_a = execution_binding('workspace-a')
@@ -150,7 +163,7 @@ async def test_empty_projected_workspaces_do_not_retain_installation_sets():
assert connector._workspace_installations == {}
assert connector._known_desired_states == {}
- connector.handler.reconcile_plugin_installations.assert_awaited_once_with(())
+ connector.handler.reconcile_plugin_installations.assert_awaited_once_with((), timeout=300.0)
@pytest.mark.asyncio
diff --git a/tests/unit_tests/plugin/test_handler.py b/tests/unit_tests/plugin/test_handler.py
index c0339d3c4..844dcdc12 100644
--- a/tests/unit_tests/plugin/test_handler.py
+++ b/tests/unit_tests/plugin/test_handler.py
@@ -9,8 +9,8 @@ from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, Mock
import pytest
-from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction
-from langbot_plugin.entities.io.context import ActionContext, InstallationBinding
+from langbot_plugin.entities.io.actions.enums import LangBotToRuntimeAction, PluginToRuntimeAction
+from langbot_plugin.entities.io.context import ActionContext, InstallationBinding, PluginInstallationDesiredState
def make_handler(app):
@@ -67,6 +67,32 @@ def make_handler(app):
return runtime_handler
+@pytest.mark.asyncio
+async def test_reconcile_plugin_installations_allows_cloud_cold_start_to_finish():
+ app = SimpleNamespace()
+ runtime_handler = make_handler(app)
+ runtime_handler.call_action = AsyncMock(return_value={})
+ binding = next(iter(runtime_handler._installation_bindings.values()))[0]
+ desired = PluginInstallationDesiredState(binding=binding, enabled=True)
+
+ await runtime_handler.reconcile_plugin_installations((desired,))
+
+ assert runtime_handler.call_action.await_args.args[0] == LangBotToRuntimeAction.RECONCILE_PLUGIN_INSTALLATIONS
+ assert runtime_handler.call_action.await_args.kwargs['timeout'] == 300
+
+
+@pytest.mark.asyncio
+async def test_reconcile_plugin_installations_accepts_configured_cold_start_timeout():
+ runtime_handler = make_handler(SimpleNamespace())
+ runtime_handler.call_action = AsyncMock(return_value={})
+ binding = next(iter(runtime_handler._installation_bindings.values()))[0]
+ desired = PluginInstallationDesiredState(binding=binding, enabled=True)
+
+ await runtime_handler.reconcile_plugin_installations((desired,), timeout=900)
+
+ assert runtime_handler.call_action.await_args.kwargs["timeout"] == 900
+
+
class TestHandlerQueryVariables:
"""Tests for handler query variable logic."""
diff --git a/tests/unit_tests/plugin/test_handler_actions.py b/tests/unit_tests/plugin/test_handler_actions.py
index 4089e23be..aa0762471 100644
--- a/tests/unit_tests/plugin/test_handler_actions.py
+++ b/tests/unit_tests/plugin/test_handler_actions.py
@@ -234,6 +234,7 @@ class TestSetBinaryStorage:
},
}
mock_app.persistence_mgr = Mock()
+ mock_app.persistence_mgr.get_db_engine.return_value = SimpleNamespace(dialect=SimpleNamespace(name='sqlite'))
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=make_result())
mock_app.logger = Mock()
return mock_app
@@ -270,8 +271,8 @@ class TestSetBinaryStorage:
)
assert response.code == 0
- assert app.persistence_mgr.execute_async.await_count == 2
- insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[1].args[0])
+ assert app.persistence_mgr.execute_async.await_count == 3
+ insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[2].args[0])
assert insert_params['workspace_uuid'] == 'workspace-a'
assert insert_params['unique_key'] == canonical_binary_key(
'plugin',
@@ -301,6 +302,69 @@ class TestSetBinaryStorage:
assert expected_key in update_params.values()
assert update_params['value'] == b'new'
+ @pytest.mark.asyncio
+ async def test_adopts_legacy_storage_before_updating(self, app):
+ """A migrated pre-tenancy row is updated in place rather than duplicated."""
+ runtime_handler = make_handler(app)
+ legacy_storage = SimpleNamespace(unique_key='plugin:test-author/test-plugin:test-key')
+ adopted = SimpleNamespace(rowcount=1)
+ app.persistence_mgr.execute_async.side_effect = [
+ make_result(),
+ make_result(legacy_storage),
+ adopted,
+ ]
+
+ response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](self.payload(b'new'))
+
+ assert response.code == 0
+ assert app.persistence_mgr.execute_async.await_count == 3
+ adoption_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[2].args[0])
+ expected_key = canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key')
+ assert expected_key in adoption_params.values()
+ assert adoption_params['value'] == b'new'
+
+ @pytest.mark.asyncio
+ async def test_legacy_adoption_race_updates_winning_canonical_row(self, app):
+ runtime_handler = make_handler(app)
+ legacy_storage = SimpleNamespace(unique_key='plugin:test-author/test-plugin:test-key')
+ lost_race = SimpleNamespace(rowcount=0)
+ canonical_winner = SimpleNamespace(rowcount=1)
+ app.persistence_mgr.execute_async.side_effect = [
+ make_result(),
+ make_result(legacy_storage),
+ lost_race,
+ canonical_winner,
+ ]
+
+ response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](self.payload(b'new'))
+
+ assert response.code == 0
+ assert app.persistence_mgr.execute_async.await_count == 4
+ winner_update = compiled_params(app.persistence_mgr.execute_async.await_args_list[3].args[0])
+ assert canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key') in winner_update.values()
+ assert winner_update['value'] == b'new'
+
+ @pytest.mark.asyncio
+ async def test_legacy_adoption_lost_to_delete_inserts_new_value(self, app):
+ runtime_handler = make_handler(app)
+ legacy_storage = SimpleNamespace(unique_key='plugin:test-author/test-plugin:test-key')
+ lost_race = SimpleNamespace(rowcount=0)
+ app.persistence_mgr.execute_async.side_effect = [
+ make_result(),
+ make_result(legacy_storage),
+ lost_race,
+ SimpleNamespace(rowcount=0),
+ make_result(),
+ ]
+
+ response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](self.payload(b'new'))
+
+ assert response.code == 0
+ assert app.persistence_mgr.execute_async.await_count == 5
+ insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[4].args[0])
+ assert insert_params['unique_key'] == canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key')
+ assert insert_params['value'] == b'new'
+
@pytest.mark.asyncio
async def test_invalid_max_value_bytes_falls_back_to_default_limit(self, app):
"""Invalid max_value_bytes uses the 10MB default limit."""
@@ -525,6 +589,46 @@ class TestGetBinaryStorage:
in statement_params.values()
)
+ @pytest.mark.asyncio
+ async def test_reads_legacy_storage_without_mutating_key(self, app):
+ runtime_handler = make_handler(app)
+ legacy_storage = SimpleNamespace(
+ unique_key='plugin:test-author/test-plugin:test-key',
+ value=b'legacy bytes',
+ )
+ app.persistence_mgr.execute_async.side_effect = [
+ make_result(),
+ make_result(legacy_storage),
+ ]
+
+ response = await runtime_handler.actions[RuntimeToLangBotAction.GET_BINARY_STORAGE.value](
+ {'key': 'test-key', 'owner_type': 'plugin', 'owner': 'ignored'}
+ )
+
+ assert response.code == 0
+ assert base64.b64decode(response.data['value_base64']) == b'legacy bytes'
+ assert app.persistence_mgr.execute_async.await_count == 2
+
+ @pytest.mark.asyncio
+ async def test_retries_canonical_after_concurrent_legacy_adoption(self, app):
+ runtime_handler = make_handler(app)
+ canonical_storage = SimpleNamespace(value=b'adopted bytes')
+ app.persistence_mgr.execute_async.side_effect = [
+ make_result(),
+ make_result(),
+ make_result(canonical_storage),
+ ]
+
+ response = await runtime_handler.actions[RuntimeToLangBotAction.GET_BINARY_STORAGE.value](
+ {'key': 'test-key', 'owner_type': 'plugin', 'owner': 'ignored'}
+ )
+
+ assert response.code == 0
+ assert base64.b64decode(response.data['value_base64']) == b'adopted bytes'
+ assert app.persistence_mgr.execute_async.await_count == 3
+ retry_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[2].args[0])
+ assert canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key') in retry_params.values()
+
@pytest.mark.asyncio
async def test_returns_error_when_not_found(self, app):
"""Missing binary storage rows return an error response."""
@@ -567,21 +671,47 @@ class TestDeleteAndListBinaryStorage:
assert response.code == 0
statement_params = compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
- assert 'workspace-a' in statement_params.values()
+ flat_values = [
+ item for value in statement_params.values() for item in (value if isinstance(value, list) else [value])
+ ]
+ assert 'workspace-a' in flat_values
assert (
canonical_binary_key(
'plugin',
'test-author/test-plugin',
'test-key',
)
- in statement_params.values()
+ in flat_values
)
- assert 'forged-owner' not in statement_params.values()
+ assert 'forged-owner' not in flat_values
+
+ @pytest.mark.asyncio
+ async def test_delete_removes_canonical_and_legacy_scoped_keys(self, app):
+ runtime_handler = make_handler(app)
+
+ response = await runtime_handler.actions[RuntimeToLangBotAction.DELETE_BINARY_STORAGE.value](
+ {
+ 'key': 'test-key',
+ 'owner_type': 'plugin',
+ 'owner': 'forged-owner',
+ }
+ )
+
+ assert response.code == 0
+ statement_params = compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
+ values = [
+ item for value in statement_params.values() for item in (value if isinstance(value, list) else [value])
+ ]
+ assert 'workspace-a' in values
+ assert canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key') in values
+ assert 'plugin:test-author/test-plugin:test-key' in values
+ assert 'test-author/test-plugin' in values
+ assert 'forged-owner' not in values
@pytest.mark.asyncio
async def test_list_keys_uses_trusted_plugin_owner(self, app):
result = Mock()
- result.scalars.return_value.all.return_value = ['first', 'second']
+ result.scalars.return_value.all.return_value = ['first', 'second', 'first']
app.persistence_mgr.execute_async.return_value = result
runtime_handler = make_handler(app)
diff --git a/tests/unit_tests/plugin/test_handler_tenancy.py b/tests/unit_tests/plugin/test_handler_tenancy.py
index 43856b9cf..371123eb4 100644
--- a/tests/unit_tests/plugin/test_handler_tenancy.py
+++ b/tests/unit_tests/plugin/test_handler_tenancy.py
@@ -444,3 +444,23 @@ async def test_host_to_runtime_action_carries_trusted_connector_context():
'runtime_id': 'runtime-a',
}
assert request.get('context') is None
+
+
+@pytest.mark.asyncio
+async def test_get_debug_info_converts_execution_context_to_sdk_action_context():
+ runtime_handler, _app, _installation_context = make_handler()
+ runtime_handler.call_action = AsyncMock(return_value={'plugin_debug_key': 'debug-key'})
+ execution_context = ExecutionContext(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=7,
+ )
+
+ result = await runtime_handler.get_debug_info(execution_context)
+
+ assert result == {'plugin_debug_key': 'debug-key'}
+ assert runtime_handler.call_action.await_args.kwargs['action_context'] == ActionContext(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=7,
+ )
diff --git a/tests/unit_tests/test_optional_dependencies.py b/tests/unit_tests/test_optional_dependencies.py
new file mode 100644
index 000000000..841344906
--- /dev/null
+++ b/tests/unit_tests/test_optional_dependencies.py
@@ -0,0 +1,15 @@
+from __future__ import annotations
+
+import tomllib
+from pathlib import Path
+
+
+def test_seekdb_is_only_declared_as_an_optional_dependency() -> None:
+ project_root = Path(__file__).resolve().parents[2]
+ with (project_root / 'pyproject.toml').open('rb') as pyproject_file:
+ pyproject = tomllib.load(pyproject_file)
+
+ project = pyproject['project']
+ base_dependencies = project['dependencies']
+ assert not any(dependency.lower().startswith('pyseekdb') for dependency in base_dependencies)
+ assert project['optional-dependencies']['seekdb'] == ['pyseekdb==1.1.0.post3']
diff --git a/tests/unit_tests/vector/test_seekdb_optional.py b/tests/unit_tests/vector/test_seekdb_optional.py
new file mode 100644
index 000000000..f30e6ac20
--- /dev/null
+++ b/tests/unit_tests/vector/test_seekdb_optional.py
@@ -0,0 +1,34 @@
+from __future__ import annotations
+
+import importlib
+from unittest.mock import MagicMock
+
+import pytest
+
+from tests.utils.import_isolation import isolated_sys_modules
+
+
+_INSTALL_HINT = "Install LangBot with the 'seekdb' extra"
+
+
+def test_seekdb_vector_backend_reports_missing_optional_extra() -> None:
+ module_name = 'langbot.pkg.vector.vdbs.seekdb'
+
+ with isolated_sys_modules({'pyseekdb': None}, clear=[module_name]):
+ seekdb_module = importlib.import_module(module_name)
+
+ assert seekdb_module.SEEKDB_AVAILABLE is False
+ with pytest.raises(ImportError, match=_INSTALL_HINT):
+ seekdb_module.SeekDBVectorDatabase(MagicMock())
+
+
+@pytest.mark.asyncio
+async def test_seekdb_embedding_reports_missing_optional_extra() -> None:
+ module_name = 'langbot.pkg.provider.modelmgr.requesters.seekdbembed'
+
+ with isolated_sys_modules({'pyseekdb': None}, clear=[module_name]):
+ seekdb_embedding_module = importlib.import_module(module_name)
+ requester = seekdb_embedding_module.SeekDBEmbedding.__new__(seekdb_embedding_module.SeekDBEmbedding)
+
+ with pytest.raises(ImportError, match=_INSTALL_HINT):
+ await requester.initialize()
diff --git a/tests/unit_tests/workspace/test_invitation_delivery.py b/tests/unit_tests/workspace/test_invitation_delivery.py
index 34a7f5512..91a7136ae 100644
--- a/tests/unit_tests/workspace/test_invitation_delivery.py
+++ b/tests/unit_tests/workspace/test_invitation_delivery.py
@@ -88,14 +88,15 @@ async def test_environment_mapping_enables_provider_without_leaking_secret(monke
assert service.capability() == {'enabled': True, 'provider': 'smtp'}
-async def test_cloud_invitation_email_has_branded_html_plain_fallback_and_expiry_copy():
+async def test_invitation_email_has_generic_langbot_brand_plain_fallback_and_expiry_copy():
service = InvitationDeliveryService(_app({}))
link = 'https://cloud.langbot.app/invitations/accept#token=lbi_secret&next='
text = service._plain_text('Research & Development', link)
html = service._html('Research & Development', link)
- assert 'LangBot Cloud' in text
+ assert 'LangBot' in text
+ assert 'LangBot Cloud' not in text
assert 'Research & Development' in text
assert '7 days' in text
assert link in text
@@ -103,3 +104,55 @@ async def test_cloud_invitation_email_has_branded_html_plain_fallback_and_expiry
assert 'Research & Development' in html
assert 'expires in 7 days' in html
assert 'lbi_secret&next=<unsafe>' in html
+ assert 'LangBot Cloud' not in html
+
+
+async def test_invitation_email_uses_quiet_brand_lockup_and_compact_fallback_link():
+ service = InvitationDeliveryService(_app({}))
+ link = 'https://cloud.langbot.app/invitations/accept#token=lbi_secret'
+
+ html = service._html("RockChinQ's Workspace", link)
+
+ assert 'https://docs.langbot.app/langbot-logo.png' in html
+ assert '>LangBot<' in html
+ assert 'Workspace invitation' in html
+ assert 'Open invitation link' in html
+ assert 'linear-gradient' not in html
+ assert 'box-shadow' not in html
+ assert 'border-top:4px solid' not in html
+ assert 'border:1px solid #dfe6f0' not in html
+ assert 'height="28"' in html
+ assert 'height="32"' in html
+ assert 'margin-top:32px' not in html
+ assert f'>{link}<' not in html
+
+
+async def test_oss_smtp_configuration_delivers_the_generic_invitation_email():
+ service = InvitationDeliveryService(
+ _app(
+ {
+ 'workspace': {
+ 'invitations': {
+ 'email': {
+ 'provider': 'smtp',
+ 'from': 'LangBot ',
+ 'smtp': {'host': 'smtp.example.com'},
+ }
+ }
+ }
+ }
+ )
+ )
+ service._send_smtp = AsyncMock(return_value=True)
+ link = 'https://self-hosted.example/invitations/accept#token=lbi_secret'
+
+ result = await service.deliver_invitation(
+ recipient_email='member@example.com',
+ workspace_name='Self-hosted Workspace',
+ invitation_link=link,
+ )
+
+ assert result == InvitationDeliveryResult(status='sent', provider='smtp')
+ service._send_smtp.assert_awaited_once()
+ assert 'LangBot Cloud' not in service._plain_text('Self-hosted Workspace', link)
+ assert 'LangBot Cloud' not in service._html('Self-hosted Workspace', link)
diff --git a/uv.lock b/uv.lock
index 7866686ed..743cdf599 100644
--- a/uv.lock
+++ b/uv.lock
@@ -9,10 +9,10 @@ resolution-markers = [
"python_full_version == '3.13.*' and sys_platform == 'emscripten'",
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
"python_full_version == '3.12.*' and sys_platform == 'win32'",
- "python_full_version < '3.12' and sys_platform == 'win32'",
"python_full_version == '3.12.*' and sys_platform == 'emscripten'",
- "python_full_version < '3.12' and sys_platform == 'emscripten'",
"python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
+ "python_full_version < '3.12' and sys_platform == 'win32'",
+ "python_full_version < '3.12' and sys_platform == 'emscripten'",
"python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'",
]
@@ -2008,7 +2008,7 @@ wheels = [
[[package]]
name = "langbot"
-version = "4.10.7"
+version = "4.10.8"
source = { editable = "." }
dependencies = [
{ name = "aiocqhttp" },
@@ -2063,7 +2063,6 @@ dependencies = [
{ name = "pymilvus" },
{ name = "pynacl" },
{ name = "pypdf2" },
- { name = "pyseekdb" },
{ name = "python-docx" },
{ name = "python-multipart" },
{ name = "python-socks" },
@@ -2089,6 +2088,11 @@ dependencies = [
{ name = "websockets" },
]
+[package.optional-dependencies]
+seekdb = [
+ { name = "pyseekdb" },
+]
+
[package.dev-dependencies]
dev = [
{ name = "moto" },
@@ -2125,7 +2129,7 @@ requires-dist = [
{ name = "ebooklib", specifier = ">=0.18" },
{ name = "gewechat-client", specifier = ">=0.1.5" },
{ name = "html2text", specifier = ">=2024.2.26" },
- { name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=9d216208cdfb41f0cb7fcb64632e2a46816d6dc6" },
+ { name = "langbot-plugin", specifier = "==0.5.5" },
{ name = "langchain", specifier = ">=1.3.9" },
{ name = "langchain-core", specifier = ">=1.3.3" },
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
@@ -2153,7 +2157,7 @@ requires-dist = [
{ name = "pymilvus", specifier = ">=2.6.4" },
{ name = "pynacl", specifier = ">=1.5.0" },
{ name = "pypdf2", specifier = ">=3.0.1" },
- { name = "pyseekdb", specifier = "==1.1.0.post3" },
+ { name = "pyseekdb", marker = "extra == 'seekdb'", specifier = "==1.1.0.post3" },
{ name = "python-docx", specifier = ">=1.1.0" },
{ name = "python-multipart", specifier = ">=0.0.27" },
{ name = "python-socks", specifier = ">=2.7.1" },
@@ -2178,6 +2182,7 @@ requires-dist = [
{ name = "valkey-glide", marker = "sys_platform != 'win32'", specifier = ">=2.4.1,<3.0.0" },
{ name = "websockets", specifier = ">=15.0.1" },
]
+provides-extras = ["seekdb"]
[package.metadata.requires-dev]
dev = [
@@ -2191,8 +2196,8 @@ dev = [
[[package]]
name = "langbot-plugin"
-version = "0.5.0"
-source = { git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=9d216208cdfb41f0cb7fcb64632e2a46816d6dc6#9d216208cdfb41f0cb7fcb64632e2a46816d6dc6" }
+version = "0.5.5"
+source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiofiles" },
{ name = "aiohttp" },
@@ -2212,6 +2217,10 @@ dependencies = [
{ name = "watchdog" },
{ name = "websockets" },
]
+sdist = { url = "https://files.pythonhosted.org/packages/c3/be/1bbdf959d8c16b625e3721cde586b3bb22eaa22dd8c22d072c04f9b491ba/langbot_plugin-0.5.5.tar.gz", hash = "sha256:ea31b0ddf64c2ef8fdec012273b2d3dee6f0d140475f07694f31ea685be40695", size = 472639, upload-time = "2026-08-16T17:33:27.783Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/00/30/72caa601b571542fa4de5f2a3461d6f601f75c52d484d9fc95ebb82ce30c/langbot_plugin-0.5.5-py3-none-any.whl", hash = "sha256:a55d20a0c015414ef85d783b493f83d27b64f1d662887de94330df9d3d4ab64e", size = 304643, upload-time = "2026-08-16T17:33:26.687Z" },
+]
[[package]]
name = "langchain"
diff --git a/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx b/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx
index 5865dbb4a..439b99f9d 100644
--- a/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx
+++ b/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx
@@ -15,6 +15,7 @@ import {
XCircle,
} from 'lucide-react';
import QRCode from 'qrcode';
+import { getActiveWorkspaceUuid } from '@/app/infra/http/workspaceContext';
export type QrLoginPlatform =
| 'feishu'
@@ -55,12 +56,12 @@ const PLATFORM_CONFIGS: Record = {
},
weixin: {
titleKey: 'weixin.scanLogin',
- connectingKey: 'feishu.connecting',
+ connectingKey: 'weixin.connecting',
scanQRCodeKey: 'weixin.scanQRCode',
- waitingKey: 'feishu.waitingForScan',
+ waitingKey: 'weixin.waitingForScan',
successKey: 'weixin.loginSuccess',
failedKey: 'weixin.loginFailed',
- retryKey: 'feishu.retry',
+ retryKey: 'weixin.retry',
apiBase: '/api/v1/platform/adapters/weixin/login',
extractSuccess: (data) => ({
token: data.token,
@@ -146,6 +147,8 @@ export default function QrCodeLoginDialog({
const checkExpiredRef = useRef | null>(null);
const abortRef = useRef(null);
const sessionIdRef = useRef(null);
+ const sessionWorkspaceUuidRef = useRef(null);
+ const sessionApiBaseRef = useRef('');
const baseUrlRef = useRef('');
const cleanedRef = useRef(false);
@@ -180,18 +183,23 @@ export default function QrCodeLoginDialog({
}
if (sessionIdRef.current) {
const token = localStorage.getItem('token');
- const baseUrl =
- import.meta.env.VITE_API_BASE_URL || window.location.origin;
+ const workspaceUuid = sessionWorkspaceUuidRef.current;
fetch(
- `${baseUrl}${platformConfigRef.current.apiBase}/${sessionIdRef.current}`,
+ `${baseUrlRef.current}${sessionApiBaseRef.current}/${sessionIdRef.current}`,
{
method: 'DELETE',
- headers: { Authorization: `Bearer ${token}` },
+ headers: {
+ Authorization: `Bearer ${token}`,
+ ...(workspaceUuid ? { 'X-Workspace-Id': workspaceUuid } : {}),
+ },
keepalive: true,
},
).catch(() => {});
sessionIdRef.current = null;
}
+ sessionWorkspaceUuidRef.current = null;
+ sessionApiBaseRef.current = '';
+ baseUrlRef.current = '';
}, []);
const startLogin = useCallback(async () => {
@@ -204,6 +212,7 @@ export default function QrCodeLoginDialog({
setSuccessMeta('');
const token = localStorage.getItem('token');
+ const workspaceUuid = getActiveWorkspaceUuid();
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin;
baseUrlRef.current = baseUrl;
const cfg = platformConfigRef.current;
@@ -214,7 +223,10 @@ export default function QrCodeLoginDialog({
const res = await fetch(`${baseUrl}${cfg.apiBase}`, {
method: 'POST',
- headers: { Authorization: `Bearer ${token}` },
+ headers: {
+ Authorization: `Bearer ${token}`,
+ ...(workspaceUuid ? { 'X-Workspace-Id': workspaceUuid } : {}),
+ },
signal: controller.signal,
});
@@ -225,6 +237,8 @@ export default function QrCodeLoginDialog({
const { session_id, qr_data_url, qr_url, expire_at } = json.data;
sessionIdRef.current = session_id;
+ sessionWorkspaceUuidRef.current = workspaceUuid;
+ sessionApiBaseRef.current = cfg.apiBase;
if (qr_data_url) {
setQrDataUrl(qr_data_url);
@@ -270,11 +284,19 @@ export default function QrCodeLoginDialog({
`${baseUrlRef.current}${cfg.apiBase}/${sessionIdRef.current}`,
{
method: 'DELETE',
- headers: { Authorization: `Bearer ${token}` },
+ headers: {
+ Authorization: `Bearer ${token}`,
+ ...(workspaceUuid
+ ? { 'X-Workspace-Id': workspaceUuid }
+ : {}),
+ },
keepalive: true,
},
).catch(() => {});
sessionIdRef.current = null;
+ sessionWorkspaceUuidRef.current = null;
+ sessionApiBaseRef.current = '';
+ baseUrlRef.current = '';
}
setState('expired');
}
@@ -286,7 +308,12 @@ export default function QrCodeLoginDialog({
try {
const pollRes = await fetch(
`${baseUrl}${cfg.apiBase}/status/${session_id}`,
- { headers: { Authorization: `Bearer ${token}` } },
+ {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ ...(workspaceUuid ? { 'X-Workspace-Id': workspaceUuid } : {}),
+ },
+ },
);
if (!pollRes.ok) return;
diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts
index 8dec1248b..4a57956ea 100644
--- a/web/src/app/infra/http/BackendClient.ts
+++ b/web/src/app/infra/http/BackendClient.ts
@@ -721,11 +721,32 @@ export class BackendClient extends BaseHttpClient {
);
}
- private async getAuthenticatedObjectURL(path: string): Promise {
+ private async getAuthenticatedObjectURL(
+ path: string,
+ rewritePluginPageSdk = false,
+ ): Promise {
const response = await this.instance.get(path, {
responseType: 'blob',
});
- return URL.createObjectURL(response.data);
+ let blob = response.data;
+ if (rewritePluginPageSdk && blob.type.startsWith('text/html')) {
+ const apiBase =
+ this.instance.defaults.baseURL === '/'
+ ? window.location.origin
+ : this.instance.defaults.baseURL?.replace(/\/$/, '');
+ const pageSdkUrl = `${apiBase}/api/v1/plugins/_sdk/page-sdk.js`;
+ const html = await blob.text();
+ blob = new Blob(
+ [
+ html.replace(
+ /(
+
+