mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-05 00:57:14 +00:00
Compare commits
76 Commits
7f29d51c69
...
v4.10.10
| Author | SHA1 | Date | |
|---|---|---|---|
| a63808caa6 | |||
| de3c0b00ad | |||
| cb45807b12 | |||
| d942bfe19a | |||
| b44b8f474d | |||
| ab52684a01 | |||
| d50957fc4f | |||
| c8d8b1aac4 | |||
| 018dd7a363 | |||
| 7b7d3f04e8 | |||
| 601c6975ea | |||
| 5ca30133a3 | |||
| 5c49cb60e3 | |||
| 8cf0015502 | |||
| bf8d418ad4 | |||
| 7aab0cee07 | |||
| 1b7ae791b3 | |||
| e69a80f5e9 | |||
| bafdaf0033 | |||
| aeff8d7e30 | |||
| be3734ffda | |||
| 855ae2bdba | |||
| b66db86bff | |||
| 95b8736e93 | |||
| cabde423a1 | |||
| 08307790e5 | |||
| 777fe1f20b | |||
| f0ee57c1e0 | |||
| a45e27e76e | |||
| 536fcdf29f | |||
| c87548c0b9 | |||
| 79634772da | |||
| bb366779af | |||
| 1336f47cb4 | |||
| 962366c507 | |||
| 23875b240f | |||
| e699358a5a | |||
| 14277d129c | |||
| 6bf1546df2 | |||
| 0bec72a3f9 | |||
| f36542135a | |||
| 693c59b726 | |||
| c3fe312a43 | |||
| c4bad508d2 | |||
| 3d4a726cd8 | |||
| e934f08adf | |||
| 7803d56254 | |||
| 54c96a18e1 | |||
| 579e3556e4 | |||
| a08a177a11 | |||
| c224f61c8c | |||
| b62cc9da45 | |||
| 700104c015 | |||
| 717bd4b8bf | |||
| 0cc0e1b02d | |||
| 1f1a3aff55 | |||
| de28b3160c | |||
| 79773b669a | |||
| 5084f2391d | |||
| b121c66f18 | |||
| 536534865e | |||
| ff528e8e0b | |||
| f7914a8900 | |||
| c18fc9dfe3 | |||
| bc40418bda | |||
| 45ed6efceb | |||
| c67a503532 | |||
| 8b14d2ab8e | |||
| 97428310b9 | |||
| 338ee733cb | |||
| c3299fd1a6 | |||
| be8478e940 | |||
| 96a535827f | |||
| 9b81a0b606 | |||
| bbc912d0ef | |||
| 20710df9cb |
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -83,6 +83,7 @@ cd LangBot/docker
|
||||
docker compose --profile all up -d
|
||||
```
|
||||
|
||||
|
||||
### One-Click Cloud Deploy
|
||||
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
|
||||
@@ -83,6 +83,7 @@ cd LangBot/docker
|
||||
docker compose --profile all up -d
|
||||
```
|
||||
|
||||
|
||||
### 一键云部署
|
||||
|
||||
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -82,6 +82,7 @@ cd LangBot/docker
|
||||
docker compose --profile all up -d
|
||||
```
|
||||
|
||||
|
||||
### ワンクリッククラウドデプロイ
|
||||
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
|
||||
@@ -82,6 +82,7 @@ cd LangBot/docker
|
||||
docker compose --profile all up -d
|
||||
```
|
||||
|
||||
|
||||
### 원클릭 클라우드 배포
|
||||
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
|
||||
@@ -82,6 +82,7 @@ cd LangBot/docker
|
||||
docker compose --profile all up -d
|
||||
```
|
||||
|
||||
|
||||
### Облачное развертывание одним кликом
|
||||
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
|
||||
@@ -84,6 +84,7 @@ cd LangBot/docker
|
||||
docker compose --profile all up -d
|
||||
```
|
||||
|
||||
|
||||
### 一鍵雲端部署
|
||||
|
||||
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+34
-43
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+7
-3
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "langbot"
|
||||
version = "4.10.7"
|
||||
version = "4.10.10"
|
||||
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.7",
|
||||
"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"
|
||||
|
||||
@@ -1349,7 +1349,8 @@
|
||||
"local-agent",
|
||||
"tools",
|
||||
"e2b",
|
||||
"nsjail"
|
||||
"nsjail",
|
||||
"host"
|
||||
],
|
||||
"automation": "",
|
||||
"setup_automation": [],
|
||||
|
||||
@@ -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
|
||||
@@ -62,7 +63,7 @@ Key settings:
|
||||
| `api.global_api_key` | **Global API key** for the HTTP API + MCP server. Non-empty = accepted with no login/DB record; no `lbk_` prefix required. Empty = disabled. Plaintext — trusted/internal only, serve over HTTPS. |
|
||||
| `plugin.runtime_ws_url` | Standalone plugin runtime WS URL (e.g. `ws://langbot_plugin_runtime:5400/control/ws`) |
|
||||
| `box.enabled` | Master switch for the Box sandbox runtime |
|
||||
| `box.backend` | `local` (Docker/nsjail autopick) / `docker` / `nsjail` / `e2b`; env override `BOX__BACKEND` |
|
||||
| `box.backend` | `local` (Docker/nsjail autopick) / `docker` / `nsjail` / `e2b` / explicit unsafe `host`; env override `BOX__BACKEND` |
|
||||
| `box.runtime.endpoint` | External Box runtime URL (e.g. `ws://127.0.0.1:5410`); empty = local auto-managed |
|
||||
|
||||
Many keys have `ENV__SUBKEY` overrides (e.g. `BOX__BACKEND`, `BOX__ENABLED`).
|
||||
@@ -74,6 +75,10 @@ Many keys have `ENV__SUBKEY` overrides (e.g. `BOX__BACKEND`, `BOX__ENABLED`).
|
||||
with `--standalone-runtime`.
|
||||
- Box has a parallel `--standalone-box` flag; the Docker box host is
|
||||
`langbot_box:5410`.
|
||||
- `box.backend: host` runs commands directly as the Box Runtime system user.
|
||||
It is never auto-selected, provides no sandbox isolation, and is only for
|
||||
trusted local development. A WebSocket-controlled host backend requires
|
||||
`LANGBOT_BOX_CONTROL_TOKEN`; local stdio control is allowed.
|
||||
|
||||
## Global API key — enabling for agents/automation
|
||||
|
||||
@@ -92,5 +97,7 @@ login session. See `langbot-mcp-ops` for using it, and `docs/API_KEY_AUTH.md`.
|
||||
- "No supported sandbox backend (Docker / nsjail / E2B)" with Docker running
|
||||
usually means the user isn't in the `docker` group →
|
||||
`sudo usermod -aG docker <user>` and restart in a new shell.
|
||||
- Do not use `box.backend: host` as a production fallback. It cannot enforce
|
||||
image, filesystem, network, PID, CPU, memory, or storage isolation.
|
||||
- Box root host/container path mismatch breaks sandbox container creation.
|
||||
- Don't commit a non-empty `api.global_api_key` to version control.
|
||||
|
||||
@@ -75,6 +75,8 @@ shape as the corresponding HTTP API request body. Discover resources with the
|
||||
`list_*` / `get_*` tools before mutating; identifiers are UUIDs. Reads require
|
||||
`resource.view`; mutations require `resource.manage`. All service calls inherit
|
||||
the immutable Workspace context authenticated at the MCP transport boundary.
|
||||
Pass `is_default: true` to `create_pipeline` only when the Workspace does not
|
||||
already have a default pipeline.
|
||||
|
||||
## How to use
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -13,6 +13,7 @@ tags:
|
||||
- tools
|
||||
- e2b
|
||||
- nsjail
|
||||
- host
|
||||
skills:
|
||||
- langbot-env-setup
|
||||
- langbot-testing
|
||||
@@ -23,7 +24,7 @@ env:
|
||||
- LANGBOT_LOCAL_AGENT_PIPELINE_NAME
|
||||
preconditions:
|
||||
- "LANGBOT_LOCAL_AGENT_PIPELINE_URL or LANGBOT_LOCAL_AGENT_PIPELINE_NAME points to the local-agent pipeline under test."
|
||||
- "LangBot is started with the sandbox backend intended for this run, such as e2b or nsjail."
|
||||
- "LangBot is started with the Box backend intended for this run, such as e2b, nsjail, or explicit host development mode."
|
||||
- "The selected model route supports tool/function calling strongly enough to invoke sandbox tools."
|
||||
steps:
|
||||
- "Start LangBot with the target sandbox backend and confirm the Box status UI or LANGBOT_BACKEND_URL /api/v1/box/status reports the expected backend."
|
||||
@@ -33,7 +34,7 @@ steps:
|
||||
checks:
|
||||
- "UI: Debug Chat final assistant response contains E2E_OK:<skill-name>."
|
||||
- "Logs: The model called exec, register_skill, activate, then exec again from the activated skill path."
|
||||
- "Logs: The selected backend name is the expected one, such as e2b or nsjail."
|
||||
- "Logs: The selected backend name is the expected one, such as e2b, nsjail, or host."
|
||||
- "Skill store: The registered package and activated writeback match references/sandbox-skill-authoring.md."
|
||||
- "Box status: recent_error_count is 0 after the run."
|
||||
evidence_required:
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Verify that Local Agent can use sandbox tools to create, register, activate, and use a LangBot skill package through the same path a user would exercise in Debug Chat.
|
||||
|
||||
This flow applies to Docker, nsjail, and E2B backends. API calls are useful diagnostics, but the primary pass/fail signal is the model-driven Debug Chat tool sequence.
|
||||
This flow applies to Docker, nsjail, E2B, and the explicit host development backend. Host runs commands directly as the Box Runtime user and must never be treated as sandbox-isolation coverage. API calls are useful diagnostics, but the primary pass/fail signal is the model-driven Debug Chat tool sequence.
|
||||
|
||||
## Preconditions
|
||||
|
||||
@@ -13,6 +13,7 @@ This flow applies to Docker, nsjail, and E2B backends. API calls are useful diag
|
||||
- `BOX_BACKEND=e2b` when validating E2B.
|
||||
- `BOX_BACKEND=nsjail` when validating nsjail.
|
||||
- `BOX_BACKEND=local` or `docker` when validating local container fallback.
|
||||
- `BOX_BACKEND=host` only when validating explicit, trusted local direct execution.
|
||||
3. Confirm `/api/v1/box/status` reports `available: true` and the expected backend name.
|
||||
4. Confirm Debug Chat uses a model with function-calling ability.
|
||||
5. Confirm backend logs say native sandbox tools are available.
|
||||
@@ -71,7 +72,7 @@ Backend logs should show:
|
||||
- `register_skill`
|
||||
- `activate`
|
||||
- a second `exec` whose workdir is `/workspace/.skills/<skill-name>`
|
||||
- `backend=e2b`, `backend=nsjail`, or the expected local backend
|
||||
- `backend=e2b`, `backend=nsjail`, `backend=host`, or the expected local backend
|
||||
|
||||
After the run, verify the skill store through the UI or API:
|
||||
|
||||
@@ -125,6 +126,8 @@ For E2B raw HTTP diagnostics, include a valid template id such as `base`; a miss
|
||||
- Session metadata should keep LangBot logical paths such as `/workspace`; storing provider-internal paths can make later requests look incompatible.
|
||||
- nsjail versions differ. Some expose only `--disable_clone_new*` flags and use `--bindmount` instead of `--rw_bind`.
|
||||
- On WSL, cgroup v2 may exist but not be writable. The backend should warn and fall back to rlimits rather than fail the sandbox.
|
||||
- The host backend does not honor sandbox image, network, rootfs, process, or
|
||||
resource isolation. Use a disposable workspace and low-privilege account.
|
||||
- If `ALL_PROXY` uses a SOCKS URL and `socksio` is not installed, some Python HTTP clients can fail during startup. Prefer consistent HTTP proxy variables unless SOCKS support is installed.
|
||||
|
||||
## Related Troubleshooting
|
||||
|
||||
@@ -3,7 +3,7 @@ title: "Native sandbox tools are unavailable even though a backend is configured
|
||||
date: 2026-05-18
|
||||
symptoms:
|
||||
- "Backend logs show Native sandbox tools (exec/read/write/edit/glob/grep) are NOT available."
|
||||
- "The Box runtime later reports that E2B, nsjail, or Docker is configured."
|
||||
- "The Box runtime later reports that E2B, nsjail, Docker, or explicit host mode is configured."
|
||||
- "Debug Chat does not expose exec, register_skill, or activate as usable tools."
|
||||
patterns:
|
||||
- "Native sandbox tools ... are NOT available"
|
||||
@@ -19,6 +19,7 @@ fix_steps:
|
||||
- "Ensure the Box runtime reselects a backend when get_backend_info is called and the cached backend is empty."
|
||||
- "For E2B, verify the key without printing it and confirm any required template setting."
|
||||
- "For nsjail, run nsjail --help and confirm the binary is on PATH for the LangBot process."
|
||||
- "For trusted local development only, explicitly set box.backend=host; never use host as a production sandbox fallback."
|
||||
verification: "Run sandbox-skill-authoring-e2e. Logs should show Native sandbox tools are available and /api/v1/box/status should report available=true with the expected backend."
|
||||
related_cases:
|
||||
- sandbox-skill-authoring-e2e
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
import typing
|
||||
import json
|
||||
import os
|
||||
import typing
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from .errors import DifyAPIError
|
||||
from pathlib import Path
|
||||
import os
|
||||
|
||||
_MAX_DIFY_RESPONSE_BYTES = 1024 * 1024
|
||||
_MAX_DIFY_SSE_LINE_BYTES = 1024 * 1024
|
||||
@@ -15,6 +16,32 @@ _MAX_DIFY_STREAM_BYTES = 16 * 1024 * 1024
|
||||
_MAX_DIFY_UPLOAD_BYTES = 10 * 1024 * 1024
|
||||
|
||||
|
||||
def _decode_sse_data(line: bytes) -> dict[str, typing.Any] | None:
|
||||
data = line[5:].strip()
|
||||
if not data or data == b'[DONE]':
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(data.decode('utf-8'))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
||||
raise DifyAPIError('Dify SSE data line is not valid JSON') from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise DifyAPIError('Dify SSE event is not a JSON object')
|
||||
return payload
|
||||
|
||||
|
||||
def _decode_upload_response(body: bytes) -> dict[str, typing.Any]:
|
||||
try:
|
||||
response = json.loads(body)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
||||
raise DifyAPIError('Dify upload response is not valid JSON') from exc
|
||||
if not isinstance(response, dict):
|
||||
raise DifyAPIError('Dify upload response is not a JSON object')
|
||||
payload = response.get('data', response)
|
||||
if not isinstance(payload, dict) or not isinstance(payload.get('id'), str) or not payload['id']:
|
||||
raise DifyAPIError('Dify upload response does not contain a valid file id')
|
||||
return payload
|
||||
|
||||
|
||||
async def _read_limited_response(
|
||||
response: httpx.Response,
|
||||
*,
|
||||
@@ -56,16 +83,16 @@ async def _iter_sse_json(
|
||||
line = raw_line.rstrip(b'\r').strip()
|
||||
if not line or not line.startswith(b'data:'):
|
||||
continue
|
||||
payload = json.loads(line[5:].decode('utf-8', errors='replace'))
|
||||
if isinstance(payload, dict):
|
||||
payload = _decode_sse_data(line)
|
||||
if payload is not None:
|
||||
yield payload
|
||||
if len(buffer) > _MAX_DIFY_SSE_LINE_BYTES:
|
||||
raise DifyAPIError('Dify SSE event exceeds the runtime limit')
|
||||
|
||||
line = bytes(buffer).rstrip(b'\r').strip()
|
||||
if line.startswith(b'data:'):
|
||||
payload = json.loads(line[5:].decode('utf-8', errors='replace'))
|
||||
if isinstance(payload, dict):
|
||||
payload = _decode_sse_data(line)
|
||||
if payload is not None:
|
||||
yield payload
|
||||
|
||||
|
||||
@@ -242,7 +269,7 @@ class AsyncDifyServiceClient:
|
||||
file: httpx._types.FileTypes,
|
||||
user: str,
|
||||
timeout: float = 30.0,
|
||||
) -> str:
|
||||
) -> dict[str, typing.Any]:
|
||||
# 处理 Path 对象
|
||||
if isinstance(file, Path):
|
||||
if not file.exists():
|
||||
@@ -271,6 +298,6 @@ class AsyncDifyServiceClient:
|
||||
timeout=timeout,
|
||||
) as response:
|
||||
body = await _read_limited_response(response)
|
||||
if response.status_code != 201:
|
||||
if response.status_code not in (200, 201):
|
||||
raise DifyAPIError(f'{response.status_code} {body.decode(errors="replace")}')
|
||||
return json.loads(body)
|
||||
return _decode_upload_response(body)
|
||||
|
||||
@@ -697,9 +697,10 @@ class DingTalkClient:
|
||||
if not await self.check_access_token():
|
||||
await self.get_access_token()
|
||||
|
||||
cardData: dict = {'cardParamMap': _stringify_card_param_map(card_param_map)}
|
||||
template_params = dict(card_param_map or {})
|
||||
if card_data_config is not None:
|
||||
cardData['config'] = json.dumps(card_data_config)
|
||||
template_params['config'] = card_data_config
|
||||
cardData: dict = {'cardParamMap': _stringify_card_param_map(template_params)}
|
||||
|
||||
body: dict = {
|
||||
'cardTemplateId': card_template_id,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -789,6 +936,13 @@ class WecomBotWsClient:
|
||||
'chat_type': message_data.get('type', 'single'),
|
||||
}
|
||||
self._prune_stream_state()
|
||||
# Send an initial empty stream frame so the WeCom client
|
||||
# shows its built-in loading spinner while the pipeline
|
||||
# processes the message (e.g. RAG retrieval).
|
||||
try:
|
||||
await self.reply_stream(req_id, stream_id, '', finish=False)
|
||||
except Exception:
|
||||
await self.logger.warning(f'Failed to send initial stream frame: {traceback.format_exc()}')
|
||||
message_data['stream_id'] = stream_id
|
||||
message_data['req_id'] = req_id
|
||||
|
||||
|
||||
@@ -295,6 +295,34 @@ class WecomCSClient:
|
||||
raise Exception('Failed to send message')
|
||||
return data
|
||||
|
||||
@_bounded_token_retry
|
||||
async def send_image_msg(self, open_kfid: str, external_userid: str, msgid: str, media_id: str):
|
||||
if not await self.check_access_token():
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
|
||||
url = f'{self.base_url}/kf/send_msg?access_token={self.access_token}'
|
||||
payload = {
|
||||
'touser': external_userid,
|
||||
'open_kfid': open_kfid,
|
||||
'msgid': msgid,
|
||||
'msgtype': 'image',
|
||||
'image': {
|
||||
'media_id': media_id,
|
||||
},
|
||||
}
|
||||
|
||||
async with self._http_client_context() as client:
|
||||
response = await client.post(url, json=payload)
|
||||
|
||||
data = await httpclient.parse_json_response(response)
|
||||
if data['errcode'] == 40014 or data['errcode'] == 42001:
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
return await self.send_image_msg(open_kfid, external_userid, msgid, media_id)
|
||||
if data['errcode'] != 0:
|
||||
await self.logger.error(f'发送图片失败:{data}')
|
||||
raise Exception('Failed to send image message')
|
||||
return data
|
||||
|
||||
async def handle_callback_request(self):
|
||||
"""处理回调请求(独立端口模式,使用全局 request)。"""
|
||||
return await self._handle_callback_internal(request)
|
||||
|
||||
@@ -218,6 +218,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
pipeline_ids = quart.request.args.getlist('pipelineId')
|
||||
start_time_str = quart.request.args.get('startTime')
|
||||
end_time_str = quart.request.args.get('endTime')
|
||||
user_query = quart.request.args.get('userQuery')
|
||||
is_active_str = quart.request.args.get('isActive')
|
||||
limit = int(quart.request.args.get('limit', 100))
|
||||
offset = int(quart.request.args.get('offset', 0))
|
||||
@@ -237,6 +238,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
user_query=user_query,
|
||||
is_active=is_active,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
@@ -396,7 +398,14 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
@self.route('/sessions/<session_id>/analysis', methods=['GET'], permission=Permission.RESOURCE_VIEW)
|
||||
async def get_session_analysis(session_id: str, request_context: RequestContext) -> str:
|
||||
"""Get detailed analysis for a specific session"""
|
||||
analysis = await self.ap.monitoring_service.get_session_analysis(request_context, session_id)
|
||||
start_time = parse_iso_datetime(quart.request.args.get('startTime'))
|
||||
end_time = parse_iso_datetime(quart.request.args.get('endTime'))
|
||||
analysis = await self.ap.monitoring_service.get_session_analysis(
|
||||
request_context,
|
||||
session_id,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
)
|
||||
|
||||
# Always return success with the analysis data
|
||||
# The frontend will handle the 'found: false' case
|
||||
|
||||
@@ -39,7 +39,13 @@ class PipelinesRouterGroup(group.RouterGroup):
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
pipeline_uuid = await self.ap.pipeline_service.create_pipeline(request_context, await quart.request.json)
|
||||
pipeline_data = await quart.request.json
|
||||
create_as_default = pipeline_data.get('is_default') is True
|
||||
pipeline_uuid = await self.ap.pipeline_service.create_pipeline(
|
||||
request_context,
|
||||
pipeline_data,
|
||||
default=create_as_default,
|
||||
)
|
||||
return self.success(data={'uuid': pipeline_uuid})
|
||||
|
||||
@self.route(
|
||||
|
||||
@@ -113,6 +113,24 @@ class BotsRouterGroup(group.RouterGroup):
|
||||
)
|
||||
return self.success(data={'sent': True})
|
||||
|
||||
@self.route(
|
||||
'/<bot_uuid>/test-inbound',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(bot_uuid: str, request_context: RequestContext) -> str:
|
||||
json_data = await quart.request.get_json(silent=True) or {}
|
||||
try:
|
||||
result = await self.ap.bot_service.send_http_bot_test_message(
|
||||
request_context,
|
||||
bot_uuid,
|
||||
str(json_data.get('message') or ''),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
return self.success(data=result)
|
||||
|
||||
@self.route(
|
||||
'/<bot_uuid>/admins',
|
||||
methods=['GET'],
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -206,6 +206,20 @@ class SystemRouterGroup(group.RouterGroup):
|
||||
|
||||
return self.success(data={})
|
||||
|
||||
@self.route(
|
||||
'/wizard/recommended-model',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
"""Resolve Space's best available chat model to this Workspace."""
|
||||
try:
|
||||
model = await self.ap.space_service.get_recommended_chat_model(request_context)
|
||||
except ValueError as exc:
|
||||
return self.http_status(503, -1, str(exc))
|
||||
return self.success(data=model)
|
||||
|
||||
@self.route(
|
||||
'/tasks',
|
||||
methods=['GET'],
|
||||
|
||||
@@ -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:
|
||||
@@ -202,6 +186,9 @@ class UserRouterGroup(group.RouterGroup):
|
||||
json_data = await quart.request.json
|
||||
code = json_data.get('code')
|
||||
state = json_data.get('state')
|
||||
redirect_uri = json_data.get('redirect_uri') or (
|
||||
quart.request.url_root.rstrip('/') + '/auth/space/callback'
|
||||
)
|
||||
launch_assertion = json_data.get('launch_assertion')
|
||||
workspace_uuid = json_data.get('workspace_uuid')
|
||||
|
||||
@@ -215,8 +202,11 @@ class UserRouterGroup(group.RouterGroup):
|
||||
return self.fail(1, 'Missing authorization code')
|
||||
if not state:
|
||||
return self.fail(1, 'Missing state parameter')
|
||||
if not str(code).startswith('v4_'):
|
||||
return self.fail(1, 'Unsupported Space OAuth code contract')
|
||||
|
||||
try:
|
||||
redirect_uri = self._validate_space_redirect_uri(str(redirect_uri), bind=False)
|
||||
consumed_state = await self.ap.user_service.consume_space_oauth_state_details(state, 'login')
|
||||
# Exchange code for tokens
|
||||
launch_workspace_uuid = consumed_state.launch_workspace_uuid
|
||||
@@ -234,24 +224,36 @@ class UserRouterGroup(group.RouterGroup):
|
||||
code,
|
||||
workspace_uuids,
|
||||
workspace_created_ats,
|
||||
redirect_uri=redirect_uri,
|
||||
)
|
||||
access_token = token_data.get('access_token')
|
||||
refresh_token = token_data.get('refresh_token')
|
||||
expires_in = token_data.get('expires_in', 0)
|
||||
cloud_workspace_uuid = token_data.get('cloud_workspace_uuid')
|
||||
|
||||
if not access_token:
|
||||
return self.fail(1, 'Failed to get access token from Space')
|
||||
|
||||
# Authenticate and create/update local user
|
||||
cloud_mode = getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud'
|
||||
if cloud_mode and launch_workspace_uuid and launch_workspace_uuid != cloud_workspace_uuid:
|
||||
return self.fail(1, 'Space OAuth Workspace binding mismatch')
|
||||
target_workspace_uuid = launch_workspace_uuid or cloud_workspace_uuid
|
||||
if cloud_mode:
|
||||
if not target_workspace_uuid:
|
||||
return self.fail(1, 'Space OAuth response is missing the Cloud Workspace binding')
|
||||
await self.ap.directory_projection_service.reconcile_workspaces((target_workspace_uuid,))
|
||||
|
||||
# Authenticate only after the signed, exact Workspace delta has
|
||||
# established the Account and membership runtime shadow rows.
|
||||
jwt_token, user_obj = await self.ap.user_service.authenticate_space_user(
|
||||
access_token, refresh_token, expires_in
|
||||
)
|
||||
|
||||
if launch_workspace_uuid:
|
||||
if target_workspace_uuid:
|
||||
try:
|
||||
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
|
||||
user_obj.uuid,
|
||||
launch_workspace_uuid,
|
||||
target_workspace_uuid,
|
||||
)
|
||||
except Exception:
|
||||
self.ap.logger.warning('Rejected Space OAuth launch for unauthorized Workspace')
|
||||
@@ -338,6 +340,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)
|
||||
@@ -382,12 +385,17 @@ class UserRouterGroup(group.RouterGroup):
|
||||
json_data = await quart.request.json
|
||||
code = json_data.get('code')
|
||||
state = json_data.get('state')
|
||||
redirect_uri = json_data.get('redirect_uri') or (
|
||||
quart.request.url_root.rstrip('/') + '/auth/space/callback?mode=bind'
|
||||
)
|
||||
|
||||
if not code:
|
||||
return self.http_status(400, -1, 'Missing authorization code')
|
||||
|
||||
if not state:
|
||||
return self.http_status(400, -1, 'Missing state parameter')
|
||||
if not str(code).startswith('v4_'):
|
||||
return self.http_status(400, -1, 'Unsupported Space OAuth code contract')
|
||||
|
||||
try:
|
||||
user_obj = await self.ap.user_service.consume_space_oauth_state(state, 'bind')
|
||||
@@ -400,7 +408,10 @@ class UserRouterGroup(group.RouterGroup):
|
||||
return self.http_status(400, -1, 'Only local accounts can bind to Space')
|
||||
|
||||
try:
|
||||
updated_user = await self.ap.user_service.bind_space_account(user_obj.user, code)
|
||||
redirect_uri = self._validate_space_redirect_uri(str(redirect_uri), bind=True)
|
||||
updated_user = await self.ap.user_service.bind_space_account(
|
||||
user_obj.user, code, redirect_uri=redirect_uri
|
||||
)
|
||||
jwt_token = await self.ap.user_service.generate_jwt_token(updated_user)
|
||||
return self.success(
|
||||
data={
|
||||
@@ -416,7 +427,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
|
||||
|
||||
@@ -443,6 +454,10 @@ class UserRouterGroup(group.RouterGroup):
|
||||
}
|
||||
)
|
||||
|
||||
projection_service = self.ap.directory_projection_service
|
||||
if projection_service is None:
|
||||
raise SpaceLaunchError('Cloud directory projection is unavailable')
|
||||
await projection_service.reconcile_workspaces((launch['workspace_uuid'],))
|
||||
account = await self.ap.user_service.get_user_by_uuid(launch['account_uuid'])
|
||||
if account is None:
|
||||
raise SpaceLaunchError('Launch Account is not projected into Core')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
import json
|
||||
import sqlalchemy
|
||||
|
||||
from ....core import app
|
||||
@@ -8,6 +9,8 @@ from ....entity.persistence import bot as persistence_bot
|
||||
from ....entity.persistence import pipeline as persistence_pipeline
|
||||
from ....workspace.errors import WorkspaceNotFoundError
|
||||
from .tenant import TenantContext, require_workspace_uuid, scope_statement
|
||||
from ....utils import httpclient
|
||||
from ....platform.sources import http_bot_signing
|
||||
|
||||
|
||||
class BotService:
|
||||
@@ -80,6 +83,7 @@ class BotService:
|
||||
'wecomcs',
|
||||
'LINE',
|
||||
'lark',
|
||||
'http_bot',
|
||||
]:
|
||||
webhook_prefix = self.ap.instance_config.data['api'].get('webhook_prefix', 'http://127.0.0.1:5300')
|
||||
extra_webhook_prefix = self.ap.instance_config.data['api'].get('extra_webhook_prefix', '')
|
||||
@@ -133,7 +137,16 @@ class BotService:
|
||||
|
||||
bot = await self.get_bot(context, bot_data['uuid'], include_secret=True)
|
||||
|
||||
await self.ap.platform_mgr.load_bot(context, bot)
|
||||
try:
|
||||
await self.ap.platform_mgr.load_bot(context, bot)
|
||||
except Exception:
|
||||
# The bot row was already inserted above; without this rollback a
|
||||
# failing adapter constructor (e.g. a missing optional credential
|
||||
# key) would leave a permanently disabled orphan bot in the DB.
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.delete(persistence_bot.Bot).where(persistence_bot.Bot.uuid == bot_data['uuid'])
|
||||
)
|
||||
raise
|
||||
|
||||
return bot_data['uuid']
|
||||
|
||||
@@ -216,6 +229,53 @@ class BotService:
|
||||
|
||||
return [log.to_json() for log in logs], total_count
|
||||
|
||||
async def send_http_bot_test_message(
|
||||
self,
|
||||
context: TenantContext,
|
||||
bot_uuid: str,
|
||||
message: str,
|
||||
) -> dict:
|
||||
"""Send a signed test message through the HTTP Bot public ingress."""
|
||||
bot = await self.get_bot(context, bot_uuid, include_secret=True)
|
||||
if bot is None:
|
||||
raise WorkspaceNotFoundError('Bot not found')
|
||||
if bot.get('adapter') != 'http_bot':
|
||||
raise ValueError('Inbound test is only available for HTTP Bot')
|
||||
if not bot.get('enable'):
|
||||
raise ValueError('Bot must be enabled before sending a test message')
|
||||
|
||||
text = message.strip()
|
||||
if not text or len(text) > 2000:
|
||||
raise ValueError('Test message must contain 1 to 2000 characters')
|
||||
|
||||
payload = {
|
||||
'session_id': f'wizard-{uuid.uuid4().hex}',
|
||||
'sender': {'id': 'wizard-user', 'name': 'Wizard Test'},
|
||||
'message': [{'type': 'Plain', 'text': text}],
|
||||
}
|
||||
body = json.dumps(payload, ensure_ascii=False, separators=(',', ':')).encode()
|
||||
config = bot.get('adapter_config') or {}
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
if config.get('signature_required', True):
|
||||
secret = str(config.get('inbound_secret') or '')
|
||||
if not secret:
|
||||
raise ValueError('HTTP Bot inbound signing secret is required')
|
||||
timestamp, signature = http_bot_signing.sign(secret, body)
|
||||
headers[http_bot_signing.HEADER_TIMESTAMP] = timestamp
|
||||
headers[http_bot_signing.HEADER_SIGNATURE] = signature
|
||||
|
||||
port = int(self.ap.instance_config.data.get('api', {}).get('port', 5300))
|
||||
session = httpclient.get_session()
|
||||
async with session.post(
|
||||
f'http://127.0.0.1:{port}/bots/{bot_uuid}',
|
||||
data=body,
|
||||
headers=headers,
|
||||
) as response:
|
||||
result = await httpclient.read_json_limited(response)
|
||||
if response.status not in {200, 202}:
|
||||
raise ValueError(result.get('msg') or f'HTTP Bot test failed with status {response.status}')
|
||||
return result.get('data') or {}
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
context: TenantContext,
|
||||
|
||||
@@ -1257,6 +1257,7 @@ class MonitoringService:
|
||||
pipeline_ids: list[str] | None = None,
|
||||
start_time: datetime.datetime | None = None,
|
||||
end_time: datetime.datetime | None = None,
|
||||
user_query: str | None = None,
|
||||
is_active: bool | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
@@ -1274,6 +1275,14 @@ class MonitoringService:
|
||||
conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time)
|
||||
if end_time:
|
||||
conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time)
|
||||
if user_query and user_query.strip():
|
||||
user_pattern = f'%{user_query.strip()}%'
|
||||
conditions.append(
|
||||
sqlalchemy.or_(
|
||||
persistence_monitoring.MonitoringSession.user_id.ilike(user_pattern),
|
||||
persistence_monitoring.MonitoringSession.user_name.ilike(user_pattern),
|
||||
)
|
||||
)
|
||||
if is_active is not None:
|
||||
conditions.append(persistence_monitoring.MonitoringSession.is_active == is_active)
|
||||
|
||||
@@ -1365,6 +1374,8 @@ class MonitoringService:
|
||||
self,
|
||||
context: TenantContext,
|
||||
session_id: str,
|
||||
start_time: datetime.datetime | None = None,
|
||||
end_time: datetime.datetime | None = None,
|
||||
) -> dict:
|
||||
"""Get bounded session details with full statistics computed in SQL."""
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
@@ -1478,12 +1489,17 @@ class MonitoringService:
|
||||
)
|
||||
)
|
||||
tool_stats = tool_stats_result.one()
|
||||
tool_conditions = [
|
||||
persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid,
|
||||
persistence_monitoring.MonitoringToolCall.session_id == session_id,
|
||||
]
|
||||
if start_time is not None:
|
||||
tool_conditions.append(persistence_monitoring.MonitoringToolCall.timestamp >= start_time)
|
||||
if end_time is not None:
|
||||
tool_conditions.append(persistence_monitoring.MonitoringToolCall.timestamp <= end_time)
|
||||
tool_query = (
|
||||
sqlalchemy.select(persistence_monitoring.MonitoringToolCall)
|
||||
.where(
|
||||
persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid,
|
||||
persistence_monitoring.MonitoringToolCall.session_id == session_id,
|
||||
)
|
||||
.where(*tool_conditions)
|
||||
.order_by(persistence_monitoring.MonitoringToolCall.timestamp.asc())
|
||||
.limit(detail_limit + 1)
|
||||
)
|
||||
|
||||
@@ -11,6 +11,9 @@ import sqlalchemy
|
||||
from ....core import app
|
||||
from ....entity.persistence import user
|
||||
from ....entity.dto.space_model import SpaceModel
|
||||
from ....entity.dto.space_model import SpaceModelSelection
|
||||
from ....entity.persistence import model as persistence_model
|
||||
from ....cloud.model_catalog import LANGBOT_MODELS_PROVIDER_REQUESTER
|
||||
|
||||
|
||||
_CREDITS_CACHE_TTL_SECONDS = 60
|
||||
@@ -116,7 +119,7 @@ class SpaceService:
|
||||
|
||||
space_config = self._get_space_config()
|
||||
authorize_url = space_config['oauth_authorize_url']
|
||||
params = {'redirect_uri': redirect_uri}
|
||||
params = {'redirect_uri': redirect_uri, 'code_contract': 'redirect-v1'}
|
||||
if state:
|
||||
params['state'] = state
|
||||
return f'{authorize_url}?{urlencode(params)}'
|
||||
@@ -126,6 +129,8 @@ class SpaceService:
|
||||
code: str,
|
||||
workspace_uuids: list[str] | None = None,
|
||||
workspace_created_ats: dict[str, int] | None = None,
|
||||
*,
|
||||
redirect_uri: str = '',
|
||||
) -> typing.Dict:
|
||||
"""Exchange OAuth authorization code for tokens"""
|
||||
from langbot.pkg.utils import constants
|
||||
@@ -138,6 +143,7 @@ class SpaceService:
|
||||
f'{space_url}/api/v1/accounts/oauth/token',
|
||||
json={
|
||||
'code': code,
|
||||
'redirect_uri': redirect_uri,
|
||||
'instance_id': constants.instance_id,
|
||||
# Sending an explicit empty list tells new Space servers not to
|
||||
# synthesize a legacy instance-derived Workspace binding.
|
||||
@@ -238,3 +244,76 @@ class SpaceService:
|
||||
raise ValueError(f'Failed to get models: {data.get("msg")}')
|
||||
models_data = data.get('data', {}).get('models', [])
|
||||
return [SpaceModel.model_validate(model_dict) for model_dict in models_data]
|
||||
|
||||
async def get_model_selection(self, category: str) -> typing.List[SpaceModelSelection]:
|
||||
"""Return Space models in the availability-ranked selection order."""
|
||||
space_url = self._get_space_config()['url']
|
||||
session = httpclient.get_session()
|
||||
async with session.get(
|
||||
f'{space_url}/api/v1/models/selection',
|
||||
params={'category': category},
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
error = await httpclient.read_text_limited(response)
|
||||
raise ValueError(f'Failed to get model selection: {error}')
|
||||
payload = await httpclient.read_json_limited(response)
|
||||
if payload.get('code') != 0:
|
||||
raise ValueError(f'Failed to get model selection: {payload.get("msg")}')
|
||||
|
||||
data = payload.get('data', [])
|
||||
if isinstance(data, dict):
|
||||
data = data.get('models', data.get('items', []))
|
||||
if not isinstance(data, list):
|
||||
raise ValueError('Failed to get model selection: invalid response')
|
||||
|
||||
models = []
|
||||
for selection in data:
|
||||
if isinstance(selection, dict) and isinstance(selection.get('model'), dict):
|
||||
models.append(selection['model'])
|
||||
else:
|
||||
models.append(selection)
|
||||
return [SpaceModelSelection.model_validate(model) for model in models]
|
||||
|
||||
async def get_recommended_chat_model(self, context: typing.Any) -> dict:
|
||||
"""Resolve Space's first ranked chat model to a local Workspace model."""
|
||||
selection = await self.get_model_selection('chat')
|
||||
if not selection:
|
||||
raise ValueError('No recommended chat model is available')
|
||||
recommended = selection[0]
|
||||
|
||||
async def find_local_model():
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.LLMModel)
|
||||
.join(
|
||||
persistence_model.ModelProvider,
|
||||
sqlalchemy.and_(
|
||||
persistence_model.ModelProvider.workspace_uuid == persistence_model.LLMModel.workspace_uuid,
|
||||
persistence_model.ModelProvider.uuid == persistence_model.LLMModel.provider_uuid,
|
||||
),
|
||||
)
|
||||
.where(
|
||||
persistence_model.LLMModel.workspace_uuid == context.workspace_uuid,
|
||||
persistence_model.ModelProvider.requester == LANGBOT_MODELS_PROVIDER_REQUESTER,
|
||||
sqlalchemy.or_(
|
||||
persistence_model.LLMModel.uuid == recommended.uuid,
|
||||
persistence_model.LLMModel.name == recommended.model_id,
|
||||
),
|
||||
)
|
||||
)
|
||||
return result.first()
|
||||
|
||||
local_model = await find_local_model()
|
||||
if local_model is None:
|
||||
# OSS synchronizes the public catalog locally. Refresh once in case
|
||||
# the recommendation was published after this process started.
|
||||
from ..context import ExecutionContext
|
||||
|
||||
try:
|
||||
await self.ap.model_mgr.sync_new_models_from_space(ExecutionContext.from_request(context))
|
||||
except Exception:
|
||||
pass
|
||||
local_model = await find_local_model()
|
||||
|
||||
if local_model is None:
|
||||
raise ValueError('Recommended chat model is not available in this Workspace')
|
||||
return {'uuid': local_model.uuid, 'name': local_model.name}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -774,7 +774,7 @@ class UserService:
|
||||
f'email:{normalized_email}',
|
||||
)
|
||||
|
||||
async def bind_space_account(self, user_email: str, code: str) -> user.User:
|
||||
async def bind_space_account(self, user_email: str, code: str, *, redirect_uri: str = '') -> user.User:
|
||||
"""Bind Space account to existing local account"""
|
||||
local_account = await self.get_user_by_email(user_email)
|
||||
if local_account is None:
|
||||
@@ -794,12 +794,13 @@ class UserService:
|
||||
code,
|
||||
[binding.workspace_uuid],
|
||||
{binding.workspace_uuid: created_ts},
|
||||
redirect_uri=redirect_uri,
|
||||
)
|
||||
else:
|
||||
# Compatibility for early/bootstrap call sites that have not wired
|
||||
# WorkspaceService yet; old Space servers still derive the legacy
|
||||
# Workspace identity from instance_id when the field is omitted.
|
||||
token_data = await self.ap.space_service.exchange_oauth_code(code)
|
||||
token_data = await self.ap.space_service.exchange_oauth_code(code, redirect_uri=redirect_uri)
|
||||
access_token = token_data.get('access_token')
|
||||
refresh_token = token_data.get('refresh_token')
|
||||
expires_in = token_data.get('expires_in', 0)
|
||||
@@ -825,7 +826,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)
|
||||
|
||||
@@ -147,7 +147,16 @@ class LangBotMCPServer:
|
||||
)
|
||||
async def create_pipeline(pipeline_data: dict) -> str:
|
||||
context = _authorized(Permission.RESOURCE_MANAGE)
|
||||
return _dump({'uuid': await ap.pipeline_service.create_pipeline(context, pipeline_data)})
|
||||
create_as_default = pipeline_data.get('is_default') is True
|
||||
return _dump(
|
||||
{
|
||||
'uuid': await ap.pipeline_service.create_pipeline(
|
||||
context,
|
||||
pipeline_data,
|
||||
default=create_as_default,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@mcp.tool(description='Update a pipeline by UUID. `pipeline_data` matches the PUT body.')
|
||||
async def update_pipeline(pipeline_uuid: str, pipeline_data: dict) -> str:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -455,7 +455,9 @@ class BoxService:
|
||||
|
||||
async def _require_validated_workspace_sandbox(self, execution_context: ExecutionContext) -> None:
|
||||
if not self._available:
|
||||
raise BoxError('Box runtime is not available. Install and start Docker to use sandbox features.')
|
||||
raise BoxError(
|
||||
'Box runtime is not available. Configure an available Box backend before using Box features.'
|
||||
)
|
||||
if self._cloud_managed:
|
||||
if self._admission is None:
|
||||
raise BoxAdmissionError('Cloud Box sandbox admission is unavailable')
|
||||
@@ -565,7 +567,9 @@ class BoxService:
|
||||
skip_host_mount_validation: bool = False,
|
||||
) -> dict:
|
||||
if not self._available:
|
||||
raise BoxError('Box runtime is not available. Install and start Docker to use sandbox features.')
|
||||
raise BoxError(
|
||||
'Box runtime is not available. Configure an available Box backend before using Box features.'
|
||||
)
|
||||
execution_context = await self._validated_execution_context(self._query_execution_context(query))
|
||||
spec_payload = self._managed_policy_payload(execution_context, spec_payload)
|
||||
await self._require_validated_workspace_sandbox(execution_context)
|
||||
@@ -1210,8 +1214,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 +1270,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 []
|
||||
|
||||
@@ -2133,5 +2146,8 @@ class BoxService:
|
||||
if backend_name:
|
||||
payload['connector_error'] = f'Configured sandbox backend "{backend_name}" is unavailable'
|
||||
else:
|
||||
payload['connector_error'] = 'No supported sandbox backend (Docker / nsjail / E2B) is available'
|
||||
payload['connector_error'] = (
|
||||
'No supported sandbox backend (Docker / nsjail / E2B) is available. '
|
||||
'Trusted local development may explicitly select the unsafe host backend.'
|
||||
)
|
||||
return payload
|
||||
|
||||
@@ -125,10 +125,21 @@ class DirectoryProjectionService:
|
||||
# The database cursor remains the shared projection high-water mark,
|
||||
# while this cursor tracks what this process has actually observed.
|
||||
self._consumer_cursor: int | None = None
|
||||
self._sync_lock = asyncio.Lock()
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Block Cloud startup until one full signed snapshot is committed."""
|
||||
|
||||
async with self._sync_lock:
|
||||
await self._refresh_snapshot()
|
||||
|
||||
async def refresh_snapshot(self) -> None:
|
||||
"""Refresh from one full signed snapshot within the sync single-flight."""
|
||||
|
||||
async with self._sync_lock:
|
||||
await self._refresh_snapshot()
|
||||
|
||||
async def _refresh_snapshot(self) -> None:
|
||||
last_superseded: _DirectorySnapshotSuperseded | None = None
|
||||
for _attempt in range(5):
|
||||
snapshot = await self.provider.fetch_snapshot(self.instance_uuid)
|
||||
@@ -159,9 +170,84 @@ class DirectoryProjectionService:
|
||||
delay = min(max(delay * 2, self.sync_interval_seconds), self.max_staleness_seconds / 2)
|
||||
|
||||
async def sync_once(self) -> None:
|
||||
async with self._sync_lock:
|
||||
await self._sync_once()
|
||||
|
||||
async def reconcile_workspaces(self, workspace_uuids: Iterable[str]) -> None:
|
||||
"""Synchronously project an exact Workspace set without moving the event cursor."""
|
||||
|
||||
requested = tuple(sorted({str(value).strip() for value in workspace_uuids if str(value).strip()}))
|
||||
if not requested:
|
||||
raise DirectoryProjectionUnavailableError('Targeted directory reconciliation requires a Workspace')
|
||||
if len(requested) > self.event_limit:
|
||||
raise DirectoryProjectionUnavailableError('Targeted directory reconciliation exceeds the batch limit')
|
||||
async with self._sync_lock:
|
||||
delta = await self.provider.fetch_workspaces(self.instance_uuid, requested)
|
||||
await self._apply_targeted_delta(delta, requested)
|
||||
|
||||
async def _apply_targeted_delta(
|
||||
self,
|
||||
delta: DirectoryDelta,
|
||||
requested_workspace_uuids: tuple[str, ...],
|
||||
) -> None:
|
||||
if not isinstance(delta, DirectoryDelta):
|
||||
raise DirectoryProjectionUnavailableError('Directory provider returned an invalid delta')
|
||||
workspace_count, membership_count = self._validate_batch_capacity(
|
||||
delta.workspaces,
|
||||
full_snapshot=False,
|
||||
)
|
||||
delta = DirectoryDelta.model_validate(delta.model_dump())
|
||||
if delta.instance_uuid != self.instance_uuid:
|
||||
raise DirectoryProjectionUnavailableError('Directory delta targets another LangBot instance')
|
||||
requested = set(requested_workspace_uuids)
|
||||
if set(delta.requested_workspace_uuids) != requested:
|
||||
raise DirectoryProjectionUnavailableError('Directory delta does not match the requested Workspaces')
|
||||
if {workspace.uuid for workspace in delta.workspaces} != requested:
|
||||
raise DirectoryProjectionUnavailableError('Directory delta omitted a requested Workspace')
|
||||
|
||||
directory_uow = getattr(self.ap.persistence_mgr, 'directory_projection_uow', None)
|
||||
if not callable(directory_uow):
|
||||
raise DirectoryProjectionUnavailableError('Directory projection persistence scope is unavailable')
|
||||
|
||||
async with directory_uow(self.instance_uuid) as uow:
|
||||
session = uow.session
|
||||
state = await session.scalar(
|
||||
sqlalchemy.select(DirectoryProjectionState)
|
||||
.where(DirectoryProjectionState.instance_uuid == self.instance_uuid)
|
||||
.with_for_update()
|
||||
)
|
||||
if state is None:
|
||||
raise DirectoryProjectionUnavailableError('Directory projection is not initialized')
|
||||
snapshot = DirectorySnapshot(
|
||||
instance_uuid=self.instance_uuid,
|
||||
cursor=state.cursor,
|
||||
generated_at=delta.generated_at,
|
||||
workspaces=delta.workspaces,
|
||||
)
|
||||
accounts_by_uuid = await self._apply_accounts(session, snapshot, preserve_existing=True)
|
||||
await self._apply_workspaces(session, snapshot, accounts_by_uuid=accounts_by_uuid)
|
||||
active_workspace_count = await self._enforce_active_workspace_capacity(session)
|
||||
await session.flush()
|
||||
|
||||
await self._update_entitlement_workspace_activity(
|
||||
snapshot.workspaces,
|
||||
requested_workspace_uuids=requested,
|
||||
)
|
||||
self._publish_runtime_execution_projection(
|
||||
snapshot.workspaces,
|
||||
affected_workspace_uuids=requested,
|
||||
)
|
||||
self._request_model_catalog_sync()
|
||||
self._record_batch_cardinality(
|
||||
active_workspaces=active_workspace_count,
|
||||
workspaces=workspace_count,
|
||||
memberships=membership_count,
|
||||
)
|
||||
|
||||
async def _sync_once(self) -> None:
|
||||
cursor = self._consumer_cursor
|
||||
if cursor is None:
|
||||
await self.initialize()
|
||||
await self._refresh_snapshot()
|
||||
return
|
||||
batch = await self.provider.fetch_events(
|
||||
self.instance_uuid,
|
||||
@@ -708,7 +794,13 @@ class DirectoryProjectionService:
|
||||
for row in inbox_rows:
|
||||
row.applied_at = now
|
||||
|
||||
async def _apply_accounts(self, session: Any, snapshot: DirectorySnapshot) -> dict[str, User]:
|
||||
async def _apply_accounts(
|
||||
self,
|
||||
session: Any,
|
||||
snapshot: DirectorySnapshot,
|
||||
*,
|
||||
preserve_existing: bool = False,
|
||||
) -> dict[str, User]:
|
||||
selected: dict[str, DirectoryMember] = {}
|
||||
emails: dict[str, str] = {}
|
||||
for workspace in snapshot.workspaces:
|
||||
@@ -773,6 +865,12 @@ class DirectoryProjectionService:
|
||||
continue
|
||||
if account.source != AccountSource.CLOUD_PROJECTION.value:
|
||||
raise DirectoryProjectionUnavailableError('Directory account UUID collides with a local Core account')
|
||||
if preserve_existing:
|
||||
# A targeted Workspace fetch has no independently monotonic
|
||||
# Account revision. It may create a missing runtime shadow, but
|
||||
# ordered event/snapshot projection remains the only updater of
|
||||
# existing Account identity and status fields.
|
||||
continue
|
||||
if account.projection_revision > snapshot.cursor:
|
||||
raise DirectoryProjectionUnavailableError('Directory account revision rolled back')
|
||||
projected_account = self._account_projection(member)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -47,3 +47,10 @@ class SpaceModel(pydantic.BaseModel):
|
||||
status: str
|
||||
created_at: str | None = None
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
class SpaceModelSelection(pydantic.BaseModel):
|
||||
"""Minimal model identity returned by the ranked selection endpoint."""
|
||||
|
||||
uuid: str
|
||||
model_id: str
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import dataclasses
|
||||
import datetime
|
||||
import json
|
||||
@@ -12,6 +13,7 @@ import re
|
||||
import secrets
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import time
|
||||
import typing
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
@@ -81,7 +83,7 @@ def _verify_connection(connection: sqlite3.Connection, expected_revision: str) -
|
||||
|
||||
|
||||
def _verify_file(path: pathlib.Path, expected_revision: str) -> None:
|
||||
with _open_read_only(path) as connection:
|
||||
with contextlib.closing(_open_read_only(path)) as connection:
|
||||
_verify_connection(connection, expected_revision)
|
||||
|
||||
|
||||
@@ -117,8 +119,23 @@ 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.
|
||||
|
||||
Uses O_RDWR so os.fsync works on Windows (where _commit requires write
|
||||
access to the file descriptor).
|
||||
"""
|
||||
|
||||
descriptor: int | None = None
|
||||
for attempt in range(reopen_attempts):
|
||||
try:
|
||||
descriptor = os.open(path, os.O_RDWR)
|
||||
break
|
||||
except FileNotFoundError:
|
||||
if attempt + 1 >= reopen_attempts:
|
||||
raise
|
||||
time.sleep(0.05)
|
||||
assert descriptor is not None
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
@@ -126,13 +143,37 @@ def _fsync_file(path: pathlib.Path) -> None:
|
||||
|
||||
|
||||
def _fsync_directory(path: pathlib.Path) -> None:
|
||||
descriptor = os.open(path, os.O_RDONLY)
|
||||
if os.name == 'nt':
|
||||
# Windows cannot fsync directory handles opened through os.open.
|
||||
return
|
||||
descriptor = os.open(path, os.O_RDONLY | getattr(os, 'O_DIRECTORY', 0))
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _remove_stale_temporary_files(
|
||||
directory: pathlib.Path,
|
||||
*,
|
||||
prefix: str,
|
||||
suffix: str,
|
||||
) -> None:
|
||||
"""Remove temporary files left by an interrupted backup or restore."""
|
||||
|
||||
for candidate in directory.iterdir():
|
||||
if candidate.is_dir() or not candidate.name.startswith(prefix) or not candidate.name.endswith(suffix):
|
||||
continue
|
||||
try:
|
||||
candidate.unlink()
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
except PermissionError:
|
||||
# Another process may still own this file. Do not turn harmless
|
||||
# cleanup into a migration failure; its unique name cannot collide.
|
||||
continue
|
||||
|
||||
|
||||
def _create_backup(
|
||||
database_path: pathlib.Path,
|
||||
source_revision: str,
|
||||
@@ -141,6 +182,11 @@ def _create_backup(
|
||||
backup_directory = database_path.parent / 'migration-backups'
|
||||
backup_directory.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
os.chmod(backup_directory, 0o700)
|
||||
_remove_stale_temporary_files(
|
||||
backup_directory,
|
||||
prefix=f'.{database_path.stem}-pre-',
|
||||
suffix='.creating',
|
||||
)
|
||||
created_at = datetime.datetime.now(datetime.UTC).strftime('%Y-%m-%dT%H-%M-%S.%fZ')
|
||||
stem = (
|
||||
f'{database_path.stem}-pre-{_safe_label(target_revision)}-'
|
||||
@@ -157,11 +203,8 @@ def _create_backup(
|
||||
temporary_path = pathlib.Path(temporary_name)
|
||||
try:
|
||||
with (
|
||||
_open_read_only(database_path) as source,
|
||||
sqlite3.connect(
|
||||
temporary_path,
|
||||
timeout=30,
|
||||
) as destination,
|
||||
contextlib.closing(_open_read_only(database_path)) as source,
|
||||
contextlib.closing(sqlite3.connect(temporary_path, timeout=30)) as destination,
|
||||
):
|
||||
source.execute('PRAGMA busy_timeout = 30000')
|
||||
source.backup(destination)
|
||||
@@ -209,6 +252,11 @@ async def create_verified_backup(
|
||||
|
||||
def _restore_backup(backup: SQLiteMigrationBackup) -> None:
|
||||
_verify_file(backup.backup_path, backup.source_revision)
|
||||
_remove_stale_temporary_files(
|
||||
backup.database_path.parent,
|
||||
prefix=f'.{backup.database_path.name}.',
|
||||
suffix='.restoring',
|
||||
)
|
||||
descriptor, temporary_name = tempfile.mkstemp(
|
||||
prefix=f'.{backup.database_path.name}.',
|
||||
suffix='.restoring',
|
||||
@@ -218,11 +266,8 @@ def _restore_backup(backup: SQLiteMigrationBackup) -> None:
|
||||
temporary_path = pathlib.Path(temporary_name)
|
||||
try:
|
||||
with (
|
||||
_open_read_only(backup.backup_path) as source,
|
||||
sqlite3.connect(
|
||||
temporary_path,
|
||||
timeout=30,
|
||||
) as destination,
|
||||
contextlib.closing(_open_read_only(backup.backup_path)) as source,
|
||||
contextlib.closing(sqlite3.connect(temporary_path, timeout=30)) as destination,
|
||||
):
|
||||
source.backup(destination)
|
||||
destination.commit()
|
||||
|
||||
@@ -209,7 +209,7 @@ _ALLOWED_SCOPED_BUILTIN_FUNCTION_TYPES = {
|
||||
'now': sqlalchemy.sql.functions.now,
|
||||
'sum': sqlalchemy.sql.functions.sum,
|
||||
}
|
||||
_ALLOWED_SCOPED_GENERIC_FUNCTIONS = frozenset({'date_trunc', 'length', 'nullif'})
|
||||
_ALLOWED_SCOPED_GENERIC_FUNCTIONS = frozenset({'date_trunc', 'length', 'nullif', 'strftime'})
|
||||
_ALLOWED_SCOPED_CUSTOM_OPERATORS = frozenset({'<=>'})
|
||||
_ALLOWED_SCOPED_STATEMENT_TYPES = (
|
||||
sqlalchemy.sql.dml.UpdateBase,
|
||||
|
||||
@@ -5,6 +5,11 @@ from .. import entities
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
from ....utils.safe_regex import SafeRegexError, mask_patterns
|
||||
|
||||
# Legacy sensitive-words.json files shipped ~70 rules, which exceeds the
|
||||
# default safe_regex per-call cap of 64 and used to fail-close every message.
|
||||
# Keep one 50ms CPU budget for the whole list; only raise the pattern cap.
|
||||
_MAX_SENSITIVE_WORD_PATTERNS = 256
|
||||
|
||||
|
||||
@filter_model.filter_class('ban-word-filter')
|
||||
class BanWordFilter(filter_model.ContentFilter):
|
||||
@@ -14,12 +19,17 @@ class BanWordFilter(filter_model.ContentFilter):
|
||||
pass
|
||||
|
||||
async def process(self, query: pipeline_query.Query, message: str) -> entities.FilterResult:
|
||||
words = self.ap.sensitive_meta.data.get('words') or []
|
||||
mask = self.ap.sensitive_meta.data['mask']
|
||||
mask_word = self.ap.sensitive_meta.data['mask_word']
|
||||
|
||||
try:
|
||||
found, message = await mask_patterns(
|
||||
self.ap.sensitive_meta.data['words'],
|
||||
found, current = await mask_patterns(
|
||||
words,
|
||||
message,
|
||||
mask=self.ap.sensitive_meta.data['mask'],
|
||||
mask_word=self.ap.sensitive_meta.data['mask_word'],
|
||||
mask=mask,
|
||||
mask_word=mask_word,
|
||||
max_pattern_count=_MAX_SENSITIVE_WORD_PATTERNS,
|
||||
)
|
||||
except SafeRegexError as exc:
|
||||
return entities.FilterResult(
|
||||
@@ -31,7 +41,7 @@ class BanWordFilter(filter_model.ContentFilter):
|
||||
|
||||
return entities.FilterResult(
|
||||
level=entities.ResultLevel.MASKED if found else entities.ResultLevel.PASS,
|
||||
replacement=message,
|
||||
replacement=current,
|
||||
user_notice='消息中存在不合适的内容, 请修改' if found else '',
|
||||
console_notice='',
|
||||
)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -160,6 +160,29 @@ def _lark_should_update_stream_element(
|
||||
return not resume_from and not form_data and (msg_seq % 8 == 0 or is_final)
|
||||
|
||||
|
||||
def _lark_final_layout_texts(
|
||||
*,
|
||||
resume_from: bool,
|
||||
text_message: str,
|
||||
pre_pause_cached: str | None,
|
||||
resume_cached: str,
|
||||
) -> tuple[str, str]:
|
||||
"""Return (main_text, resume_placeholder_text) for the final card update.
|
||||
|
||||
Non-resume round: the full reply belongs in the main streaming element
|
||||
only — also rendering the resume placeholder duplicates the reply, since
|
||||
both hold the same accumulated text. Resume round (Dify HITL): keep the
|
||||
pre-pause text in the main element and the resumed text in the
|
||||
placeholder, as they are distinct segments.
|
||||
"""
|
||||
if resume_from:
|
||||
# An empty pre-pause cache is valid (Dify paused before emitting any
|
||||
# text); only a missing entry (None) falls back to the full text.
|
||||
main_text = text_message if pre_pause_cached is None else pre_pause_cached
|
||||
return main_text, resume_cached
|
||||
return text_message, ''
|
||||
|
||||
|
||||
def _lark_display_input_value(field: dict, value: typing.Any) -> str:
|
||||
field_type = _dify_field_type(field)
|
||||
if field_type == 'file':
|
||||
@@ -2358,16 +2381,21 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
self.card_form_input_defs[card_id] = _lark_form_input_defs(form_data)
|
||||
self.card_form_inputs[card_id] = dict(form_data.get('inputs') or {})
|
||||
else:
|
||||
# Normal finish: keep pre-pause + resume content visible,
|
||||
# remove buttons/notice, drop the resume placeholder.
|
||||
# Normal finish: remove buttons/notice and finalize the card.
|
||||
main_text, resume_text = _lark_final_layout_texts(
|
||||
resume_from=resume_from,
|
||||
text_message=text_message,
|
||||
pre_pause_cached=self.card_pre_pause_text.get(card_id),
|
||||
resume_cached=resume_cached,
|
||||
)
|
||||
await self._update_card_layout(
|
||||
card_id=card_id,
|
||||
message_source=message_source,
|
||||
text_message=pre_pause,
|
||||
text_message=main_text,
|
||||
sequence=final_seq,
|
||||
form_data=None,
|
||||
notice_text=selected_notice if resume_from else '',
|
||||
resume_placeholder_text=resume_cached,
|
||||
resume_placeholder_text=resume_text,
|
||||
)
|
||||
self._drop_card_state(card_id)
|
||||
self.card_id_dict.pop(message_id, None)
|
||||
|
||||
@@ -25,6 +25,7 @@ from linebot.v3.webhooks import (
|
||||
ImageMessageContent,
|
||||
VideoMessageContent,
|
||||
AudioMessageContent,
|
||||
UserMentionee,
|
||||
)
|
||||
|
||||
# from linebot import WebhookParser
|
||||
@@ -58,15 +59,19 @@ class LINEMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
|
||||
return content_list
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(message, bot_client) -> platform_message.MessageChain:
|
||||
def __init__(self, bot_account_id: str = ''):
|
||||
self.bot_account_id = bot_account_id
|
||||
|
||||
async def target2yiri(self, message, bot_client) -> platform_message.MessageChain:
|
||||
lb_msg_list = []
|
||||
msg_create_time = datetime.datetime.fromtimestamp(int(message.timestamp) / 1000)
|
||||
|
||||
lb_msg_list.append(platform_message.Source(id=message.webhook_event_id, time=msg_create_time))
|
||||
|
||||
if isinstance(message.message, TextMessageContent):
|
||||
lb_msg_list.append(platform_message.Plain(text=message.message.text))
|
||||
lb_msg_list.extend(
|
||||
self._build_text_components(message.message.text, getattr(message.message, 'mention', None))
|
||||
)
|
||||
elif isinstance(message.message, AudioMessageContent):
|
||||
pass
|
||||
elif isinstance(message.message, VideoMessageContent):
|
||||
@@ -86,22 +91,60 @@ class LINEMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
lb_msg_list.append(platform_message.Image(base64=data_uri))
|
||||
return platform_message.MessageChain(lb_msg_list)
|
||||
|
||||
def _build_text_components(self, text: str, mention) -> list:
|
||||
"""Build message components from text, inserting At components for mentions.
|
||||
|
||||
LINE provides mention positions (index/length) and is_self per mentionee in the
|
||||
webhook payload. Mapping the bot mention to At(target=bot_account_id) makes the
|
||||
'at-bot' group respond rule work for LINE, consistent with other adapters.
|
||||
"""
|
||||
components: list = []
|
||||
if not mention or not mention.mentionees:
|
||||
if text:
|
||||
components.append(platform_message.Plain(text=text))
|
||||
return components
|
||||
segments: list[tuple[int, int, object]] = sorted((m.index, m.index + m.length, m) for m in mention.mentionees)
|
||||
cursor = 0
|
||||
for start, end, mentionee in segments:
|
||||
if start < cursor:
|
||||
start, end = cursor, min(end, len(text))
|
||||
if start < cursor or end <= start or end > len(text):
|
||||
continue
|
||||
if start > cursor:
|
||||
components.append(platform_message.Plain(text=text[cursor:start]))
|
||||
if isinstance(mentionee, UserMentionee):
|
||||
target = self.bot_account_id if mentionee.is_self else mentionee.user_id
|
||||
if not target:
|
||||
target = text[start:end]
|
||||
else:
|
||||
target = text[start:end]
|
||||
# At.__str__ already prepends '@', so strip one from the LINE text token.
|
||||
display = text[start:end].lstrip('@')
|
||||
components.append(platform_message.At(target=str(target), display=display))
|
||||
cursor = end
|
||||
if cursor < len(text):
|
||||
components.append(platform_message.Plain(text=text[cursor:]))
|
||||
return components
|
||||
|
||||
|
||||
class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
def __init__(self, bot_account_id: str = ''):
|
||||
self.bot_account_id = bot_account_id
|
||||
self.message_converter = LINEMessageConverter(bot_account_id)
|
||||
|
||||
@staticmethod
|
||||
async def yiri2target(
|
||||
event: platform_events.MessageEvent,
|
||||
) -> MessageEvent:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(event, bot_client) -> platform_events.Event:
|
||||
message_chain = await LINEMessageConverter.target2yiri(event, bot_client)
|
||||
async def target2yiri(self, event, bot_client) -> platform_events.Event:
|
||||
message_chain = await self.message_converter.target2yiri(event, bot_client)
|
||||
|
||||
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 +153,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,
|
||||
),
|
||||
@@ -163,8 +212,8 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
listeners={},
|
||||
card_id_dict={},
|
||||
seq=1,
|
||||
event_converter=LINEEventConverter(),
|
||||
message_converter=LINEMessageConverter(),
|
||||
event_converter=LINEEventConverter(bot_account_id),
|
||||
message_converter=LINEMessageConverter(bot_account_id),
|
||||
line_webhook=line_webhook,
|
||||
parser=parser,
|
||||
configuration=configuration,
|
||||
|
||||
@@ -205,7 +205,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
||||
bot = QQOfficialClient(
|
||||
app_id=config['appid'],
|
||||
secret=config['secret'],
|
||||
token=config['token'],
|
||||
token=config.get('token', ''),
|
||||
logger=logger,
|
||||
unified_mode=enable_webhook,
|
||||
)
|
||||
@@ -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()}')
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -274,11 +274,11 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
if content['type'] == 'text':
|
||||
await self.bot.send_private_msg(user_id, agent_id, content['content'])
|
||||
if content['type'] == 'image':
|
||||
await self.bot.send_image(user_id, agent_id, content['media'])
|
||||
await self.bot.send_image(user_id, agent_id, content['media_id'])
|
||||
if content['type'] == 'voice':
|
||||
await self.bot.send_voice(user_id, agent_id, content['media'])
|
||||
await self.bot.send_voice(user_id, agent_id, content['media_id'])
|
||||
if content['type'] == 'file':
|
||||
await self.bot.send_file(user_id, agent_id, content['media'])
|
||||
await self.bot.send_file(user_id, agent_id, content['media_id'])
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
if event.type == 'text':
|
||||
yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)
|
||||
friend = platform_entities.Friend(
|
||||
id=f'u{event.user_id}',
|
||||
id=f'{event.receiver_id}|u{event.user_id}',
|
||||
nickname=nickname,
|
||||
remark='',
|
||||
)
|
||||
@@ -117,7 +117,7 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
)
|
||||
elif event.type == 'image':
|
||||
friend = platform_entities.Friend(
|
||||
id=f'u{event.user_id}',
|
||||
id=f'{event.receiver_id}|u{event.user_id}',
|
||||
nickname=nickname,
|
||||
remark='',
|
||||
)
|
||||
@@ -197,7 +197,7 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
|
||||
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
|
||||
for content in content_list:
|
||||
msgid = f'langbot_{uuid.uuid4().hex}'
|
||||
msgid = f'{uuid.uuid4().hex}'
|
||||
if content['type'] == 'text':
|
||||
await self.bot.send_text_msg(
|
||||
open_kfid=open_kfid,
|
||||
@@ -205,6 +205,13 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
msgid=msgid,
|
||||
content=content['content'],
|
||||
)
|
||||
elif content['type'] == 'image':
|
||||
await self.bot.send_image_msg(
|
||||
open_kfid=open_kfid,
|
||||
external_userid=external_userid,
|
||||
msgid=msgid,
|
||||
media_id=content['media_id'],
|
||||
)
|
||||
|
||||
def set_bot_uuid(self, bot_uuid: str):
|
||||
"""设置 bot UUID(用于生成 webhook URL)"""
|
||||
|
||||
@@ -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()):
|
||||
@@ -1896,9 +1913,14 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
|
||||
return plugins
|
||||
|
||||
async def get_plugin_info(self, author: str, plugin_name: str) -> dict[str, Any]:
|
||||
async def get_plugin_info(self, author: str, plugin_name: str) -> dict[str, Any] | None:
|
||||
runtime_handler = self._runtime_handler()
|
||||
binding = await self._target_binding(author, plugin_name)
|
||||
try:
|
||||
binding = await self._target_binding(author, plugin_name)
|
||||
except ValueError as exc:
|
||||
if str(exc) == f'Plugin {author}/{plugin_name} is not installed in this Workspace':
|
||||
return None
|
||||
raise
|
||||
with runtime_handler.installation_scope(binding):
|
||||
return await runtime_handler.get_plugin_info(author, plugin_name)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -573,7 +573,7 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
||||
levels = ['provider_default', 'disabled', 'enabled']
|
||||
elif family == 'doubao':
|
||||
levels = ['provider_default', 'disabled', 'low', 'medium', 'high']
|
||||
elif family == 'ollama':
|
||||
elif family in ('ollama', 'ollama_chat'):
|
||||
levels = ['provider_default']
|
||||
levels.append('disabled')
|
||||
if normalized_name.startswith('gpt-oss') or '/gpt-oss' in normalized_name:
|
||||
@@ -747,9 +747,24 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
||||
converted_parts = []
|
||||
for part in content:
|
||||
if isinstance(part, dict) and part.get('type') == 'image_base64':
|
||||
part['image_url'] = {'url': part['image_base64']}
|
||||
part['type'] = 'image_url'
|
||||
del part['image_base64']
|
||||
# History trimming (SessionManager) clears image_base64
|
||||
# on past turns and exclude_none serialization drops
|
||||
# the key entirely, so the replayed part may carry no
|
||||
# payload. Prefer the base64 payload; fall back to an
|
||||
# image_url that survived on the same element; drop
|
||||
# hollow parts instead of raising KeyError (#2469).
|
||||
image_b64 = part.get('image_base64')
|
||||
fallback_url = None
|
||||
if not image_b64:
|
||||
raw_image_url = part.get('image_url')
|
||||
if isinstance(raw_image_url, dict):
|
||||
fallback_url = raw_image_url.get('url')
|
||||
if image_b64 or fallback_url:
|
||||
part['image_url'] = {'url': image_b64 or fallback_url}
|
||||
part['type'] = 'image_url'
|
||||
part.pop('image_base64', None)
|
||||
else:
|
||||
continue
|
||||
# OpenAI-compatible chat models reject non-image file parts
|
||||
# (audio/document base64 or url). These originate from Voice /
|
||||
# File attachments — including ones replayed from conversation
|
||||
@@ -1330,7 +1345,14 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
||||
extra_args: dict[str, typing.Any] = {},
|
||||
) -> tuple[list[list[float]], dict]:
|
||||
"""Invoke embedding and return vectors with usage info."""
|
||||
model_name = self._build_litellm_model_name(model.model_entity.name)
|
||||
# litellm's embedding routing has no "ollama_chat" branch (that provider
|
||||
# exists only for /api/chat completions) — embeddings still go through
|
||||
# the plain "ollama" provider. Requesters configured for ollama_chat
|
||||
# (to get native tool-calling on the chat path) must fall back to
|
||||
# "ollama" here specifically, or embedding calls raise "Unmapped LLM
|
||||
# provider for this endpoint".
|
||||
embedding_provider = 'ollama' if self._get_custom_llm_provider() == 'ollama_chat' else None
|
||||
model_name = self._build_litellm_model_name(model.model_entity.name, embedding_provider)
|
||||
api_key = model.provider.token_mgr.get_token()
|
||||
|
||||
args = {
|
||||
@@ -1526,6 +1548,12 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
||||
event_hooks=httpclient.httpx_response_limit_hooks(),
|
||||
) as client:
|
||||
response = await client.get(models_url, headers=headers)
|
||||
if response.status_code == 404 and not base_url.rstrip('/').endswith('/v1'):
|
||||
# Some OpenAI-compatible servers (notably a bare Ollama host,
|
||||
# e.g. http://host:11434) expose the model list under /v1/models
|
||||
# rather than /models. Providers whose configured base_url
|
||||
# already ends in /v1 keep their original (working) URL.
|
||||
response = await client.get(f'{base_url}/v1/models', headers=headers)
|
||||
response.raise_for_status()
|
||||
payload = await httpclient.parse_json_response(response)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ metadata:
|
||||
zh_Hans: Ollama
|
||||
icon: ollama.svg
|
||||
spec:
|
||||
litellm_provider: ollama
|
||||
litellm_provider: ollama_chat
|
||||
config:
|
||||
- name: base_url
|
||||
label:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -619,7 +619,9 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
and len(func_ret) > 0
|
||||
and isinstance(func_ret[0], provider_message.ContentElement)
|
||||
):
|
||||
tool_content = func_ret
|
||||
# OpenAI-compatible APIs require tool-message content to be a
|
||||
# string; a raw list of ContentElement causes HTTP 500 (#2457).
|
||||
tool_content = '\n'.join(str(ce) for ce in func_ret)
|
||||
else:
|
||||
tool_content = json.dumps(func_ret, ensure_ascii=False)
|
||||
|
||||
|
||||
@@ -222,6 +222,7 @@ class NativeToolLoader(loader.ToolLoader):
|
||||
self.ap.logger.warning(
|
||||
'Native sandbox tools (exec/read/write/edit/glob/grep) are NOT available. '
|
||||
'No sandbox backend (Docker/nsjail/E2B) is ready. '
|
||||
'Trusted local development may explicitly select box.backend=host. '
|
||||
'The LLM will not have access to code execution or file operation tools.'
|
||||
)
|
||||
|
||||
|
||||
@@ -42,7 +42,8 @@ class SkillToolLoader(loader.ToolLoader):
|
||||
else:
|
||||
self.ap.logger.info(
|
||||
'Skill tools (activate/register_skill) are NOT available. '
|
||||
'No sandbox backend (Docker/nsjail/E2B) is ready.'
|
||||
'No sandbox backend (Docker/nsjail/E2B) is ready. '
|
||||
'Trusted local development may explicitly select box.backend=host.'
|
||||
)
|
||||
|
||||
async def _check_sandbox_available(self) -> bool:
|
||||
|
||||
@@ -27,10 +27,16 @@ class SafeRegexTimeoutError(SafeRegexError):
|
||||
"""Raised when the regex engine exhausts the operation CPU budget."""
|
||||
|
||||
|
||||
def _validate_patterns(patterns: Sequence[str]) -> tuple[str, ...]:
|
||||
def _validate_patterns(
|
||||
patterns: Sequence[str],
|
||||
*,
|
||||
max_pattern_count: int = MAX_PATTERN_COUNT,
|
||||
) -> tuple[str, ...]:
|
||||
if max_pattern_count < 1:
|
||||
raise ValueError('max_pattern_count must be positive')
|
||||
if len(patterns) > max_pattern_count:
|
||||
raise SafeRegexLimitError(f'At most {max_pattern_count} regex patterns are allowed')
|
||||
normalized = tuple(patterns)
|
||||
if len(normalized) > MAX_PATTERN_COUNT:
|
||||
raise SafeRegexLimitError(f'At most {MAX_PATTERN_COUNT} regex patterns are allowed')
|
||||
for pattern in normalized:
|
||||
if not isinstance(pattern, str):
|
||||
raise SafeRegexError('Regex patterns must be strings')
|
||||
@@ -115,8 +121,9 @@ def _mask_patterns_sync(
|
||||
mask: str,
|
||||
mask_word: str,
|
||||
timeout_seconds: float,
|
||||
max_pattern_count: int,
|
||||
) -> tuple[bool, str]:
|
||||
normalized_patterns = _validate_patterns(patterns)
|
||||
normalized_patterns = _validate_patterns(patterns, max_pattern_count=max_pattern_count)
|
||||
_validate_input(value)
|
||||
if len(mask) > MAX_REPLACEMENT_CHARS or len(mask_word) > MAX_REPLACEMENT_CHARS:
|
||||
raise SafeRegexLimitError(f'Regex replacements may contain at most {MAX_REPLACEMENT_CHARS} characters')
|
||||
@@ -162,6 +169,7 @@ async def mask_patterns(
|
||||
mask: str,
|
||||
mask_word: str,
|
||||
timeout_seconds: float = DEFAULT_OPERATION_TIMEOUT_SECONDS,
|
||||
max_pattern_count: int = MAX_PATTERN_COUNT,
|
||||
) -> tuple[bool, str]:
|
||||
"""Apply untrusted masking patterns with bounded CPU and output growth."""
|
||||
|
||||
@@ -174,4 +182,5 @@ async def mask_patterns(
|
||||
mask=mask,
|
||||
mask_word=mask_word,
|
||||
timeout_seconds=timeout_seconds,
|
||||
max_pattern_count=max_pattern_count,
|
||||
)
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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:
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Join {escaped_workspace} on LangBot Cloud</title>
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<title>Join {escaped_workspace} in LangBot</title>
|
||||
</head>
|
||||
<body style="margin:0;background:#f4f7fb;color:#152033;font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
|
||||
<div style="display:none;max-height:0;overflow:hidden;opacity:0;">You have been invited to join {escaped_workspace} on LangBot Cloud.</div>
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f4f7fb;padding:40px 16px;">
|
||||
<tr><td align="center">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:600px;background:#ffffff;border:1px solid #e5eaf2;border-radius:16px;overflow:hidden;box-shadow:0 12px 32px rgba(20,49,93,.08);">
|
||||
<tr><td style="padding:28px 36px;background:linear-gradient(135deg,#0f172a,#1d4ed8);color:#ffffff;">
|
||||
<div style="font-size:14px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;opacity:.78;">LangBot Cloud</div>
|
||||
<div style="font-size:26px;font-weight:700;margin-top:8px;line-height:1.25;">You’re invited</div>
|
||||
</td></tr>
|
||||
<tr><td style="padding:36px;">
|
||||
<p style="margin:0 0 18px;font-size:16px;line-height:1.65;color:#475569;">You have been invited to collaborate in this Workspace:</p>
|
||||
<div style="margin:0 0 26px;padding:18px 20px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:12px;font-size:18px;font-weight:700;color:#0f172a;">{escaped_workspace}</div>
|
||||
<table role="presentation" cellspacing="0" cellpadding="0"><tr><td style="border-radius:9px;background:#2563eb;">
|
||||
<a href="{escaped_link}" style="display:inline-block;padding:13px 22px;color:#ffffff;text-decoration:none;font-size:15px;font-weight:700;">Accept invitation</a>
|
||||
</td></tr></table>
|
||||
<p style="margin:26px 0 8px;font-size:14px;line-height:1.6;color:#64748b;">This invitation expires in 7 days and is bound to the email address that received it.</p>
|
||||
<p style="margin:0 0 8px;font-size:13px;line-height:1.6;color:#94a3b8;">If the button does not work, copy and paste this URL into your browser:</p>
|
||||
<p style="margin:0;padding:12px;background:#f8fafc;border-radius:8px;word-break:break-all;font-size:12px;line-height:1.55;color:#475569;">{escaped_link}</p>
|
||||
</td></tr>
|
||||
<tr><td style="padding:20px 36px;border-top:1px solid #eef2f7;font-size:12px;line-height:1.6;color:#94a3b8;">If you were not expecting this invitation, you can safely ignore this email.</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
<body style="margin:0;padding:0;background:#f4f7fb;color:#111827;font-family:Arial,'Helvetica Neue',sans-serif;">
|
||||
<div style="display:none;max-height:0;overflow:hidden;opacity:0;">You have been invited to join {escaped_workspace} in LangBot.</div>
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;background:#f4f7fb;">
|
||||
<tr>
|
||||
<td align="center" style="padding:48px 16px;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;max-width:600px;">
|
||||
<tr>
|
||||
<td style="padding:0 4px 20px;">
|
||||
<img src="https://docs.langbot.app/langbot-logo.png" alt="LangBot" width="34" height="34" style="display:inline-block;width:34px;height:34px;border:0;vertical-align:middle;">
|
||||
<span style="display:inline-block;margin-left:10px;vertical-align:middle;font-size:18px;font-weight:700;letter-spacing:-.01em;">LangBot</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background:#ffffff;border-radius:10px;overflow:hidden;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
|
||||
<tr>
|
||||
<td style="padding:42px 42px 38px;">
|
||||
<div style="margin:0 0 12px;font-size:13px;line-height:1.4;font-weight:600;color:#5f6f84;">Workspace invitation</div>
|
||||
<h1 style="margin:0 0 16px;font-size:28px;line-height:1.25;font-weight:700;letter-spacing:-.025em;color:#111827;">You’re invited to collaborate</h1>
|
||||
<p style="margin:0 0 28px;font-size:15px;line-height:1.7;color:#526173;">Join your team in LangBot and start building together in this Workspace.</p>
|
||||
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background:#f6f8fb;border-radius:8px;">
|
||||
<tr>
|
||||
<td style="padding:16px 18px;">
|
||||
<div style="margin:0 0 4px;font-size:11px;line-height:1.4;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#5f6f84;">Workspace</div>
|
||||
<div style="font-size:18px;line-height:1.4;font-weight:700;color:#111827;">{escaped_workspace}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
|
||||
<tr><td height="28" style="height:28px;font-size:0;line-height:0;"> </td></tr>
|
||||
</table>
|
||||
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
|
||||
<tr>
|
||||
<td style="background:#2563eb;border-radius:8px;">
|
||||
<a href="{escaped_link}" target="_blank" style="display:inline-block;padding:13px 22px;font-size:15px;line-height:1.2;font-weight:700;color:#ffffff;text-decoration:none;border-radius:8px;">Accept invitation</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
|
||||
<tr><td height="32" style="height:32px;font-size:0;line-height:0;"> </td></tr>
|
||||
</table>
|
||||
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="border-top:1px solid #e8edf4;">
|
||||
<tr>
|
||||
<td style="padding-top:22px;">
|
||||
<p style="margin:0 0 10px;font-size:13px;line-height:1.6;color:#5f6f84;">For your security, this invitation expires in 7 days and only works for the email address that received it.</p>
|
||||
<a href="{escaped_link}" target="_blank" style="font-size:13px;line-height:1.6;font-weight:600;color:#2563eb;text-decoration:none;">Open invitation link →</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding:20px 24px 0;font-size:12px;line-height:1.6;color:#5f6f84;">
|
||||
Sent by LangBot<br>
|
||||
If you were not expecting this invitation, you can safely ignore this email.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>'''
|
||||
|
||||
@@ -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:
|
||||
@@ -326,10 +331,14 @@ box:
|
||||
# skill tool, skill add/edit, and stdio-mode MCP servers. Skills can still
|
||||
# be listed read-only and http/sse MCP servers continue to work.
|
||||
enabled: true
|
||||
backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', or 'e2b'. Can be written via BOX__BACKEND.
|
||||
# 'host' runs commands directly as the Box Runtime user without sandbox
|
||||
# isolation. It is never auto-selected and is only for trusted local
|
||||
# development. Can be written via BOX__BACKEND.
|
||||
backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', 'e2b', or explicit unsafe 'host'.
|
||||
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
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
// Read config from script tag data attributes
|
||||
var scriptEl = document.currentScript;
|
||||
var scriptTitle = scriptEl ? scriptEl.getAttribute("data-title") : null;
|
||||
var scriptTestNotice = scriptEl
|
||||
? scriptEl.getAttribute("data-test-notice")
|
||||
: null;
|
||||
|
||||
// ========== i18n ==========
|
||||
var I18N = {
|
||||
@@ -192,6 +195,7 @@
|
||||
.lb-header-btn { background: none; border: none; color: #fff; cursor: pointer; padding: 4px; border-radius: 6px; display: flex; align-items: center; justify-content: center; opacity: 0.8; transition: opacity 0.15s; }\
|
||||
.lb-header-btn:hover { opacity: 1; }\
|
||||
.lb-header-btn svg { width: 18px; height: 18px; fill: currentColor; }\
|
||||
.lb-test-notice { padding: 8px 16px; border-bottom: 1px solid #fde68a; background: #fffbeb; color: #92400e; font-size: 12px; line-height: 1.5; text-align: center; flex-shrink: 0; }\
|
||||
.lb-messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 16px; scroll-behavior: smooth; }\
|
||||
.lb-messages::-webkit-scrollbar { width: 6px; }\
|
||||
.lb-messages::-webkit-scrollbar-track { background: transparent; }\
|
||||
@@ -638,9 +642,10 @@
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
if (
|
||||
prevContent === content ||
|
||||
prevContent.indexOf(content) >= 0 ||
|
||||
content.indexOf(prevContent) >= 0
|
||||
prevContent &&
|
||||
(prevContent === content ||
|
||||
prevContent.indexOf(content) >= 0 ||
|
||||
content.indexOf(prevContent) >= 0)
|
||||
)
|
||||
return;
|
||||
}
|
||||
@@ -1240,6 +1245,14 @@
|
||||
// Root container
|
||||
var root = document.createElement("div");
|
||||
root.id = "langbot-widget-root";
|
||||
root.langbotDestroy = function () {
|
||||
wsDisconnect();
|
||||
if (state.historyReloadTimer) {
|
||||
clearTimeout(state.historyReloadTimer);
|
||||
state.historyReloadTimer = null;
|
||||
}
|
||||
root.remove();
|
||||
};
|
||||
document.body.appendChild(root);
|
||||
|
||||
var shadow = root.attachShadow({ mode: "open" });
|
||||
@@ -1328,6 +1341,14 @@
|
||||
header.appendChild(headerActions);
|
||||
panel.appendChild(header);
|
||||
|
||||
if (scriptTestNotice) {
|
||||
var testNotice = document.createElement("div");
|
||||
testNotice.className = "lb-test-notice";
|
||||
testNotice.setAttribute("role", "note");
|
||||
testNotice.textContent = scriptTestNotice;
|
||||
panel.appendChild(testNotice);
|
||||
}
|
||||
|
||||
// Messages area
|
||||
var messages = document.createElement("div");
|
||||
messages.className = "lb-messages";
|
||||
|
||||
@@ -325,7 +325,7 @@ stages:
|
||||
zh_Hans: API 密钥
|
||||
type: string
|
||||
required: true
|
||||
default: 'your-api-key'
|
||||
default: ''
|
||||
- name: n8n-service-api
|
||||
label:
|
||||
en_US: n8n Workflow API
|
||||
|
||||
@@ -242,6 +242,22 @@ class TestMonitoringSessionsEndpoint:
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_sessions_forwards_user_search_and_page_window(self, quart_test_client, fake_monitoring_app):
|
||||
fake_monitoring_app.monitoring_service.get_sessions.reset_mock()
|
||||
|
||||
response = await quart_test_client.get(
|
||||
'/api/v1/monitoring/sessions?botId=bot-1&userQuery=alice&limit=20&offset=40',
|
||||
headers={'Authorization': 'Bearer test_token'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
kwargs = fake_monitoring_app.monitoring_service.get_sessions.await_args.kwargs
|
||||
assert kwargs['bot_ids'] == ['bot-1']
|
||||
assert kwargs['user_query'] == 'alice'
|
||||
assert kwargs['limit'] == 20
|
||||
assert kwargs['offset'] == 40
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||
class TestMonitoringErrorsEndpoint:
|
||||
@@ -278,13 +294,19 @@ class TestMonitoringDetailsEndpoints:
|
||||
"""Tests for detail endpoints."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_session_analysis(self, quart_test_client):
|
||||
async def test_get_session_analysis(self, quart_test_client, fake_monitoring_app):
|
||||
"""GET /api/v1/monitoring/sessions/{id}/analysis."""
|
||||
response = await quart_test_client.get(
|
||||
'/api/v1/monitoring/sessions/sess-1/analysis', headers={'Authorization': 'Bearer test_token'}
|
||||
'/api/v1/monitoring/sessions/sess-1/analysis'
|
||||
'?startTime=2026-08-31T16%3A00%3A00.000Z'
|
||||
'&endTime=2026-09-01T15%3A59%3A59.999Z',
|
||||
headers={'Authorization': 'Bearer test_token'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
kwargs = fake_monitoring_app.monitoring_service.get_session_analysis.await_args.kwargs
|
||||
assert kwargs['start_time'].isoformat() == '2026-08-31T16:00:00'
|
||||
assert kwargs['end_time'].isoformat() == '2026-09-01T15:59:59.999000'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_message_details(self, quart_test_client):
|
||||
|
||||
@@ -254,6 +254,22 @@ class TestPipelinesCRUDEndpoints:
|
||||
assert data['code'] == 0
|
||||
assert 'uuid' in data['data']
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_default_pipeline_forwards_default_flag(self, quart_test_client, fake_pipeline_app):
|
||||
"""POST /api/v1/pipelines explicitly creates a default pipeline."""
|
||||
fake_pipeline_app.pipeline_service.create_pipeline.reset_mock()
|
||||
|
||||
response = await quart_test_client.post(
|
||||
'/api/v1/pipelines',
|
||||
headers={'Authorization': 'Bearer test_token'},
|
||||
json={'name': 'Default Pipeline', 'config': {}, 'is_default': True},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
call = fake_pipeline_app.pipeline_service.create_pipeline.await_args
|
||||
assert call.kwargs == {'default': True}
|
||||
assert call.args[1]['is_default'] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_pipeline_success(self, quart_test_client):
|
||||
"""PUT /api/v1/pipelines/{uuid} updates pipeline."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -27,7 +27,8 @@ async def space_oauth_api():
|
||||
execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=1),
|
||||
)
|
||||
application = Mock()
|
||||
application.deployment = SimpleNamespace(multi_workspace_enabled=False)
|
||||
application.deployment = SimpleNamespace(multi_workspace_enabled=False, mode='oss')
|
||||
application.directory_projection_service = None
|
||||
application.persistence_mgr = None
|
||||
application.user_service.get_authenticated_account = AsyncMock(return_value=account)
|
||||
application.user_service.issue_space_oauth_state = AsyncMock(
|
||||
@@ -125,6 +126,26 @@ async def test_cloud_launch_state_is_server_issued_and_workspace_bound(space_oau
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_login_entry_uses_normal_stateful_oauth(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
application.deployment.mode = 'cloud'
|
||||
|
||||
response = await client.get(
|
||||
'/api/v1/user/space/authorize-url',
|
||||
query_string={
|
||||
'redirect_uri': 'http://localhost/auth/space/callback',
|
||||
'cloud_entry': '1',
|
||||
},
|
||||
headers={'Origin': 'http://localhost'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
authorize_url = (await response.get_json())['data']['authorize_url']
|
||||
assert authorize_url.startswith('https://space.example/authorize?state=')
|
||||
application.user_service.issue_space_oauth_state.assert_awaited_once_with('login')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_public_login_rejects_caller_supplied_state(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
@@ -165,34 +186,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
|
||||
@@ -232,10 +270,14 @@ async def test_server_side_webhook_origin_supports_bundled_ui(space_oauth_api):
|
||||
async def test_login_callback_requires_and_consumes_server_state(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
|
||||
missing = await client.post('/api/v1/user/space/callback', json={'code': 'oauth-code'})
|
||||
missing = await client.post('/api/v1/user/space/callback', json={'code': 'v4_oauth-code'})
|
||||
response = await client.post(
|
||||
'/api/v1/user/space/callback',
|
||||
json={'code': 'oauth-code', 'state': 'opaque-login-state'},
|
||||
json={
|
||||
'code': 'v4_oauth-code',
|
||||
'state': 'opaque-login-state',
|
||||
'redirect_uri': 'https://oss.example/auth/space/callback',
|
||||
},
|
||||
)
|
||||
|
||||
assert (await missing.get_json())['code'] == 1
|
||||
@@ -243,12 +285,146 @@ async def test_login_callback_requires_and_consumes_server_state(space_oauth_api
|
||||
assert (await response.get_json())['data']['token'] == 'space-login-token'
|
||||
application.user_service.consume_space_oauth_state_details.assert_awaited_once_with('opaque-login-state', 'login')
|
||||
application.space_service.exchange_oauth_code.assert_awaited_once_with(
|
||||
'oauth-code',
|
||||
'v4_oauth-code',
|
||||
[WORKSPACE_UUID],
|
||||
{WORKSPACE_UUID: int(WORKSPACE_CREATED_AT.timestamp())},
|
||||
redirect_uri='https://oss.example/auth/space/callback',
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_callback_rejects_downgraded_legacy_code(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
|
||||
response = await client.post(
|
||||
'/api/v1/user/space/callback',
|
||||
json={'code': 'v2_legacy-code', 'state': 'opaque-login-state'},
|
||||
)
|
||||
|
||||
payload = await response.get_json()
|
||||
assert response.status_code == 200
|
||||
assert payload['code'] == 1
|
||||
assert 'code contract' in payload['msg']
|
||||
application.space_service.exchange_oauth_code.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_login_callback_reconciles_authorized_workspace_before_local_authentication(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
application.deployment.mode = 'cloud'
|
||||
calls: list[str] = []
|
||||
application.directory_projection_service = SimpleNamespace(
|
||||
reconcile_workspaces=AsyncMock(side_effect=lambda _workspace_uuids: calls.append('reconcile'))
|
||||
)
|
||||
application.space_service.exchange_oauth_code.return_value = {
|
||||
'access_token': 'space-access-token',
|
||||
'refresh_token': 'space-refresh-token',
|
||||
'expires_in': 3600,
|
||||
'cloud_workspace_uuid': WORKSPACE_UUID,
|
||||
}
|
||||
|
||||
authenticated_account = application.user_service.authenticate_space_user.return_value[1]
|
||||
|
||||
async def authenticate(*_args):
|
||||
calls.append('authenticate')
|
||||
return 'space-login-token', authenticated_account
|
||||
|
||||
application.user_service.authenticate_space_user.side_effect = authenticate
|
||||
|
||||
response = await client.post(
|
||||
'/api/v1/user/space/callback',
|
||||
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert (await response.get_json())['data']['workspace_uuid'] == WORKSPACE_UUID
|
||||
assert calls == ['reconcile', 'authenticate']
|
||||
application.directory_projection_service.reconcile_workspaces.assert_awaited_once_with((WORKSPACE_UUID,))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_login_callback_fails_closed_without_workspace_binding(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
application.deployment.mode = 'cloud'
|
||||
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
|
||||
|
||||
response = await client.post(
|
||||
'/api/v1/user/space/callback',
|
||||
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
|
||||
)
|
||||
|
||||
payload = await response.get_json()
|
||||
assert response.status_code == 200
|
||||
assert payload['code'] == 1
|
||||
assert 'Cloud Workspace binding' in payload['msg']
|
||||
application.directory_projection_service.reconcile_workspaces.assert_not_awaited()
|
||||
application.user_service.authenticate_space_user.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_login_callback_requires_code_binding_for_launch_state(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
application.deployment.mode = 'cloud'
|
||||
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
|
||||
application.user_service.consume_space_oauth_state_details.return_value = SimpleNamespace(
|
||||
launch_workspace_uuid=WORKSPACE_UUID
|
||||
)
|
||||
|
||||
response = await client.post(
|
||||
'/api/v1/user/space/callback',
|
||||
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
|
||||
)
|
||||
|
||||
payload = await response.get_json()
|
||||
assert response.status_code == 200
|
||||
assert payload['code'] == 1
|
||||
assert 'Workspace binding' in payload['msg']
|
||||
application.directory_projection_service.reconcile_workspaces.assert_not_awaited()
|
||||
application.user_service.authenticate_space_user.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_login_callback_rejects_conflicting_state_and_code_workspace_bindings(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
application.deployment.mode = 'cloud'
|
||||
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
|
||||
application.user_service.consume_space_oauth_state_details.return_value = SimpleNamespace(
|
||||
launch_workspace_uuid=WORKSPACE_UUID
|
||||
)
|
||||
application.space_service.exchange_oauth_code.return_value = {
|
||||
'access_token': 'space-access-token',
|
||||
'refresh_token': 'space-refresh-token',
|
||||
'expires_in': 3600,
|
||||
'cloud_workspace_uuid': 'workspace-from-another-flow',
|
||||
}
|
||||
|
||||
response = await client.post(
|
||||
'/api/v1/user/space/callback',
|
||||
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
|
||||
)
|
||||
|
||||
payload = await response.get_json()
|
||||
assert response.status_code == 200
|
||||
assert payload['code'] == 1
|
||||
assert 'Workspace binding' in payload['msg']
|
||||
application.directory_projection_service.reconcile_workspaces.assert_not_awaited()
|
||||
application.user_service.authenticate_space_user.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oss_login_callback_does_not_request_cloud_reconciliation(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
|
||||
|
||||
response = await client.post(
|
||||
'/api/v1/user/space/callback',
|
||||
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
application.directory_projection_service.reconcile_workspaces.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_callback_launch_state_selects_asserted_workspace(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
@@ -259,7 +435,7 @@ async def test_login_callback_launch_state_selects_asserted_workspace(space_oaut
|
||||
|
||||
response = await client.post(
|
||||
'/api/v1/user/space/callback',
|
||||
json={'code': 'oauth-code', 'state': 'opaque-login-state'},
|
||||
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -295,6 +471,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
|
||||
@@ -335,18 +534,22 @@ async def test_bind_callback_uses_opaque_state_and_never_treats_it_as_jwt(space_
|
||||
|
||||
rejected = await client.post(
|
||||
'/api/v1/user/bind-space',
|
||||
json={'code': 'attacker-code', 'state': 'jwt.must-not-be-used'},
|
||||
json={'code': 'v4_attacker-code', 'state': 'jwt.must-not-be-used'},
|
||||
)
|
||||
response = await client.post(
|
||||
'/api/v1/user/bind-space',
|
||||
json={'code': 'oauth-code', 'state': 'opaque-bind-state'},
|
||||
json={'code': 'v4_oauth-code', 'state': 'opaque-bind-state'},
|
||||
)
|
||||
|
||||
assert rejected.status_code == 401
|
||||
assert response.status_code == 200
|
||||
assert (await response.get_json())['data']['token'] == 'rotated-account-token'
|
||||
application.user_service.verify_jwt_token.assert_not_awaited()
|
||||
application.user_service.bind_space_account.assert_awaited_once_with('owner@example.com', 'oauth-code')
|
||||
application.user_service.bind_space_account.assert_awaited_once_with(
|
||||
'owner@example.com',
|
||||
'v4_oauth-code',
|
||||
redirect_uri='http://localhost/auth/space/callback?mode=bind',
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -354,6 +557,7 @@ async def test_direct_launch_assertion_does_not_consume_normal_oauth_state(space
|
||||
application, client = space_oauth_api
|
||||
application.user_service.consume_space_oauth_state.reset_mock()
|
||||
application.space_service.exchange_oauth_code.reset_mock()
|
||||
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
|
||||
|
||||
response = await client.post(
|
||||
'/api/v1/user/space/callback',
|
||||
@@ -374,3 +578,29 @@ async def test_direct_launch_assertion_does_not_consume_normal_oauth_state(space
|
||||
)
|
||||
application.user_service.consume_space_oauth_state.assert_not_awaited()
|
||||
application.space_service.exchange_oauth_code.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direct_launch_reconciles_exact_workspace_before_resolving_access(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
projected_account = SimpleNamespace(
|
||||
uuid='account-a',
|
||||
user='owner@example.com',
|
||||
account_type='space',
|
||||
status='active',
|
||||
)
|
||||
application.user_service.get_user_by_uuid = AsyncMock(return_value=projected_account)
|
||||
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
|
||||
|
||||
response = await client.post(
|
||||
'/api/v1/user/space/callback',
|
||||
json={
|
||||
'workspace_uuid': WORKSPACE_UUID,
|
||||
'launch_assertion': 'signed-launch-token',
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert (await response.get_json())['data']['workspace_uuid'] == WORKSPACE_UUID
|
||||
application.directory_projection_service.reconcile_workspaces.assert_awaited_once_with((WORKSPACE_UUID,))
|
||||
application.user_service.get_user_by_uuid.assert_awaited_once_with('account-a')
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -115,6 +115,7 @@ class _CapacityPluginRuntimeHandler:
|
||||
def __init__(self) -> None:
|
||||
self.bindings: dict[str, typing.Any] = {}
|
||||
self.reconciled: tuple[typing.Any, ...] = ()
|
||||
self.reconcile_timeout: float | None = None
|
||||
|
||||
def register_installation_binding(
|
||||
self,
|
||||
@@ -132,8 +133,14 @@ class _CapacityPluginRuntimeHandler:
|
||||
def unregister_installation_binding(self, binding) -> None:
|
||||
self.bindings.pop(binding.installation_uuid, None)
|
||||
|
||||
async def reconcile_plugin_installations(self, desired_states) -> dict:
|
||||
async def reconcile_plugin_installations(
|
||||
self,
|
||||
desired_states,
|
||||
*,
|
||||
timeout: float | None = None,
|
||||
) -> dict:
|
||||
self.reconciled = tuple(desired_states)
|
||||
self.reconcile_timeout = timeout
|
||||
return {
|
||||
'applied': [],
|
||||
'removed': [],
|
||||
@@ -1034,6 +1041,7 @@ class TestPostgreSQLTenantRuntime:
|
||||
assert not mcp_loader._hosted_mcp_tasks
|
||||
assert len(plugin_handler.reconciled) == workspace_count
|
||||
assert len(plugin_handler.bindings) == workspace_count
|
||||
assert plugin_handler.reconcile_timeout == 300.0
|
||||
assert all(count == workspace_count for count in statement_counts.values()), statement_counts
|
||||
if max_elapsed is not None:
|
||||
assert elapsed <= max_elapsed
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -38,6 +39,35 @@ def _assert_verified_backup(payload: dict) -> None:
|
||||
assert connection.execute('SELECT version_num FROM alembic_version').fetchone()[0] == payload['source_revision']
|
||||
|
||||
|
||||
def _temporary_sqlite_files(root: pathlib.Path) -> list[pathlib.Path]:
|
||||
return [*root.rglob('*.creating'), *root.rglob('*.restoring')]
|
||||
|
||||
|
||||
async def test_backup_removes_stale_temporary_file_from_interrupted_run(tmp_path):
|
||||
database_path = tmp_path / 'legacy-stale-backup.db'
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{database_path}')
|
||||
try:
|
||||
await create_legacy_resource_schema(engine, instance_uuid='stale-backup')
|
||||
await alembic_runner.run_alembic_stamp(engine, '0008_mcp_resource_prefs')
|
||||
backup_directory = tmp_path / 'migration-backups'
|
||||
backup_directory.mkdir()
|
||||
stale_path = backup_directory / '.legacy-stale-backup-pre-0009-old.creating'
|
||||
unrelated_path = backup_directory / '.another-database-pre-0009-old.creating'
|
||||
stale_path.write_bytes(b'interrupted backup')
|
||||
unrelated_path.write_bytes(b'unrelated backup')
|
||||
|
||||
await sqlite_migration_backup.create_verified_backup(
|
||||
engine,
|
||||
source_revision='0008_mcp_resource_prefs',
|
||||
target_revision='0009_workspace_tenancy',
|
||||
)
|
||||
|
||||
assert not stale_path.exists()
|
||||
assert unrelated_path.read_bytes() == b'unrelated backup'
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_tenancy_migrations_retain_verified_boundary_backups(tmp_path):
|
||||
database_path = tmp_path / 'legacy-with-backups.db'
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{database_path}')
|
||||
@@ -58,6 +88,7 @@ async def test_tenancy_migrations_retain_verified_boundary_backups(tmp_path):
|
||||
}
|
||||
for payload in payloads:
|
||||
_assert_verified_backup(payload)
|
||||
assert _temporary_sqlite_files(tmp_path) == []
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
@@ -99,9 +130,73 @@ async def test_failed_tenancy_migration_restores_backup_and_revision(
|
||||
assert restored[0]['status'] == 'restored_after_failure'
|
||||
assert restored[0]['source_revision'] == '0009_workspace_tenancy'
|
||||
_assert_verified_backup(restored[0])
|
||||
assert _temporary_sqlite_files(tmp_path) == []
|
||||
|
||||
monkeypatch.setattr(alembic_runner, 'run_alembic_upgrade', real_upgrade)
|
||||
await _manager(engine)._run_alembic_migrations()
|
||||
assert await alembic_runner.get_alembic_current(engine) == alembic_runner.get_alembic_head()
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_restore_publish_failure_preserves_current_database(tmp_path, monkeypatch):
|
||||
database_path = tmp_path / 'restore-publish-failure.db'
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{database_path}')
|
||||
try:
|
||||
await create_legacy_resource_schema(engine, instance_uuid='restore-publish-failure')
|
||||
await alembic_runner.run_alembic_stamp(engine, '0008_mcp_resource_prefs')
|
||||
backup = await sqlite_migration_backup.create_verified_backup(
|
||||
engine,
|
||||
source_revision='0008_mcp_resource_prefs',
|
||||
target_revision='0009_workspace_tenancy',
|
||||
)
|
||||
stale_restore_path = tmp_path / f'.{database_path.name}.interrupted.restoring'
|
||||
stale_restore_path.write_bytes(b'interrupted restore')
|
||||
async with engine.begin() as connection:
|
||||
await connection.execute(sa.text("UPDATE alembic_version SET version_num = 'failed-revision'"))
|
||||
await engine.dispose()
|
||||
database_before_restore = database_path.read_bytes()
|
||||
real_replace = os.replace
|
||||
|
||||
def fail_restore_publish(source, destination):
|
||||
if pathlib.Path(destination) == database_path:
|
||||
raise OSError('simulated atomic publish failure')
|
||||
return real_replace(source, destination)
|
||||
|
||||
monkeypatch.setattr(sqlite_migration_backup.os, 'replace', fail_restore_publish)
|
||||
|
||||
with pytest.raises(OSError, match='atomic publish failure'):
|
||||
await sqlite_migration_backup.restore_verified_backup(engine, backup)
|
||||
|
||||
assert database_path.read_bytes() == database_before_restore
|
||||
assert _temporary_sqlite_files(tmp_path) == []
|
||||
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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -9,8 +9,10 @@ Source: src/langbot/pkg/api/http/service/bot.py
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
from types import SimpleNamespace
|
||||
import json
|
||||
import sqlalchemy
|
||||
import uuid
|
||||
|
||||
from langbot.pkg.api.http.service.bot import BotService
|
||||
@@ -241,6 +243,29 @@ class TestBotServiceGetRuntimeBotInfo:
|
||||
assert result['adapter_runtime_values']['webhook_url'] == '/bots/wecom-uuid'
|
||||
assert result['adapter_runtime_values']['webhook_full_url'] == 'http://127.0.0.1:5300/bots/wecom-uuid'
|
||||
|
||||
async def test_get_runtime_bot_info_returns_webhook_for_http_bot(self):
|
||||
ap = SimpleNamespace(
|
||||
instance_config=SimpleNamespace(
|
||||
data={'api': {'webhook_prefix': 'https://bot.example.com'}}
|
||||
),
|
||||
platform_mgr=SimpleNamespace(get_bot_by_uuid=AsyncMock(return_value=None)),
|
||||
)
|
||||
service = BotService(ap)
|
||||
service.get_bot = AsyncMock(
|
||||
return_value={
|
||||
'uuid': 'http-bot-uuid',
|
||||
'name': 'HTTP Bot',
|
||||
'adapter': 'http_bot',
|
||||
'adapter_config': {},
|
||||
}
|
||||
)
|
||||
|
||||
result = await service.get_runtime_bot_info(WORKSPACE_UUID, 'http-bot-uuid')
|
||||
|
||||
assert result['adapter_runtime_values']['webhook_full_url'] == (
|
||||
'https://bot.example.com/bots/http-bot-uuid'
|
||||
)
|
||||
|
||||
async def test_get_runtime_bot_info_no_webhook_for_telegram(self):
|
||||
"""Returns no webhook URL for non-webhook adapters like telegram."""
|
||||
# Setup
|
||||
@@ -425,10 +450,58 @@ class TestBotServiceCreateBot:
|
||||
insert_statement = ap.persistence_mgr.execute_async.await_args_list[1].args[0]
|
||||
insert_values = insert_statement.compile().params
|
||||
assert insert_values['workspace_uuid'] == WORKSPACE_UUID
|
||||
assert insert_values['use_pipeline_uuid'] == 'default-pipeline-uuid'
|
||||
assert insert_values['use_pipeline_name'] == 'Default Pipeline'
|
||||
assert bot_uuid is not None # Verify UUID was returned
|
||||
|
||||
async def test_create_bot_rolls_back_insert_when_load_bot_fails(self):
|
||||
"""Deletes the inserted row when the adapter fails to load.
|
||||
|
||||
Regression: a failing adapter constructor (e.g. KeyError on a missing
|
||||
optional credential key) used to leave a permanently disabled orphan
|
||||
bot in the DB — the insert was already committed and the HTTP layer
|
||||
surfaced a 500 without any cleanup.
|
||||
"""
|
||||
# Setup
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
ap.instance_config = SimpleNamespace()
|
||||
ap.instance_config.data = {'system': {'limitation': {'max_bots': -1}}}
|
||||
ap.platform_mgr = SimpleNamespace()
|
||||
ap.platform_mgr.load_bot = AsyncMock(side_effect=KeyError('token'))
|
||||
|
||||
pipeline_result = Mock()
|
||||
pipeline_result.first = Mock(return_value=None)
|
||||
bot_result = Mock()
|
||||
bot_result.first = Mock(return_value=_create_mock_bot())
|
||||
|
||||
executed_statements = []
|
||||
|
||||
async def mock_execute(query):
|
||||
executed_statements.append(query)
|
||||
if len(executed_statements) <= 2:
|
||||
return pipeline_result # 1: limitation bots query, 2: pipeline query
|
||||
if len(executed_statements) == 3:
|
||||
return Mock() # insert
|
||||
return bot_result # get_bot after insert
|
||||
|
||||
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
|
||||
ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'new-uuid', 'name': 'New Bot'})
|
||||
|
||||
service = BotService(ap)
|
||||
|
||||
# Execute & Verify: the adapter error propagates
|
||||
with pytest.raises(KeyError, match='token'):
|
||||
await service.create_bot(
|
||||
WORKSPACE_UUID, {'name': 'New Bot', 'adapter': 'telegram', 'adapter_config': {}}
|
||||
)
|
||||
|
||||
# And the inserted row is rolled back via a DELETE on the new uuid
|
||||
# (no limitation query runs because max_bots=-1)
|
||||
assert len(executed_statements) == 4 # pipeline select, insert, bot select, delete
|
||||
delete_statement = executed_statements[-1]
|
||||
assert isinstance(delete_statement, sqlalchemy.sql.dml.Delete)
|
||||
compiled = delete_statement.compile()
|
||||
assert compiled.params['uuid_1'] is not None
|
||||
|
||||
|
||||
class TestBotServiceUpdateBot:
|
||||
"""Tests for update_bot method."""
|
||||
@@ -605,6 +678,77 @@ class TestBotServiceListEventLogs:
|
||||
assert total == 5
|
||||
|
||||
|
||||
class TestBotServiceHttpBotInboundTest:
|
||||
async def test_sends_signed_message_through_public_ingress(self):
|
||||
ap = SimpleNamespace(
|
||||
instance_config=SimpleNamespace(data={'api': {'port': 5300}}),
|
||||
)
|
||||
service = BotService(ap)
|
||||
service.get_bot = AsyncMock(
|
||||
return_value={
|
||||
'uuid': 'http-bot-uuid',
|
||||
'adapter': 'http_bot',
|
||||
'adapter_config': {
|
||||
'signature_required': True,
|
||||
'inbound_secret': 'test-secret',
|
||||
},
|
||||
'enable': True,
|
||||
}
|
||||
)
|
||||
response = MagicMock(status=202)
|
||||
session = MagicMock()
|
||||
session.post.return_value.__aenter__ = AsyncMock(return_value=response)
|
||||
session.post.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch('langbot.pkg.api.http.service.bot.httpclient.get_session', return_value=session),
|
||||
patch(
|
||||
'langbot.pkg.api.http.service.bot.httpclient.read_json_limited',
|
||||
new=AsyncMock(
|
||||
return_value={
|
||||
'code': 0,
|
||||
'data': {
|
||||
'session_id': 'wizard-session',
|
||||
'accepted_message_id': 'in-message',
|
||||
},
|
||||
}
|
||||
),
|
||||
),
|
||||
):
|
||||
result = await service.send_http_bot_test_message(
|
||||
WORKSPACE_UUID,
|
||||
'http-bot-uuid',
|
||||
'hello',
|
||||
)
|
||||
|
||||
assert result['accepted_message_id'] == 'in-message'
|
||||
request = session.post.call_args
|
||||
assert request.args[0] == 'http://127.0.0.1:5300/bots/http-bot-uuid'
|
||||
payload = json.loads(request.kwargs['data'])
|
||||
assert payload['message'] == [{'type': 'Plain', 'text': 'hello'}]
|
||||
headers = request.kwargs['headers']
|
||||
assert headers['X-LB-Timestamp']
|
||||
assert headers['X-LB-Signature'].startswith('sha256=')
|
||||
|
||||
async def test_rejects_non_http_bot(self):
|
||||
service = BotService(SimpleNamespace())
|
||||
service.get_bot = AsyncMock(
|
||||
return_value={
|
||||
'uuid': 'telegram-bot',
|
||||
'adapter': 'telegram',
|
||||
'adapter_config': {},
|
||||
'enable': True,
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match='only available for HTTP Bot'):
|
||||
await service.send_http_bot_test_message(
|
||||
WORKSPACE_UUID,
|
||||
'telegram-bot',
|
||||
'hello',
|
||||
)
|
||||
|
||||
|
||||
class TestBotServiceSendMessage:
|
||||
"""Tests for send_message method."""
|
||||
|
||||
|
||||
@@ -138,6 +138,39 @@ async def test_same_session_and_resource_ids_do_not_collide(service):
|
||||
assert (await service.get_message_details(context_a, message_b))['found'] is False
|
||||
|
||||
|
||||
async def test_session_search_matches_user_id_or_name_within_workspace(service):
|
||||
context_a = _context(WORKSPACE_A)
|
||||
context_b = _context(WORKSPACE_B)
|
||||
fixtures = [
|
||||
(context_a, 'session-id-match', 'customer-42', 'Alice'),
|
||||
(context_a, 'session-name-match', 'customer-99', 'Bob Alice Cooper'),
|
||||
(context_a, 'session-no-match', 'customer-7', 'Bob'),
|
||||
(context_b, 'session-other-workspace', 'customer-42', 'Alice'),
|
||||
]
|
||||
for context, session_id, user_id, user_name in fixtures:
|
||||
await service.record_session_start(
|
||||
context,
|
||||
session_id=session_id,
|
||||
bot_id='same-bot',
|
||||
bot_name='Same Bot',
|
||||
pipeline_id='same-pipeline',
|
||||
pipeline_name='Same Pipeline',
|
||||
user_id=user_id,
|
||||
user_name=user_name,
|
||||
)
|
||||
|
||||
by_id, id_total = await service.get_sessions(context_a, user_query='customer-42')
|
||||
by_name, name_total = await service.get_sessions(context_a, user_query='alice')
|
||||
|
||||
assert id_total == 1
|
||||
assert [session['session_id'] for session in by_id] == ['session-id-match']
|
||||
assert name_total == 2
|
||||
assert {session['session_id'] for session in by_name} == {
|
||||
'session-id-match',
|
||||
'session-name-match',
|
||||
}
|
||||
|
||||
|
||||
async def test_tool_call_inherits_context_from_connection_message_row(service):
|
||||
context = _context(WORKSPACE_A)
|
||||
message_id = await _record_message(service, context, 'tool context')
|
||||
|
||||
@@ -95,7 +95,9 @@ class TestSpaceServiceGetOAuthAuthorizeUrl:
|
||||
result = service.get_oauth_authorize_url('http://localhost/callback')
|
||||
|
||||
# Verify
|
||||
assert parse_qs(urlsplit(result).query)['redirect_uri'] == ['http://localhost/callback']
|
||||
query = parse_qs(urlsplit(result).query)
|
||||
assert query['redirect_uri'] == ['http://localhost/callback']
|
||||
assert query['code_contract'] == ['redirect-v1']
|
||||
assert 'https://space.langbot.app/auth/authorize' in result
|
||||
|
||||
def test_get_oauth_authorize_url_with_state(self):
|
||||
@@ -578,12 +580,14 @@ class TestSpaceServiceExchangeOAuthCode:
|
||||
'auth_code',
|
||||
['workspace-1'],
|
||||
{'workspace-1': 1_700_000_000},
|
||||
redirect_uri='https://oss.example/auth/space/callback',
|
||||
)
|
||||
|
||||
# Verify
|
||||
assert result['access_token'] == 'new_access_token'
|
||||
assert mock_session_obj.post.call_args.kwargs['json'] == {
|
||||
'code': 'auth_code',
|
||||
'redirect_uri': 'https://oss.example/auth/space/callback',
|
||||
'instance_id': constants.instance_id,
|
||||
'workspace_uuids': ['workspace-1'],
|
||||
'workspace_created_ats': {'workspace-1': 1_700_000_000},
|
||||
@@ -820,6 +824,97 @@ class TestSpaceServiceGetModels:
|
||||
await service.get_models()
|
||||
|
||||
|
||||
class TestSpaceServiceGetModelSelection:
|
||||
"""Tests for availability-ranked model selection."""
|
||||
|
||||
@pytest.mark.parametrize('response_shape', ['direct', 'models-envelope', 'availability-wrapper'])
|
||||
async def test_preserves_selection_order_and_category_query(self, response_shape):
|
||||
ap = SimpleNamespace(instance_config=SimpleNamespace(data={}))
|
||||
service = SpaceService(ap)
|
||||
models = [
|
||||
{
|
||||
'uuid': 'best-model',
|
||||
'model_id': 'best-chat-model',
|
||||
'provider': 'provider-1',
|
||||
'category': 'chat',
|
||||
'status': 'active',
|
||||
},
|
||||
{
|
||||
'uuid': 'fallback-model',
|
||||
'model_id': 'fallback-chat-model',
|
||||
'provider': 'provider-2',
|
||||
'category': 'chat',
|
||||
'status': 'active',
|
||||
},
|
||||
]
|
||||
if response_shape == 'models-envelope':
|
||||
data = {'models': models}
|
||||
elif response_shape == 'availability-wrapper':
|
||||
data = [{'model': model, 'latency_ms': index + 10, 'http_code': 200} for index, model in enumerate(models)]
|
||||
else:
|
||||
data = models
|
||||
payload = {'code': 0, 'data': data}
|
||||
mock_response = MagicMock(status=200)
|
||||
|
||||
with (
|
||||
patch('langbot.pkg.api.http.service.space.httpclient.get_session') as get_session,
|
||||
patch(
|
||||
'langbot.pkg.api.http.service.space.httpclient.read_json_limited',
|
||||
new=AsyncMock(return_value=payload),
|
||||
),
|
||||
):
|
||||
session = MagicMock()
|
||||
session.get.return_value.__aenter__ = AsyncMock(return_value=mock_response)
|
||||
session.get.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||
get_session.return_value = session
|
||||
|
||||
result = await service.get_model_selection('chat')
|
||||
|
||||
assert [model.uuid for model in result] == ['best-model', 'fallback-model']
|
||||
session.get.assert_called_once_with(
|
||||
'https://space.langbot.app/api/v1/models/selection',
|
||||
params={'category': 'chat'},
|
||||
)
|
||||
|
||||
async def test_recommended_model_uses_first_selection_and_refreshes_once(self):
|
||||
local_model = SimpleNamespace(uuid='local-model-uuid', name='best-chat-model')
|
||||
persistence = SimpleNamespace(
|
||||
execute_async=AsyncMock(
|
||||
side_effect=[
|
||||
_create_mock_result(first_item=None),
|
||||
_create_mock_result(first_item=local_model),
|
||||
]
|
||||
)
|
||||
)
|
||||
model_mgr = SimpleNamespace(sync_new_models_from_space=AsyncMock())
|
||||
ap = SimpleNamespace(
|
||||
instance_config=SimpleNamespace(data={}),
|
||||
persistence_mgr=persistence,
|
||||
model_mgr=model_mgr,
|
||||
)
|
||||
service = SpaceService(ap)
|
||||
service.get_model_selection = AsyncMock(
|
||||
return_value=[
|
||||
SimpleNamespace(uuid='best-upstream-uuid', model_id='best-chat-model'),
|
||||
SimpleNamespace(uuid='fallback-upstream-uuid', model_id='fallback-chat-model'),
|
||||
]
|
||||
)
|
||||
context = SimpleNamespace(
|
||||
instance_uuid='instance',
|
||||
workspace_uuid='workspace',
|
||||
placement_generation=1,
|
||||
principal=SimpleNamespace(),
|
||||
entitlement_revision=0,
|
||||
)
|
||||
|
||||
result = await service.get_recommended_chat_model(context)
|
||||
|
||||
assert result == {'uuid': 'local-model-uuid', 'name': 'best-chat-model'}
|
||||
service.get_model_selection.assert_awaited_once_with('chat')
|
||||
model_mgr.sync_new_models_from_space.assert_awaited_once()
|
||||
assert persistence.execute_async.await_count == 2
|
||||
|
||||
|
||||
class TestSpaceServiceCreditsCache:
|
||||
"""Tests for credits cache behavior."""
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
import sqlalchemy
|
||||
@@ -214,6 +215,88 @@ async def test_directory_delta_requests_model_catalog_sync_after_commit(projecti
|
||||
request_sync.assert_called_once_with()
|
||||
|
||||
|
||||
async def test_targeted_reconciliation_projects_new_workspace_without_advancing_event_cursor(projection_context):
|
||||
application, session_factory = projection_context
|
||||
provider = _Provider(
|
||||
[_snapshot(7, workspaces=[])],
|
||||
deltas=[_delta(workspaces=[_workspace(revision=8, name='JIT Workspace')])],
|
||||
)
|
||||
service = DirectoryProjectionService(application, provider, INSTANCE_UUID)
|
||||
await service.initialize()
|
||||
|
||||
await service.reconcile_workspaces((WORKSPACE_UUID,))
|
||||
|
||||
async with session_factory() as session:
|
||||
account = await session.scalar(sqlalchemy.select(User).where(User.uuid == ACCOUNT_UUID))
|
||||
workspace = await session.get(Workspace, WORKSPACE_UUID)
|
||||
membership = await session.scalar(
|
||||
sqlalchemy.select(WorkspaceMembership).where(
|
||||
WorkspaceMembership.workspace_uuid == WORKSPACE_UUID,
|
||||
WorkspaceMembership.account_uuid == ACCOUNT_UUID,
|
||||
)
|
||||
)
|
||||
state = await session.get(DirectoryProjectionState, INSTANCE_UUID)
|
||||
assert account is not None
|
||||
assert workspace is not None and workspace.name == 'JIT Workspace'
|
||||
assert membership is not None and membership.status == 'active'
|
||||
assert state is not None and state.cursor == 7
|
||||
assert provider.delta_calls == 1
|
||||
assert provider.after_cursors == []
|
||||
|
||||
|
||||
async def test_targeted_reconciliation_preserves_existing_account_until_ordered_event_projection(projection_context):
|
||||
application, session_factory = projection_context
|
||||
targeted_workspace = _workspace(revision=8, name='Renamed Workspace').model_copy(
|
||||
update={
|
||||
'members': [
|
||||
_member(revision=8).model_copy(update={'display_name': 'Changed Account Name'})
|
||||
]
|
||||
}
|
||||
)
|
||||
provider = _Provider(
|
||||
[_snapshot(7)],
|
||||
deltas=[_delta(workspaces=[targeted_workspace])],
|
||||
)
|
||||
service = DirectoryProjectionService(application, provider, INSTANCE_UUID)
|
||||
await service.initialize()
|
||||
|
||||
await service.reconcile_workspaces((WORKSPACE_UUID,))
|
||||
|
||||
async with session_factory() as session:
|
||||
account = await session.scalar(sqlalchemy.select(User).where(User.uuid == ACCOUNT_UUID))
|
||||
workspace = await session.get(Workspace, WORKSPACE_UUID)
|
||||
state = await session.get(DirectoryProjectionState, INSTANCE_UUID)
|
||||
assert account is not None and account.user == 'Workspace Owner'
|
||||
assert account.projection_revision == 7
|
||||
assert workspace is not None and workspace.name == 'Renamed Workspace'
|
||||
assert state is not None and state.cursor == 7
|
||||
|
||||
|
||||
async def test_targeted_reconciliation_only_updates_requested_workspace_side_effects(projection_context):
|
||||
application, _session_factory = projection_context
|
||||
provider = _Provider(
|
||||
[_snapshot(7, workspaces=[])],
|
||||
deltas=[_delta(workspaces=[_workspace(revision=8, name='JIT Workspace')])],
|
||||
)
|
||||
service = DirectoryProjectionService(application, provider, INSTANCE_UUID)
|
||||
await service.initialize()
|
||||
service._reconcile_entitlement_snapshot_set = AsyncMock()
|
||||
service._update_entitlement_workspace_activity = AsyncMock()
|
||||
service._publish_runtime_execution_projection = Mock()
|
||||
|
||||
await service.reconcile_workspaces((WORKSPACE_UUID,))
|
||||
|
||||
service._reconcile_entitlement_snapshot_set.assert_not_awaited()
|
||||
service._update_entitlement_workspace_activity.assert_awaited_once()
|
||||
assert service._update_entitlement_workspace_activity.await_args.kwargs == {
|
||||
'requested_workspace_uuids': {WORKSPACE_UUID},
|
||||
}
|
||||
service._publish_runtime_execution_projection.assert_called_once()
|
||||
assert service._publish_runtime_execution_projection.call_args.kwargs == {
|
||||
'affected_workspace_uuids': {WORKSPACE_UUID},
|
||||
}
|
||||
|
||||
|
||||
async def test_initial_snapshot_projects_core_owned_rows(projection_context):
|
||||
application, session_factory = projection_context
|
||||
reconcile_execution_projection = Mock()
|
||||
@@ -735,6 +818,72 @@ async def test_each_replica_consumes_events_with_its_own_cursor(projection_conte
|
||||
assert second_provider.after_cursors == [1, 2]
|
||||
|
||||
|
||||
async def test_concurrent_sync_once_calls_are_serialized_per_service(projection_context):
|
||||
application, _session_factory = projection_context
|
||||
|
||||
class _ConcurrentProvider(_Provider):
|
||||
def __init__(self) -> None:
|
||||
super().__init__([_snapshot(1)])
|
||||
self.first_fetch_started = asyncio.Event()
|
||||
self.release_first_fetch = asyncio.Event()
|
||||
self.active_fetches = 0
|
||||
self.max_active_fetches = 0
|
||||
|
||||
async def fetch_events(
|
||||
self,
|
||||
instance_uuid: str,
|
||||
after_cursor: int,
|
||||
limit: int,
|
||||
) -> DirectoryEventBatch:
|
||||
assert instance_uuid == INSTANCE_UUID
|
||||
assert limit == 100
|
||||
self.after_cursors.append(after_cursor)
|
||||
self.active_fetches += 1
|
||||
self.max_active_fetches = max(self.max_active_fetches, self.active_fetches)
|
||||
try:
|
||||
if len(self.after_cursors) == 1:
|
||||
self.first_fetch_started.set()
|
||||
await self.release_first_fetch.wait()
|
||||
cursor = after_cursor + 1
|
||||
return DirectoryEventBatch(
|
||||
instance_uuid=instance_uuid,
|
||||
after_cursor=after_cursor,
|
||||
cursor=cursor,
|
||||
high_water_cursor=cursor,
|
||||
events=(
|
||||
DirectoryEvent(
|
||||
cursor=cursor,
|
||||
uuid=f'40000000-0000-4000-8000-{cursor:012d}',
|
||||
aggregate_uuid=WORKSPACE_UUID,
|
||||
event_type='entitlement.changed',
|
||||
revision=cursor,
|
||||
payload={
|
||||
'workspace_uuid': WORKSPACE_UUID,
|
||||
'entitlement_revision': cursor,
|
||||
},
|
||||
created_at=datetime.datetime(2026, 7, 24, 12, cursor, tzinfo=datetime.UTC),
|
||||
),
|
||||
),
|
||||
)
|
||||
finally:
|
||||
self.active_fetches -= 1
|
||||
|
||||
provider = _ConcurrentProvider()
|
||||
service = DirectoryProjectionService(application, provider, INSTANCE_UUID)
|
||||
await service.initialize()
|
||||
|
||||
first = asyncio.create_task(service.sync_once())
|
||||
await provider.first_fetch_started.wait()
|
||||
second = asyncio.create_task(service.sync_once())
|
||||
await asyncio.sleep(0)
|
||||
provider.release_first_fetch.set()
|
||||
await asyncio.gather(first, second)
|
||||
|
||||
assert provider.max_active_fetches == 1
|
||||
assert provider.after_cursors == [1, 2]
|
||||
assert service._consumer_cursor == 3
|
||||
|
||||
|
||||
async def test_snapshot_coverage_allows_lagging_replica_to_replay_receipts(projection_context):
|
||||
application, session_factory = projection_context
|
||||
event_two = DirectoryEvent(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -964,6 +964,7 @@ async def test_scoped_session_rejects_raw_or_unapproved_sql(
|
||||
sa.func.date_trunc('hour', sa.column('timestamp')),
|
||||
sa.func.length(sa.literal('value')),
|
||||
sa.func.nullif(sa.literal('value'), sa.literal('')),
|
||||
sa.func.strftime('%Y-%m-%d %H:00', sa.column('timestamp')),
|
||||
),
|
||||
sa.select(sa.column('embedding').op('<=>')(sa.literal([0.1]))),
|
||||
sa.select(sa.cast(sa.column('embedding'), Vector(384))),
|
||||
@@ -977,6 +978,19 @@ async def test_scoped_sql_structure_allows_only_the_production_vocabulary(statem
|
||||
_validate_scoped_statement_call((statement,), {})
|
||||
|
||||
|
||||
async def test_scoped_session_executes_sqlite_strftime() -> None:
|
||||
engine = create_async_engine('sqlite+aiosqlite:///:memory:')
|
||||
try:
|
||||
async with TenantUnitOfWork(engine, 'workspace-a') as uow:
|
||||
result = await uow.session.execute(
|
||||
sa.select(sa.func.strftime('%Y-%m-%d %H:00', sa.literal('2026-08-28 03:45:00')))
|
||||
)
|
||||
|
||||
assert result.scalar_one() == '2026-08-28 03:00'
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_scoped_sql_rejects_public_execution_options() -> None:
|
||||
statement = sa.select(sa.literal(1))
|
||||
with pytest.raises(ScopedSessionTransactionError, match='execution options'):
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""BanWordFilter regression tests for legacy sensitive-word lists.
|
||||
|
||||
v4.10.7 introduced a 64-pattern cap in safe_regex. Older installs still carry
|
||||
the previous default list (~70 patterns). The filter must keep applying those
|
||||
rules instead of blocking every message.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.factories import FakeApp
|
||||
|
||||
|
||||
def _load_banwords():
|
||||
import_module('langbot.pkg.pipeline.pipelinemgr')
|
||||
banwords = import_module('langbot.pkg.pipeline.cntfilter.filters.banwords')
|
||||
entities = import_module('langbot.pkg.pipeline.cntfilter.entities')
|
||||
safe_regex = import_module('langbot.pkg.utils.safe_regex')
|
||||
return banwords, entities, safe_regex
|
||||
|
||||
|
||||
def _filter_with_words(words: list[str], *, mask: str = '*', mask_word: str = ''):
|
||||
banwords, entities, _ = _load_banwords()
|
||||
app = FakeApp()
|
||||
app.sensitive_meta = Mock()
|
||||
app.sensitive_meta.data = {
|
||||
'words': words,
|
||||
'mask': mask,
|
||||
'mask_word': mask_word,
|
||||
}
|
||||
return banwords.BanWordFilter(app), entities, app
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_word_list_over_pattern_cap_does_not_block_clean_message():
|
||||
"""A pre-v4.10.7 word list must not fail closed on every message."""
|
||||
_, _, safe_regex = _load_banwords()
|
||||
words = [f'word{i}' for i in range(safe_regex.MAX_PATTERN_COUNT + 6)]
|
||||
filt, entities, _ = _filter_with_words(words)
|
||||
|
||||
result = await filt.process(Mock(), 'hello there, nothing banned')
|
||||
|
||||
assert result.level == entities.ResultLevel.PASS
|
||||
assert result.replacement == 'hello there, nothing banned'
|
||||
assert result.user_notice == ''
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_word_list_still_masks_match_beyond_first_batch():
|
||||
"""Words past the first 64-pattern batch must still be applied."""
|
||||
_, _, safe_regex = _load_banwords()
|
||||
words = [f'word{i}' for i in range(safe_regex.MAX_PATTERN_COUNT)] + ['secret-token']
|
||||
filt, entities, _ = _filter_with_words(words, mask_word='[hidden]')
|
||||
|
||||
result = await filt.process(Mock(), 'please hide secret-token now')
|
||||
|
||||
assert result.level == entities.ResultLevel.MASKED
|
||||
assert 'secret-token' not in result.replacement
|
||||
assert '[hidden]' in result.replacement
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_word_list_masks_match_in_first_batch():
|
||||
_, _, safe_regex = _load_banwords()
|
||||
words = ['alpha-secret'] + [f'word{i}' for i in range(safe_regex.MAX_PATTERN_COUNT)]
|
||||
filt, entities, _ = _filter_with_words(words, mask_word='[hidden]')
|
||||
|
||||
result = await filt.process(Mock(), 'alpha-secret is here')
|
||||
|
||||
assert result.level == entities.ResultLevel.MASKED
|
||||
assert result.replacement == '[hidden] is here'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_sensitive_word_regex_still_blocks():
|
||||
filt, entities, _ = _filter_with_words(['(unclosed'])
|
||||
|
||||
result = await filt.process(Mock(), 'any message')
|
||||
|
||||
assert result.level == entities.ResultLevel.BLOCK
|
||||
assert result.user_notice == '内容检查规则执行失败,请联系管理员'
|
||||
assert 'rejected' in result.console_notice.lower() or 'invalid' in result.console_notice.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oversized_word_list_is_blocked():
|
||||
"""Configured rules must never be silently skipped when the list is oversized."""
|
||||
banwords, _, _ = _load_banwords()
|
||||
words = [f'word{i}' for i in range(banwords._MAX_SENSITIVE_WORD_PATTERNS + 10)]
|
||||
filt, entities, _ = _filter_with_words(words)
|
||||
|
||||
result = await filt.process(Mock(), 'hello there, nothing banned')
|
||||
|
||||
assert result.level == entities.ResultLevel.BLOCK
|
||||
assert result.replacement == ''
|
||||
assert result.user_notice == '内容检查规则执行失败,请联系管理员'
|
||||
assert 'at most 256 regex patterns are allowed' in result.console_notice.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_match_beyond_total_cap_cannot_bypass_filter():
|
||||
banwords, _, _ = _load_banwords()
|
||||
words = [f'word{i}' for i in range(banwords._MAX_SENSITIVE_WORD_PATTERNS)] + ['late-secret']
|
||||
filt, entities, _ = _filter_with_words(words, mask_word='[hidden]')
|
||||
|
||||
result = await filt.process(Mock(), 'please hide late-secret now')
|
||||
|
||||
assert result.level == entities.ResultLevel.BLOCK
|
||||
assert result.replacement == ''
|
||||
@@ -1,8 +1,11 @@
|
||||
"""Tests for DingTalk API payload helpers."""
|
||||
|
||||
import json
|
||||
from contextlib import asynccontextmanager
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from langbot.libs.dingtalk_api.api import _stringify_card_param_map
|
||||
from langbot.libs.dingtalk_api.api import DingTalkClient, _stringify_card_param_map
|
||||
from langbot.pkg.utils import httpclient
|
||||
|
||||
|
||||
def test_dingtalk_card_param_map_stringifies_select_component_arrays():
|
||||
@@ -40,3 +43,41 @@ def test_dingtalk_card_param_map_stringifies_unregistered_structures():
|
||||
|
||||
assert params['other'] == '["A"]'
|
||||
assert params['empty'] == ''
|
||||
|
||||
|
||||
async def test_create_card_embeds_layout_config_as_template_parameter(monkeypatch):
|
||||
response = type('Response', (), {'status_code': 200})()
|
||||
post = AsyncMock(return_value=response)
|
||||
|
||||
@asynccontextmanager
|
||||
async def client_context():
|
||||
yield type('HttpClient', (), {'post': post})()
|
||||
|
||||
client = object.__new__(DingTalkClient)
|
||||
client.access_token = 'access-token'
|
||||
client.robot_code = 'robot-code'
|
||||
client.key = 'client-id'
|
||||
client.logger = None
|
||||
client.check_access_token = AsyncMock(return_value=True)
|
||||
client._http_client_context = client_context
|
||||
monkeypatch.setattr(httpclient, 'response_text', AsyncMock(return_value='{}'))
|
||||
|
||||
original_params = {'content': 'hello'}
|
||||
delivered = await client.create_and_deliver_card(
|
||||
card_template_id='template-id',
|
||||
out_track_id='track-id',
|
||||
open_space_id='dtv1.card//IM_ROBOT.user-id',
|
||||
is_group=False,
|
||||
card_param_map=original_params,
|
||||
card_data_config={'autoLayout': True},
|
||||
)
|
||||
|
||||
request_body = post.await_args.kwargs['json']
|
||||
assert delivered is True
|
||||
assert request_body['cardData'] == {
|
||||
'cardParamMap': {
|
||||
'content': 'hello',
|
||||
'config': '{"autoLayout": true}',
|
||||
}
|
||||
}
|
||||
assert original_params == {'content': 'hello'}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Tests for Lark adapter helper behavior."""
|
||||
|
||||
import threading
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -12,6 +12,7 @@ from langbot.pkg.platform.sources.lark import (
|
||||
_lark_completed_input_lines,
|
||||
_lark_current_input_defs,
|
||||
_lark_extract_action_form_inputs,
|
||||
_lark_final_layout_texts,
|
||||
_lark_should_update_stream_element,
|
||||
_lark_visible_form_content,
|
||||
)
|
||||
@@ -221,3 +222,121 @@ def test_lark_completed_input_lines_display_select_value_from_object():
|
||||
)
|
||||
|
||||
assert lines == ['✅ xiala:B']
|
||||
|
||||
|
||||
def test_lark_final_layout_texts_normal_round_drops_resume_placeholder():
|
||||
"""Non-resume final chunk: the reply must land in the main element only.
|
||||
|
||||
Regression: rendering the resume placeholder too duplicated the reply,
|
||||
because the accumulated streaming text equals the final text on a normal
|
||||
round (e.g. 'It is Sep 1, 2026.\nIt is Sep 1, 2026.' in the card).
|
||||
"""
|
||||
main_text, resume_text = _lark_final_layout_texts(
|
||||
resume_from=False,
|
||||
text_message='It is Sep 1, 2026, 15:09:15.',
|
||||
pre_pause_cached=None,
|
||||
resume_cached='It is Sep 1, 2026, 15:09:15.',
|
||||
)
|
||||
|
||||
assert main_text == 'It is Sep 1, 2026, 15:09:15.'
|
||||
assert resume_text == ''
|
||||
|
||||
|
||||
def test_lark_final_layout_texts_resume_round_keeps_both_segments():
|
||||
"""Dify HITL resume final chunk: pre-pause text and resumed text differ,
|
||||
both segments stay visible."""
|
||||
main_text, resume_text = _lark_final_layout_texts(
|
||||
resume_from=True,
|
||||
text_message='resumed answer',
|
||||
pre_pause_cached='partial answer before pause',
|
||||
resume_cached='resumed answer',
|
||||
)
|
||||
|
||||
assert main_text == 'partial answer before pause'
|
||||
assert resume_text == 'resumed answer'
|
||||
|
||||
|
||||
def test_lark_final_layout_texts_resume_round_without_pre_pause_falls_back():
|
||||
main_text, resume_text = _lark_final_layout_texts(
|
||||
resume_from=True,
|
||||
text_message='answer',
|
||||
pre_pause_cached=None,
|
||||
resume_cached='answer',
|
||||
)
|
||||
|
||||
assert main_text == 'answer'
|
||||
assert resume_text == 'answer'
|
||||
|
||||
|
||||
def test_lark_final_layout_texts_resume_round_empty_pre_pause_kept_empty():
|
||||
"""Dify paused before emitting any text: the pre-pause cache is a valid
|
||||
empty string and must NOT be treated as a cache miss.
|
||||
|
||||
Regression: `pre_pause_cached or text_message` fell back to the full
|
||||
text, so the final card rendered ('resumed answer', 'resumed answer')
|
||||
and duplicated the reply.
|
||||
"""
|
||||
main_text, resume_text = _lark_final_layout_texts(
|
||||
resume_from=True,
|
||||
text_message='resumed answer',
|
||||
pre_pause_cached='',
|
||||
resume_cached='resumed answer',
|
||||
)
|
||||
|
||||
assert main_text == ''
|
||||
assert resume_text == 'resumed answer'
|
||||
|
||||
|
||||
def _build_resume_final_chunk_adapter(message_text: str):
|
||||
"""Build a LarkAdapter whose card state mimics a Dify HITL round that
|
||||
paused before emitting any text, then resumed and completed."""
|
||||
adapter = LarkAdapter.model_construct(
|
||||
api_client=MagicMock(),
|
||||
message_converter=MagicMock(yiri2target=AsyncMock(return_value=([[{'tag': 'text', 'text': message_text}]], []))),
|
||||
)
|
||||
adapter.config = {'app_type': 'self'}
|
||||
LarkAdapter.get_app_access_token = lambda self: None
|
||||
LarkAdapter.get_tenant_access_token = lambda self, tenant_key: None
|
||||
adapter.card_id_dict = {'msg-1': 'card-1'}
|
||||
adapter.card_streaming_text = {'card-1': message_text}
|
||||
adapter.card_pre_pause_text = {'card-1': ''}
|
||||
adapter.card_resume_transitioned = {'card-1'}
|
||||
adapter.card_sequence_dict = {}
|
||||
adapter.card_last_accessed = {}
|
||||
adapter.card_cleanup_at = 0.0
|
||||
adapter.card_id_to_source_ids = {}
|
||||
adapter.reply_message_card_ids = {}
|
||||
adapter.card_form_content = {}
|
||||
adapter.card_form_input_defs = {}
|
||||
adapter.card_form_inputs = {}
|
||||
adapter._update_card_layout = AsyncMock()
|
||||
return adapter
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reply_message_chunk_resume_final_with_empty_pre_pause_keeps_main_empty():
|
||||
"""End-to-end regression via reply_message_chunk: Dify paused before any
|
||||
text, so the pre-pause cache is ''. The final card update must render the
|
||||
resumed answer only once (empty main text + resume placeholder), not
|
||||
twice as ('resumed answer', 'resumed answer')."""
|
||||
adapter = _build_resume_final_chunk_adapter('resumed answer')
|
||||
|
||||
bot_message = MagicMock(
|
||||
resp_message_id='msg-1',
|
||||
msg_sequence=1,
|
||||
spec=['resp_message_id', 'msg_sequence', '_resume_from_form'],
|
||||
)
|
||||
bot_message._resume_from_form = True
|
||||
message_source = MagicMock(source_platform_object=None)
|
||||
|
||||
await adapter.reply_message_chunk(
|
||||
message_source,
|
||||
bot_message,
|
||||
MagicMock(),
|
||||
is_final=True,
|
||||
)
|
||||
|
||||
adapter._update_card_layout.assert_awaited_once()
|
||||
layout_kwargs = adapter._update_card_layout.await_args.kwargs
|
||||
assert layout_kwargs['text_message'] == ''
|
||||
assert layout_kwargs['resume_placeholder_text'] == 'resumed answer'
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from linebot.v3.webhooks import TextMessageContent, UserMentionee, AllMentionee
|
||||
|
||||
from langbot.pkg.platform import botmgr as _botmgr # noqa: F401
|
||||
from langbot.pkg.platform.sources import line
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
|
||||
BOT_ACCOUNT_ID = 'line-bot-account'
|
||||
|
||||
|
||||
def _make_event(
|
||||
*, source_type: str, user_id, group_id=None, room_id=None, message_id: str, text: str = 'hi', mention=None
|
||||
):
|
||||
event = MagicMock()
|
||||
event.timestamp = 1700000000000
|
||||
message = MagicMock(spec=TextMessageContent)
|
||||
message.id = message_id
|
||||
message.text = text
|
||||
message.mention = mention
|
||||
event.message = message
|
||||
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
|
||||
|
||||
|
||||
def _make_converter(bot_account_id: str = BOT_ACCOUNT_ID) -> line.LINEEventConverter:
|
||||
return line.LINEEventConverter(bot_account_id=bot_account_id)
|
||||
|
||||
|
||||
@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).
|
||||
"""
|
||||
converter = _make_converter()
|
||||
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 converter.target2yiri(event1, bot_client=None)
|
||||
result2 = await converter.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:
|
||||
converter = _make_converter()
|
||||
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 converter.target2yiri(event1, bot_client=None)
|
||||
result2 = await converter.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:
|
||||
converter = _make_converter()
|
||||
event = _make_event(source_type='room', user_id=None, room_id='R-stable-room', message_id='msg-1')
|
||||
|
||||
result = await converter.target2yiri(event, bot_client=None)
|
||||
|
||||
assert result.sender.group.id == 'R-stable-room'
|
||||
assert result.sender.id == 'R-stable-room'
|
||||
|
||||
|
||||
def _plain_texts(chain: platform_message.MessageChain) -> list[str]:
|
||||
return [c.text for c in chain if isinstance(c, platform_message.Plain)]
|
||||
|
||||
|
||||
def _ats(chain: platform_message.MessageChain) -> list[platform_message.At]:
|
||||
return [c for c in chain if isinstance(c, platform_message.At)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_mention_keeps_plain_text() -> None:
|
||||
converter = _make_converter()
|
||||
event = _make_event(source_type='group', user_id='U-member', group_id='G1', message_id='m1', text='hello world')
|
||||
|
||||
chain = await converter.message_converter.target2yiri(event, bot_client=None)
|
||||
|
||||
assert _plain_texts(chain) == ['hello world']
|
||||
assert _ats(chain) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bot_mention_maps_to_at_with_bot_account_id() -> None:
|
||||
"""A @bot mention must become At(target=bot_account_id) so the 'at-bot'
|
||||
group respond rule matches (previously the mention was lost and the message
|
||||
was silently dropped in groups with at-only rules).
|
||||
"""
|
||||
mention = MagicMock()
|
||||
mention.mentionees = [
|
||||
UserMentionee(type='user', index=0, length=4, userId='U-bot-user-id', isSelf=True),
|
||||
]
|
||||
converter = _make_converter()
|
||||
event = _make_event(
|
||||
source_type='group',
|
||||
user_id='U-member',
|
||||
group_id='G1',
|
||||
message_id='m1',
|
||||
text='@BOT hey',
|
||||
mention=mention,
|
||||
)
|
||||
|
||||
chain = await converter.message_converter.target2yiri(event, bot_client=None)
|
||||
|
||||
ats = _ats(chain)
|
||||
assert len(ats) == 1
|
||||
assert ats[0].target == BOT_ACCOUNT_ID
|
||||
assert _plain_texts(chain) == [' hey']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_other_user_mention_keeps_display_text() -> None:
|
||||
"""Mentions of other users keep their display text in the message string,
|
||||
so prefix/regexp rules that match the raw '@Name ...' text still work.
|
||||
"""
|
||||
mention = MagicMock()
|
||||
mention.mentionees = [
|
||||
UserMentionee(type='user', index=0, length=6, userId='U-other', isSelf=False),
|
||||
]
|
||||
converter = _make_converter()
|
||||
event = _make_event(
|
||||
source_type='group',
|
||||
user_id='U-member',
|
||||
group_id='G1',
|
||||
message_id='m1',
|
||||
text='@Alice hello',
|
||||
mention=mention,
|
||||
)
|
||||
|
||||
chain = await converter.message_converter.target2yiri(event, bot_client=None)
|
||||
|
||||
ats = _ats(chain)
|
||||
assert len(ats) == 1
|
||||
assert ats[0].target == 'U-other'
|
||||
# str() of the At component falls back to display when set
|
||||
assert str(chain) == '@Alice hello'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bot_mention_triggers_atbot_rule() -> None:
|
||||
"""End-to-end: a group message that @mentions the bot must be accepted by
|
||||
the at-bot respond rule (this is the regression that silently dropped
|
||||
'@bot' messages in LINE groups).
|
||||
"""
|
||||
from langbot.pkg.pipeline.resprule.rules.atbot import AtBotRule
|
||||
|
||||
mention = MagicMock()
|
||||
mention.mentionees = [
|
||||
UserMentionee(type='user', index=0, length=6, userId='U-bot-user-id', isSelf=True),
|
||||
]
|
||||
converter = _make_converter()
|
||||
event = _make_event(
|
||||
source_type='group',
|
||||
user_id='U-member',
|
||||
group_id='G1',
|
||||
message_id='m1',
|
||||
text='@RAIQt hi',
|
||||
mention=mention,
|
||||
)
|
||||
|
||||
chain = await converter.message_converter.target2yiri(event, bot_client=None)
|
||||
|
||||
query = MagicMock()
|
||||
query.adapter = MagicMock()
|
||||
query.adapter.bot_account_id = BOT_ACCOUNT_ID
|
||||
|
||||
rule = AtBotRule(ap=MagicMock())
|
||||
result = await rule.match(str(chain), chain, {'at': True}, query)
|
||||
|
||||
assert result.matching is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_without_bot_mention_still_dropped_by_atbot_rule() -> None:
|
||||
from langbot.pkg.pipeline.resprule.rules.atbot import AtBotRule
|
||||
|
||||
converter = _make_converter()
|
||||
event = _make_event(source_type='group', user_id='U-member', group_id='G1', message_id='m1', text='hello')
|
||||
|
||||
chain = await converter.message_converter.target2yiri(event, bot_client=None)
|
||||
|
||||
query = MagicMock()
|
||||
query.adapter = MagicMock()
|
||||
query.adapter.bot_account_id = BOT_ACCOUNT_ID
|
||||
|
||||
rule = AtBotRule(ap=MagicMock())
|
||||
result = await rule.match(str(chain), chain, {'at': True}, query)
|
||||
|
||||
assert result.matching is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_at_all_mention_preserved_as_at_component() -> None:
|
||||
mention = MagicMock()
|
||||
mention.mentionees = [
|
||||
AllMentionee(type='all', index=0, length=4),
|
||||
]
|
||||
converter = _make_converter()
|
||||
event = _make_event(
|
||||
source_type='group',
|
||||
user_id='U-member',
|
||||
group_id='G1',
|
||||
message_id='m1',
|
||||
text='@All hello',
|
||||
mention=mention,
|
||||
)
|
||||
|
||||
chain = await converter.message_converter.target2yiri(event, bot_client=None)
|
||||
|
||||
ats = _ats(chain)
|
||||
assert len(ats) == 1
|
||||
assert str(chain) == '@All hello'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_mentions_sorted_by_position() -> None:
|
||||
mention = MagicMock()
|
||||
# Intentionally out of order to exercise sorting
|
||||
mention.mentionees = [
|
||||
UserMentionee(type='user', index=9, length=4, userId='U-b', isSelf=False),
|
||||
UserMentionee(type='user', index=0, length=4, userId='U-a', isSelf=False),
|
||||
]
|
||||
converter = _make_converter()
|
||||
event = _make_event(
|
||||
source_type='group',
|
||||
user_id='U-member',
|
||||
group_id='G1',
|
||||
message_id='m1',
|
||||
text='@aaa mid @bbb tail',
|
||||
mention=mention,
|
||||
)
|
||||
|
||||
chain = await converter.message_converter.target2yiri(event, bot_client=None)
|
||||
|
||||
ats = _ats(chain)
|
||||
assert [a.target for a in ats] == ['U-a', 'U-b']
|
||||
assert str(chain) == '@aaa mid @bbb tail'
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user