Compare commits

..

52 Commits

Author SHA1 Message Date
dadachann 8737b818b6 fix(cloud): render empty skills without sandbox grant 2026-07-30 03:21:26 +00:00
dadachann 4b2a628db6 fix(deps): pin green multi-tenant plugin SDK 2026-07-29 11:15:18 +00:00
Junyan Qin 610915b9c5 fix(runtime): bound tenant resource amplification 2026-07-29 18:27:44 +08:00
Junyan Qin e8d90c4259 fix(cloud): bound tenant maintenance and monitoring work 2026-07-29 16:02:44 +08:00
dadachann 2dfbe78271 fix(cloud): scope public login capability discovery 2026-07-29 05:58:30 +00:00
Junyan Qin c89e6f3bd2 fix(cloud): enforce instance capacity ceilings 2026-07-29 13:45:58 +08:00
Junyan Qin e52d6880f5 fix(cloud): eliminate periodic runtime CPU spikes 2026-07-29 12:47:53 +08:00
Junyan Qin aa342d9347 fix(cloud): bound runtime restart storms 2026-07-29 12:14:08 +08:00
Junyan Qin ae85ac2b16 feat(cloud): harden multi-tenant runtime resources 2026-07-29 11:32:26 +08:00
dadachann 32abbb636f fix(oss): resolve workspace owner in scoped session 2026-07-26 16:30:15 +08:00
dadachann f247a9d183 test(oss): cover invitation logout handoff 2026-07-26 16:12:03 +08:00
dadachann 624a197655 style: format OSS account service 2026-07-26 16:09:50 +08:00
dadachann 712f79ed77 feat(oss): enforce invitation account and owner billing flows 2026-07-26 16:04:33 +08:00
dadachann 602e10649b fix(cloud): recover box runtime without unscoped skill reload 2026-07-26 14:25:49 +08:00
dadachann d71bd571b1 style(web): format invitation flows 2026-07-26 14:01:20 +08:00
dadachann e90a1546de feat(cloud): complete secure invitation experience 2026-07-26 13:54:42 +08:00
dadachann ff068564ab test(cloud): require Space identity for invite registration 2026-07-26 12:32:02 +08:00
dadachann 94ff4fcd2d fix(cloud): preserve Core-owned collaboration state 2026-07-26 12:27:47 +08:00
dadachann 97b3e58884 fix(workspace): bind collaboration APIs to tenant UoW 2026-07-26 11:28:46 +08:00
dadachann 66a1ceac25 style: format collaboration changes 2026-07-26 10:54:21 +08:00
dadachann 8a445bfb22 feat(workspace): add in-product collaboration and direct Cloud launch 2026-07-26 10:40:36 +08:00
dadachann 276791e7af fix(ui): align workspace switcher with sidebar entries 2026-07-26 00:23:17 +08:00
dadachann baf7e86335 fix(ui): hide roles from workspace switcher 2026-07-26 00:07:44 +08:00
dadachann 741c20af07 fix(ui): widen and center workspace switcher 2026-07-25 23:44:49 +08:00
dadachann 5ac1ab3eac fix(plugin): keep runtime identity stable across restarts 2026-07-25 21:56:14 +08:00
dadachann f96116a050 fix(cloud): surface runtime and workspace plan status 2026-07-25 21:42:05 +08:00
dadachann 40abb03928 style(web): format workspace layout test 2026-07-25 21:04:23 +08:00
dadachann 59f68b8fb4 refactor(web): streamline workspace controls 2026-07-25 20:59:05 +08:00
dadachann 7c64cd9d51 feat(web): place workspace controls in sidebar 2026-07-25 16:26:13 +08:00
dadachann 9ea1a81048 test(web): cover Workspace dropdown menu 2026-07-25 14:11:28 +08:00
dadachann d3f08a90b1 feat(cloud): complete Workspace settings navigation 2026-07-25 13:51:37 +08:00
dadachann 64e772e32d fix(cloud): reuse authenticated account for user info 2026-07-25 02:41:31 +08:00
dadachann 84440df47f fix(cloud): preserve authenticated account context 2026-07-25 02:05:17 +08:00
dadachann c860159446 test(cloud): preserve minimal model manager fixtures 2026-07-25 00:45:42 +08:00
dadachann f977629a90 fix(cloud): skip legacy model sync during startup 2026-07-25 00:26:56 +08:00
dadachann ff13d52602 chore: update multi-tenant SDK pin 2026-07-24 23:31:08 +08:00
Junyan Qin 5beab49577 docs(cloud): update control plane verification 2026-07-24 22:58:56 +08:00
Junyan Qin e8a09b7537 fix(build): install git for pinned SDK 2026-07-24 19:29:14 +08:00
Junyan Qin 98f45aa88e feat(tenancy): connect cloud workspace control plane 2026-07-24 19:11:33 +08:00
Junyan Qin d7cdd206c2 docs(tenancy): record final isolation verification 2026-07-24 16:22:45 +08:00
Junyan Qin ac72563664 fix(tenancy): close isolation and permission gaps 2026-07-24 16:22:45 +08:00
Junyan Qin 64dc887b20 docs(tenancy): record final isolation verification 2026-07-24 16:22:45 +08:00
Junyan Qin 627eb6b8ef feat(tenancy): harden shared cloud runtime boundaries 2026-07-24 16:22:45 +08:00
Junyan Qin 3f01ffe63b feat(tenancy): establish cloud isolation foundations 2026-07-24 16:22:45 +08:00
Junyan Qin d7adbeec1e docs: finalize cloud v2 multi-tenant decisions 2026-07-24 16:22:44 +08:00
Junyan Qin abf77cecfa docs(tenancy): refine architecture options 2026-07-24 16:22:44 +08:00
Junyan Qin 270622ae9d docs(tenancy): revise single-instance SaaS topology 2026-07-24 16:22:44 +08:00
Junyan Qin 30f414a534 docs(tenancy): record verification evidence 2026-07-24 16:22:44 +08:00
Junyan Qin 8b7ce77cec feat(tenancy): implement workspace isolation 2026-07-24 16:22:44 +08:00
Junyan Qin 37099ddf7e docs: redesign multi-tenant workspace architecture 2026-07-24 16:22:44 +08:00
Junyan Qin ee59e2d3fd Add OSS and commercial workspace boundaries 2026-07-24 16:22:44 +08:00
Junyan Qin a4550350c0 Document multi-tenant workspace architecture 2026-07-24 16:22:44 +08:00
66 changed files with 190 additions and 2229 deletions
-78
View File
@@ -1,78 +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: e1b261dac45e886efc667b1096a4ec493c6a6111
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
- name: Configure SSH
env:
SSH_KEY: ${{ secrets.JP09_SSH_KEY }}
KNOWN_HOSTS: ${{ secrets.JP09_KNOWN_HOSTS }}
run: |
install -m 700 -d ~/.ssh
install -m 600 /dev/null ~/.ssh/id_ed25519
printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519
printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts
- name: Upload release manifest and deploy
env:
HOST: ${{ secrets.JP09_HOST }}
USER: ${{ secrets.JP09_USER }}
PORT: ${{ secrets.JP09_PORT }}
run: |
remote="$USER@$HOST"
ssh -p "$PORT" "$remote" 'install -d -m 700 /opt/langbot-cloud-prod'
scp -P "$PORT" deploy/prod/docker-compose.yml deploy/prod/deploy.sh "$remote:/opt/langbot-cloud-prod/"
ssh -p "$PORT" "$remote" "chmod 700 /opt/langbot-cloud-prod/deploy.sh && /opt/langbot-cloud-prod/deploy.sh prod-${GITHUB_SHA}"
-92
View File
@@ -1,92 +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
}
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
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
-161
View File
@@ -1,161 +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_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:
@@ -1,166 +0,0 @@
# LangBot 多租户数据库迁移指南
## 概述
LangBot 从单租户 OSS 架构迁移到多租户 SaaS 架构,需要执行 7 个数据库迁移(0009-0015)。
## 迁移序列
```
0009_workspace_tenancy_kernel → 创建 Workspace、成员、邀请表
0010_scope_tenant_resources → 所有业务表添加 workspace_uuid
0011_postgres_tenant_rls → PostgreSQL 行级安全策略
0012_plugin_installation_identity → 插件实例租户绑定
0013_tenant_pgvector → RAG 向量存储隔离
0014_cloud_directory_projection → Cloud 控制平面同步
0015_cloud_core_collaboration → 协作和权限功能
```
## 执行步骤
### 1. 备份(必须)
```bash
# SQLite
cp ~/.langbot/data/langbot.db ~/.langbot/data/langbot.db.backup-$(date +%Y%m%d)
# PostgreSQL
pg_dump -U langbot_user -d langbot_db -F c -f langbot_backup_$(date +%Y%m%d).dump
```
### 2. 执行迁移
```bash
# 停止服务
sudo -S -p '' systemctl stop langbot
# 执行迁移
python -m langbot.pkg.persistence.migration upgrade head
# 验证
python -m langbot.pkg.persistence.migration current
# 预期: 0015_cloud_core_collaboration
# 启动服务
sudo -S -p '' systemctl start langbot
```
### 3. OSS 单租户自动迁移
迁移会自动:
- 创建默认 Workspace(名称:"Default Workspace"
- 第一个用户成为 Owner
- 所有现有资源绑定到该 Workspace
### 4. 验证检查
```bash
# 检查 Workspace
python << EOF
from langbot.pkg.persistence import manager
import sqlalchemy as sa
with manager.engine.connect() as conn:
ws = conn.execute(sa.text("SELECT uuid, name FROM workspaces LIMIT 1")).first()
print(f"Workspace: {ws[1]} ({ws[0]})")
# 检查资源绑定
bot_count = conn.execute(sa.text(
f"SELECT COUNT(*) FROM bots WHERE workspace_uuid='{ws[0]}'"
)).scalar()
print(f"Bots: {bot_count}")
EOF
```
## 回滚方案
### 完全回滚(丢失多租户数据)
```bash
# 1. 停止服务
sudo -S -p '' systemctl stop langbot
# 2. 恢复备份
cp ~/.langbot/data/langbot.db.backup-YYYYMMDD ~/.langbot/data/langbot.db
# 3. 回退代码
git checkout v4.10.x
pip install -e .
# 4. 启动
sudo -S -p '' systemctl start langbot
```
### 降级迁移(保留数据但移除多租户)
```bash
# 警告:会移除 Workspace 表但保留资源
python -m langbot.pkg.persistence.migration downgrade 0008_mcp_resource_prefs
```
## 常见问题
### Q: 迁移后无法登录
```bash
# 检查用户 UUID
python << EOF
from langbot.pkg.persistence import manager
import sqlalchemy as sa
with manager.engine.connect() as conn:
users = conn.execute(sa.text("SELECT id, user, uuid, status FROM users")).all()
for u in users:
print(f"{u[1]}: UUID={u[2]}, Status={u[3]}")
EOF
```
### Q: 资源看不见了
检查 Workspace 上下文:
```bash
# 前端请求需要带 X-Workspace-ID header
curl -H "Authorization: Bearer $TOKEN" \
-H "X-Workspace-ID: $WORKSPACE_UUID" \
http://localhost:5200/api/v1/platform/bots
```
### Q: 迁移速度慢
```bash
# SQLite 优化
sqlite3 ~/.langbot/data/langbot.db << EOF
PRAGMA journal_mode=WAL;
PRAGMA synchronous=NORMAL;
VACUUM;
EOF
```
## 性能调优
### PostgreSQL 索引
```sql
-- 迁移后创建
CREATE INDEX CONCURRENTLY idx_model_providers_workspace
ON model_providers(workspace_uuid);
CREATE INDEX CONCURRENTLY idx_bots_workspace
ON bots(workspace_uuid);
CREATE INDEX CONCURRENTLY idx_pipelines_workspace
ON pipelines(workspace_uuid);
```
## 预估时间
- SQLite < 100MB: 2-5 分钟
- SQLite 100MB-1GB: 5-15 分钟
- PostgreSQL: < 5 分钟(取决于数据量)
## 支持
问题反馈:https://github.com/langbot-app/LangBot/issues
**版本**: 1.0
**最后更新**: 2026-07-30
@@ -81,10 +81,10 @@ This log records implementation choices made while delivering the Workspace arch
- Decision: The MCP ASGI mount authenticates the API key once, binds an immutable per-request `RequestContext`, and every tool checks a fixed permission before calling tenant services with that same context.
- Reason: Authenticating the transport without propagating Workspace identity into tool calls would leave the direct service path globally scoped.
### Released SDK protocol is pinned from PyPI
### Unreleased SDK protocol is pinned reproducibly without publishing
- Decision: The SDK tenancy protocol is released as `langbot-plugin==0.5.0` and LangBot pins that exact registry version.
- Reason: The final PyPI release contains the complete tenant action context and shared Runtime hardening, while the exact version pin keeps production installs reproducible.
- Decision: The SDK tenancy protocol is versioned as 0.4.18. This task does not create a GitHub release or publish PyPI because the user authorized pushing code, not a package release. After the SDK feature branch is final, LangBot's feature branch temporarily pins the exact pushed SDK Git commit. Before merging to master, the release gate is to publish `langbot-plugin==0.4.18` and replace the Git pin with the registry pin.
- Reason: The current registry release does not contain the complete tenant action context and shared Runtime hardening. An exact Git commit is reproducible and keeps the feature branch testable without expanding release authority.
### Cloud directory writes stay outside Core
+1 -1
View File
@@ -71,7 +71,7 @@ dependencies = [
"chromadb>=1.0.0,<2.0.0",
"qdrant-client (>=1.15.1,<2.0.0)",
"pyseekdb==1.1.0.post3",
"langbot-plugin==0.5.0",
"langbot-plugin @ git+https://github.com/langbot-app/langbot-plugin-sdk.git@1d65ed301a6afc52150a998043f73cd6032c8162",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"matrix-nio>=0.25.2",
@@ -14,7 +14,6 @@ from ....utils import bounded_executor
from ....workspace.collaboration import MembershipPermissionError, WorkspaceCollaborationError
from ....workspace.errors import WorkspaceNotFoundError
from ....cloud.entitlements import EntitlementUnavailableError
from ....cloud.quotas import WorkspaceQuotaExceededError
from ....core.errors import TaskCapacityError
from ..authz import AuthorizationError, Permission, permissions_for_role, require_permission
from ..context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext
@@ -220,8 +219,6 @@ class RouterGroup(abc.ABC):
return self.http_status(403, e.code, str(e))
if isinstance(e, WorkspaceCollaborationError):
return self.http_status(400, e.code, str(e))
if isinstance(e, WorkspaceQuotaExceededError):
return self.http_status(409, e.error_code, str(e))
if isinstance(e, TaskCapacityError):
return self.http_status(429, 'task_capacity_exceeded', str(e))
if isinstance(
@@ -23,13 +23,8 @@ def _storage_owner(context: RequestContext) -> str:
@group.group_class('files', '/api/v1/files')
class FilesRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route(
'/image/<path:image_key>',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def _(image_key: str, request_context: RequestContext) -> quart.Response:
@self.route('/image/<path:image_key>', methods=['GET'], auth_type=group.AuthType.NONE)
async def _(image_key: str) -> quart.Response:
image_bytes = await self.ap.storage_mgr.resolve_public_object(
image_key,
expected_owner_type='upload_image',
@@ -128,7 +128,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
self,
request_context: RequestContext,
token: str,
) -> RequestContext:
) -> None:
"""Recheck revocable account, membership, permission, and placement state."""
account, _ = await self._authenticate_account(token)
@@ -168,7 +168,6 @@ class WebSocketChatRouterGroup(group.RouterGroup):
entitlement_revision=request_context.entitlement_revision,
)
require_permission(current_context, Permission.RUNTIME_OPERATE)
return current_context
async def _get_scoped_adapter(self, request_context: RequestContext, pipeline_uuid: str):
pipeline = await run_in_workspace_uow(
@@ -318,13 +318,6 @@ class PluginsRouterGroup(group.RouterGroup):
)
return await operation()
async def _require_authenticated_plugin_runtime_context(
self,
request_context: RequestContext,
) -> ExecutionContext:
"""Fence an authenticated resource request to its injected Workspace."""
return await self.ap.plugin_connector.require_workspace_context(request_context)
async def _require_public_plugin_runtime_context(self) -> ExecutionContext:
"""Resolve public assets only for the OSS singleton Workspace.
@@ -379,7 +372,7 @@ class PluginsRouterGroup(group.RouterGroup):
permission=Permission.RESOURCE_VIEW,
)
async def _(request_context: RequestContext) -> str:
await self._require_authenticated_plugin_runtime_context(request_context)
await self.ap.plugin_connector.require_workspace_context(request_context)
plugins = await self.ap.plugin_connector.list_plugins()
return self.success(data={'plugins': redact_plugin_secrets(plugins)})
@@ -392,7 +385,7 @@ class PluginsRouterGroup(group.RouterGroup):
)
async def _(request_context: RequestContext) -> str:
"""Get plugin debug information including debug URL and key"""
await self._require_authenticated_plugin_runtime_context(request_context)
await self.ap.plugin_connector.require_workspace_context(request_context)
debug_info = await self.ap.plugin_connector.get_debug_info()
# Get debug URL from config
@@ -435,7 +428,7 @@ class PluginsRouterGroup(group.RouterGroup):
permission=Permission.RESOURCE_VIEW,
)
async def _(author: str, plugin_name: str, request_context: RequestContext) -> str:
await self._require_authenticated_plugin_runtime_context(request_context)
await self.ap.plugin_connector.require_workspace_context(request_context)
plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name)
if plugin is None:
return self.http_status(404, -1, 'plugin not found')
@@ -476,7 +469,7 @@ class PluginsRouterGroup(group.RouterGroup):
permission=Permission.RESOURCE_VIEW,
)
async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response:
await self._require_authenticated_plugin_runtime_context(request_context)
await self.ap.plugin_connector.require_workspace_context(request_context)
plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name)
if plugin is None:
return self.http_status(404, -1, 'plugin not found')
@@ -496,7 +489,7 @@ class PluginsRouterGroup(group.RouterGroup):
permission=Permission.RESOURCE_MANAGE,
)
async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response:
await self._require_authenticated_plugin_runtime_context(request_context)
await self.ap.plugin_connector.require_workspace_context(request_context)
plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name)
if plugin is None:
return self.http_status(404, -1, 'plugin not found')
@@ -513,7 +506,7 @@ class PluginsRouterGroup(group.RouterGroup):
)
except ValueError as exc:
return self.http_status(400, -1, str(exc))
await self._require_authenticated_plugin_runtime_context(request_context)
await self.ap.plugin_connector.require_workspace_context(request_context)
await self.ap.plugin_connector.set_plugin_config(author, plugin_name, config)
return self.success(data={})
@@ -524,7 +517,7 @@ class PluginsRouterGroup(group.RouterGroup):
permission=Permission.RESOURCE_VIEW,
)
async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response:
await self._require_authenticated_plugin_runtime_context(request_context)
await self.ap.plugin_connector.require_workspace_context(request_context)
language = quart.request.args.get('language', 'en')
readme = await self.ap.plugin_connector.get_plugin_readme(author, plugin_name, language=language)
return self.success(data={'readme': readme})
@@ -536,7 +529,7 @@ class PluginsRouterGroup(group.RouterGroup):
permission=Permission.AUDIT_VIEW,
)
async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response:
await self._require_authenticated_plugin_runtime_context(request_context)
await self.ap.plugin_connector.require_workspace_context(request_context)
try:
limit = int(quart.request.args.get('limit', 200))
except (TypeError, ValueError):
@@ -545,44 +538,6 @@ class PluginsRouterGroup(group.RouterGroup):
logs = await self.ap.plugin_connector.get_plugin_logs(author, plugin_name, limit=limit, level=level)
return self.success(data={'logs': logs})
@self.route(
'/<author>/<plugin_name>/authenticated-icon',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def _(
author: str,
plugin_name: str,
request_context: RequestContext,
) -> quart.Response:
await self._require_authenticated_plugin_runtime_context(request_context)
icon_data = await self.ap.plugin_connector.get_plugin_icon(author, plugin_name)
icon_bytes = await asyncio.to_thread(base64.b64decode, icon_data['plugin_icon_base64'])
return quart.Response(icon_bytes, mimetype=icon_data['mime_type'])
@self.route(
'/<author>/<plugin_name>/authenticated-assets/<path:filepath>',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def _(
author: str,
plugin_name: str,
filepath: str,
request_context: RequestContext,
) -> quart.Response:
await self._require_authenticated_plugin_runtime_context(request_context)
asset_path = _normalize_plugin_asset_path(filepath)
if asset_path is None:
return quart.Response('Asset not found', status=404)
asset_data = await self.ap.plugin_connector.get_plugin_assets(author, plugin_name, asset_path)
if not asset_data.get('asset_base64'):
return quart.Response('Asset not found', status=404)
asset_bytes = await asyncio.to_thread(base64.b64decode, asset_data['asset_base64'])
return quart.Response(asset_bytes, mimetype=asset_data['mime_type'])
@self.route(
'/<author>/<plugin_name>/icon',
methods=['GET'],
@@ -641,7 +596,7 @@ class PluginsRouterGroup(group.RouterGroup):
)
async def _(author: str, plugin_name: str, request_context: RequestContext) -> str:
"""Forward a page API request to the plugin."""
await self._require_authenticated_plugin_runtime_context(request_context)
await self.ap.plugin_connector.require_workspace_context(request_context)
data = await quart.request.json
if not isinstance(data, dict):
return self.http_status(400, -1, 'invalid request body')
@@ -670,7 +625,7 @@ class PluginsRouterGroup(group.RouterGroup):
)
async def _(request_context: RequestContext) -> str:
"""Get releases from a GitHub repository URL"""
await self._require_authenticated_plugin_runtime_context(request_context)
await self.ap.plugin_connector.require_workspace_context(request_context)
data = await quart.request.json
repo_url = data.get('repo_url', '')
@@ -750,7 +705,7 @@ class PluginsRouterGroup(group.RouterGroup):
)
async def _(request_context: RequestContext) -> str:
"""Get assets from a specific GitHub release"""
await self._require_authenticated_plugin_runtime_context(request_context)
await self.ap.plugin_connector.require_workspace_context(request_context)
data = await quart.request.json
owner = data.get('owner', '')
repo = data.get('repo', '')
@@ -946,7 +901,7 @@ class PluginsRouterGroup(group.RouterGroup):
permission=Permission.RESOURCE_MANAGE,
)
async def _(request_context: RequestContext) -> str:
await self._require_authenticated_plugin_runtime_context(request_context)
await self.ap.plugin_connector.require_workspace_context(request_context)
file = (await quart.request.files).get('file')
if file is None:
return self.http_status(400, -1, 'file is required')
@@ -987,7 +942,7 @@ class PluginsRouterGroup(group.RouterGroup):
)
async def _(request_context: RequestContext) -> str:
"""Upload a file for plugin configuration"""
await self._require_authenticated_plugin_runtime_context(request_context)
await self.ap.plugin_connector.require_workspace_context(request_context)
file = (await quart.request.files).get('file')
if file is None:
return self.http_status(400, -1, 'file is required')
@@ -1019,7 +974,7 @@ class PluginsRouterGroup(group.RouterGroup):
)
async def _(file_key: str, request_context: RequestContext) -> str:
"""Delete a plugin configuration file"""
await self._require_authenticated_plugin_runtime_context(request_context)
await self.ap.plugin_connector.require_workspace_context(request_context)
if not self.ap.storage_mgr.is_scoped_object_key(file_key, expected_owner_type='plugin_config'):
return self.http_status(400, -1, 'invalid file key')
@@ -2,7 +2,6 @@ from __future__ import annotations
import quart
from langbot.pkg.cloud.entitlements import EntitlementFeatureUnavailableError
from langbot_plugin.box.errors import BoxError
from ...authz import Permission
@@ -24,11 +23,6 @@ class SkillsRouterGroup(group.RouterGroup):
async def list_skills(request_context: RequestContext) -> quart.Response:
try:
skills = await self.ap.skill_service.list_skills(request_context)
except EntitlementFeatureUnavailableError:
# Plans without managed sandbox support have no runnable skills.
# Treat that capability absence as an empty collection so the
# shared UI can render normally instead of surfacing a 500.
return self.success(data={'skills': []})
except (ValueError, BoxError) as exc:
return self.http_status(400, -1, str(exc))
return self.success(data={'skills': skills})
@@ -410,7 +410,6 @@ class UserRouterGroup(group.RouterGroup):
'token': token,
'user': account.user,
'workspace_uuid': access.workspace.uuid,
'return_path': launch.get('return_path', '/home'),
}
)
except SpaceLaunchError:
+8 -28
View File
@@ -4,7 +4,6 @@ import uuid
import sqlalchemy
from ....core import app
from ....cloud.quotas import require_resource_capacity, resolve_workspace_quota
from ....entity.persistence import bot as persistence_bot
from ....entity.persistence import pipeline as persistence_pipeline
from ....workspace.errors import WorkspaceNotFoundError
@@ -102,21 +101,20 @@ class BotService:
async def create_bot(self, context: TenantContext, bot_data: dict) -> str:
"""Create bot"""
workspace_uuid = require_workspace_uuid(context)
# Check limitation
limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})
quota = await resolve_workspace_quota(
self.ap,
workspace_uuid,
'bots.max',
fallback=limitation.get('max_bots', -1),
)
max_bots = limitation.get('max_bots', -1)
if max_bots >= 0:
existing_bots = await self.get_bots(context)
if len(existing_bots) >= max_bots:
raise ValueError(f'Maximum number of bots ({max_bots}) reached')
# TODO: 检查配置信息格式
bot_data = bot_data.copy()
bot_data['uuid'] = str(uuid.uuid4())
bot_data['workspace_uuid'] = workspace_uuid
# Preserve the legacy flat-row result shape for this optional lookup;
# quota admission and insertion below still share one transaction.
# bind the most recently updated pipeline if any exist
result = await self.ap.persistence_mgr.execute_async(
scope_statement(
sqlalchemy.select(persistence_pipeline.LegacyPipeline),
@@ -131,25 +129,7 @@ class BotService:
bot_data['use_pipeline_uuid'] = pipeline.uuid
bot_data['use_pipeline_name'] = pipeline.name
async def persist(execute) -> None:
await require_resource_capacity(
execute,
workspace_uuid=workspace_uuid,
model=persistence_bot.Bot,
quota=quota,
resource_name='bots',
)
await execute(sqlalchemy.insert(persistence_bot.Bot).values(bot_data))
tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
if quota.requires_transaction_lock:
if not callable(tenant_uow):
raise RuntimeError('Cloud bot quota enforcement requires transactional persistence')
async with tenant_uow(workspace_uuid) as uow:
await persist(uow.execute)
else:
await persist(self.ap.persistence_mgr.execute_async)
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_bot.Bot).values(bot_data))
bot = await self.get_bot(context, bot_data['uuid'], include_secret=True)
@@ -56,14 +56,6 @@ class KnowledgeService:
require_workspace_uuid(context)
# In new architecture, we delegate entirely to RAGManager which uses plugins.
# Legacy internal KB creation is removed.
limitation = (
getattr(getattr(self.ap, 'instance_config', None), 'data', {}).get('system', {}).get('limitation', {})
)
max_knowledge_bases = limitation.get('max_knowledge_bases', -1)
if max_knowledge_bases >= 0:
knowledge_bases = await self.ap.rag_mgr.get_all_knowledge_base_details(context)
if len(knowledge_bases) >= max_knowledge_bases:
raise ValueError(f'Maximum number of knowledge bases ({max_knowledge_bases}) reached')
knowledge_engine_plugin_id = kb_data.get('knowledge_engine_plugin_id')
if not knowledge_engine_plugin_id:
@@ -331,15 +331,8 @@ class ModelProviderService:
embedding_models = await self.ap.embedding_models_service.get_embedding_models_by_provider(
context, provider_uuid
)
rerank_service = getattr(self.ap, 'rerank_models_service', None)
rerank_models = (
await rerank_service.get_rerank_models_by_provider(context, provider_uuid)
if rerank_service is not None
else []
)
existing_llm_names = {model['name'] for model in llm_models}
existing_embedding_names = {model['name'] for model in embedding_models}
existing_rerank_names = {model['name'] for model in rerank_models}
filtered_models = []
for model in scanned_models:
@@ -366,8 +359,6 @@ class ModelProviderService:
'already_added': (
model_name in existing_embedding_names
if scanned_type == 'embedding'
else model_name in existing_rerank_names
if scanned_type == 'rerank'
else model_name in existing_llm_names
),
}
+8 -1
View File
@@ -12,6 +12,7 @@ from urllib.parse import quote, unquote, urlparse
import httpx
from ....cloud.entitlements import EntitlementFeatureUnavailableError
from ....core import app
from ....skill.utils import parse_frontmatter
from ....utils import httpclient
@@ -119,7 +120,13 @@ class SkillService:
box_service = self._box_service()
if box_service is None:
return []
return [self._serialize_skill(skill) for skill in await box_service.list_skills(execution_context)]
try:
skills = await box_service.list_skills(execution_context)
except EntitlementFeatureUnavailableError as error:
if error.feature == 'managed_sandbox':
return []
raise
return [self._serialize_skill(skill) for skill in skills]
async def get_skill(self, context: TenantContext, skill_name: str) -> Optional[dict]:
execution_context = await self._execution_context(context)
+2 -8
View File
@@ -138,14 +138,8 @@ class VerifiedCloudDeployment:
if plugin_worker.get('require_hard_limits') is not True:
raise CloudBootstrapError('Cloud Runtime requires plugin.worker.require_hard_limits=true')
box_config = config.get('box', {})
box_enabled = box_config.get('enabled')
if box_enabled is False:
# Explicitly disabling Box removes the sandbox surface entirely and
# therefore does not weaken tenant isolation. Validate the strict
# runtime/admission contract only when the surface is enabled.
return
if box_enabled is not True:
raise CloudBootstrapError('Cloud runtime requires box.enabled to be an explicit boolean')
if box_config.get('enabled') is not True:
raise CloudBootstrapError('Cloud runtime requires box.enabled=true')
if box_config.get('backend') != 'nsjail':
raise CloudBootstrapError('Cloud runtime requires box.backend=nsjail')
runtime_endpoint = str(box_config.get('runtime', {}).get('endpoint', '') or '').strip()
+5 -12
View File
@@ -18,19 +18,11 @@ class EntitlementUnavailableError(RuntimeError):
class EntitlementFeatureUnavailableError(EntitlementUnavailableError):
"""Raised only when an active entitlement does not grant one feature."""
"""Raised when an active entitlement explicitly omits a capability."""
def __init__(
self,
feature: str,
*,
entitlement_revision: int | None = None,
) -> None:
def __init__(self, message: str, *, feature: str, entitlement_revision: int | None = None) -> None:
super().__init__(message, entitlement_revision=entitlement_revision)
self.feature = feature
super().__init__(
f'Workspace entitlement does not grant {feature}',
entitlement_revision=entitlement_revision,
)
class EntitlementSnapshot(pydantic.BaseModel):
@@ -99,7 +91,8 @@ class EntitlementSnapshot(pydantic.BaseModel):
def require_feature(self, feature: str) -> None:
if self.features.get(feature) is not True:
raise EntitlementFeatureUnavailableError(
feature,
f'Workspace entitlement does not grant {feature}',
feature=feature,
entitlement_revision=self.entitlement_revision,
)
+10 -45
View File
@@ -3,11 +3,9 @@ from __future__ import annotations
import asyncio
import base64
import binascii
import datetime
import hashlib
import heapq
import json
import math
import os
import time
import typing
@@ -16,10 +14,6 @@ from collections.abc import Callable, Iterable
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
import sqlalchemy
from sqlalchemy.dialects.postgresql import insert as pg_insert
from ..entity.persistence.cloud_directory import SpaceLaunchAssertionConsumption
if typing.TYPE_CHECKING:
from ..core.app import Application
@@ -125,30 +119,21 @@ class SpaceLaunchService:
*,
expected_workspace_uuid: str | None = None,
) -> dict[str, str]:
claims, clock_skew_seconds = self._verify_assertion(assertion)
claims = self._verify_assertion(assertion)
payload = claims.get('payload')
if not isinstance(payload, dict):
raise SpaceLaunchError('Launch assertion payload must be a JSON object')
account_uuid = _required_string(payload, 'account_uuid')
workspace_uuid = _required_string(payload, 'workspace_uuid')
return_path = _required_string(payload, 'return_path')
if (
not return_path.startswith('/')
or return_path.startswith('//')
or any(character in return_path for character in ('\\', '\r', '\n', '\t'))
):
raise SpaceLaunchError('Launch assertion return path is invalid')
if expected_workspace_uuid is not None and workspace_uuid != expected_workspace_uuid:
raise SpaceLaunchError('Launch assertion targets another Workspace')
replay_retention_expires_at = _required_int(claims, 'exp', minimum=1) + math.ceil(clock_skew_seconds)
await self._consume_jti(_required_string(claims, 'jti'), replay_retention_expires_at)
await self._consume_jti(_required_string(claims, 'jti'), _required_int(claims, 'exp', minimum=1))
return {
'account_uuid': account_uuid,
'workspace_uuid': workspace_uuid,
'return_path': return_path,
}
def _verify_assertion(self, token: str) -> tuple[dict[str, typing.Any], float]:
def _verify_assertion(self, token: str) -> dict[str, typing.Any]:
if not getattr(getattr(self.ap, 'deployment', None), 'multi_workspace_enabled', False):
raise SpaceLaunchError('Space direct launch requires verified Cloud mode')
public_key, key_id, clock_skew_seconds = self._trust_config()
@@ -199,7 +184,7 @@ class SpaceLaunchService:
raise SpaceLaunchError('Launch assertion is expired')
if expires_at <= max(issued_at, not_before):
raise SpaceLaunchError('Launch assertion expiry must follow issue time')
return claims, clock_skew_seconds
return claims
def _trust_config(self) -> tuple[Ed25519PublicKey, str, float]:
data = getattr(getattr(self.ap, 'instance_config', None), 'data', {}) or {}
@@ -229,39 +214,19 @@ class SpaceLaunchService:
async def _consume_jti(self, jti: str, expires_at: int) -> None:
digest = hashlib.sha256(jti.encode('utf-8')).hexdigest()
now = int(self._wall_time())
persistence_mgr = getattr(self.ap, 'persistence_mgr', None)
instance_uuid = str(self.ap.workspace_service.instance_uuid)
if persistence_mgr is not None:
expires_at_datetime = datetime.datetime.fromtimestamp(expires_at, tz=datetime.timezone.utc)
now_datetime = datetime.datetime.fromtimestamp(now, tz=datetime.timezone.utc)
async with persistence_mgr.directory_projection_uow(instance_uuid) as uow:
await uow.session.execute(
sqlalchemy.delete(SpaceLaunchAssertionConsumption).where(
SpaceLaunchAssertionConsumption.instance_uuid == instance_uuid,
SpaceLaunchAssertionConsumption.expires_at < now_datetime,
)
)
statement = (
pg_insert(SpaceLaunchAssertionConsumption)
.values(instance_uuid=instance_uuid, jti=digest, expires_at=expires_at_datetime)
.on_conflict_do_nothing(index_elements=['instance_uuid', 'jti'])
.returning(SpaceLaunchAssertionConsumption.jti)
)
result = await uow.session.execute(statement)
if result.scalar_one_or_none() is None:
raise SpaceLaunchError('Launch assertion has already been consumed')
return
# Lightweight unit-test and OSS compatibility fallback. Verified Cloud
# runtime always supplies the durable PostgreSQL persistence manager.
async with self._replay_lock:
self._prune_consumed_jtis(now)
if digest in self._consumed_jtis:
raise SpaceLaunchError('Launch assertion has already been consumed')
if len(self._consumed_jtis) >= _CONSUMED_JTI_MAX_ENTRIES:
# Evicting a still-valid digest would make a signed launch
# assertion replayable. Bound memory by failing closed instead.
raise SpaceLaunchError('Launch assertion replay cache capacity reached')
self._consumed_jtis[digest] = expires_at
heapq.heappush(self._consumed_jti_expiry_heap, (expires_at, digest))
heapq.heappush(
self._consumed_jti_expiry_heap,
(expires_at, digest),
)
def _prune_consumed_jtis(self, now: int) -> None:
while self._consumed_jti_expiry_heap:
-82
View File
@@ -1,82 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Awaitable, Callable
import sqlalchemy
from ..entity.persistence import workspace as persistence_workspace
from .entitlements import EntitlementResolver
Execute = Callable[[Any], Awaitable[Any]]
class WorkspaceQuotaExceededError(ValueError):
"""A stable business error raised when a workspace has no free slots."""
error_code = 'workspace_quota_exceeded'
def __init__(self, resource_name: str, limit: int) -> None:
self.resource_name = resource_name
self.limit = limit
super().__init__(f'Maximum number of {resource_name} ({limit}) reached')
@dataclass(frozen=True, slots=True)
class WorkspaceQuota:
limit: int
requires_transaction_lock: bool
async def resolve_workspace_quota(
ap: Any,
workspace_uuid: str,
limit_name: str,
*,
fallback: int = -1,
) -> WorkspaceQuota:
"""Resolve a plan-agnostic Cloud limit while preserving OSS configuration."""
resolver = getattr(ap, 'entitlement_resolver', None)
if isinstance(resolver, EntitlementResolver):
snapshot = await resolver.resolve(workspace_uuid)
return WorkspaceQuota(
limit=snapshot.limit(limit_name),
requires_transaction_lock=True,
)
return WorkspaceQuota(limit=fallback, requires_transaction_lock=False)
async def lock_workspace_for_quota(execute: Execute, workspace_uuid: str) -> None:
"""Serialize quota checks on the durable Workspace row within one transaction."""
result = await execute(
sqlalchemy.select(persistence_workspace.Workspace.uuid)
.where(persistence_workspace.Workspace.uuid == workspace_uuid)
.with_for_update()
)
if result.first() is None:
raise ValueError('Workspace does not exist')
async def require_resource_capacity(
execute: Execute,
*,
workspace_uuid: str,
model: type,
quota: WorkspaceQuota,
resource_name: str,
workspace_locked: bool = False,
) -> None:
if quota.limit < 0:
return
if quota.requires_transaction_lock and not workspace_locked:
await lock_workspace_for_quota(execute, workspace_uuid)
result = await execute(
sqlalchemy.select(sqlalchemy.func.count())
.select_from(model)
.where(model.workspace_uuid == workspace_uuid)
)
if int(result.scalar_one()) >= quota.limit:
raise WorkspaceQuotaExceededError(resource_name, quota.limit)
+3 -8
View File
@@ -186,14 +186,9 @@ def _apply_env_overrides_to_config(cfg: dict) -> dict:
# At the final key
if key in current:
if isinstance(current[key], list):
# Convert comma-separated values while preserving the
# element type declared by a non-empty config default.
items = [item.strip() for item in env_value.split(',') if item.strip()]
if current[key]:
exemplar = current[key][0]
current[key] = [convert_value(item, exemplar) for item in items]
else:
current[key] = items
# Convert comma-separated string to list
# e.g., SYSTEM__DISABLED_ADAPTERS="aiocqhttp,dingtalk"
current[key] = [item.strip() for item in env_value.split(',') if item.strip()]
elif isinstance(current[key], dict):
# Skip dict types
pass
@@ -67,26 +67,3 @@ class DirectoryProjectionInbox(Base):
name='ck_directory_projection_inbox_fingerprint',
),
)
class SpaceLaunchAssertionConsumption(Base):
"""Durable, instance-scoped replay ledger for signed Space launch assertions."""
__tablename__ = 'space_launch_assertion_consumptions'
instance_uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
jti = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
expires_at = sqlalchemy.Column(sqlalchemy.DateTime(timezone=True), nullable=False)
consumed_at = sqlalchemy.Column(
sqlalchemy.DateTime(timezone=True),
nullable=False,
server_default=sqlalchemy.func.now(),
)
__table_args__ = (
sqlalchemy.Index(
'ix_space_launch_assertion_consumptions_expiry',
'instance_uuid',
'expires_at',
),
)
@@ -1,57 +0,0 @@
"""add durable replay protection for signed Space launch assertions
Revision ID: 0016_space_launch_replay
Revises: 0015_cloud_core_collab
Create Date: 2026-07-31
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = '0016_space_launch_replay'
down_revision = '0015_cloud_core_collab'
branch_labels = None
depends_on = None
_TABLE = 'space_launch_assertion_consumptions'
_POLICY = 'langbot_directory_projection'
_SETTING = "NULLIF(current_setting('langbot.directory_instance_uuid', true), '')"
def upgrade() -> None:
conn = op.get_bind()
if _TABLE not in set(sa.inspect(conn).get_table_names()):
op.create_table(
_TABLE,
sa.Column('instance_uuid', sa.String(255), nullable=False),
sa.Column('jti', sa.String(255), nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('consumed_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint('instance_uuid', 'jti'),
)
op.create_index(
'ix_space_launch_assertion_consumptions_expiry',
_TABLE,
['instance_uuid', 'expires_at'],
unique=False,
)
if conn.dialect.name == 'postgresql':
table = conn.dialect.identifier_preparer.quote(_TABLE)
policy = conn.dialect.identifier_preparer.quote(_POLICY)
expression = f'instance_uuid::text = {_SETTING}'
op.execute(sa.text(f'ALTER TABLE {table} ENABLE ROW LEVEL SECURITY'))
op.execute(sa.text(f'ALTER TABLE {table} FORCE ROW LEVEL SECURITY'))
op.execute(sa.text(f'DROP POLICY IF EXISTS {policy} ON {table}'))
op.execute(
sa.text(
f'CREATE POLICY {policy} ON {table} AS PERMISSIVE FOR ALL TO PUBLIC '
f'USING ({expression}) WITH CHECK ({expression})'
)
)
def downgrade() -> None:
if _TABLE in set(sa.inspect(op.get_bind()).get_table_names()):
op.drop_table(_TABLE)
@@ -75,7 +75,6 @@ TENANT_TABLE_COLUMNS: dict[str, str] = {
DIRECTORY_PROJECTION_TABLE_COLUMNS: dict[str, str] = {
'directory_projection_states': 'instance_uuid',
'directory_projection_inbox': 'instance_uuid',
'space_launch_assertion_consumptions': 'instance_uuid',
}
DIRECTORY_PROJECTED_TENANT_TABLES = frozenset(
@@ -1,12 +1,10 @@
from __future__ import annotations
import typing
import sqlalchemy
from .. import handler
from ... import entities
from ... import plugin_diagnostics
from ....entity.persistence.bot import BotAdmin
import langbot_plugin.api.entities.builtin.provider.message as provider_message
import langbot_plugin.api.entities.builtin.provider.session as provider_session
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
@@ -26,14 +24,7 @@ class CommandHandler(handler.MessageHandler):
privilege = 1
admins = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(BotAdmin).where(
BotAdmin.bot_uuid == (query.bot_uuid or ''),
BotAdmin.launcher_type == query.launcher_type.value,
BotAdmin.launcher_id == str(query.launcher_id),
)
)
if admins.first() is not None:
if f'{query.launcher_type.value}_{query.launcher_id}' in self.ap.instance_config.data['admins']:
privilege = 2
spt = command_text.split(' ')
@@ -241,11 +241,7 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert
async def process_message_data(msg_data, reply_list):
if msg_data['type'] == 'image':
image_base64, image_format = await image.qq_image_url_to_base64(msg_data['data']['url'])
reply_list.append(
platform_message.Image(
url=msg_data['data']['url'], base64=f'data:image/{image_format};base64,{image_base64}'
)
)
reply_list.append(platform_message.Image(base64=f'data:image/{image_format};base64,{image_base64}'))
elif msg_data['type'] == 'text':
reply_list.append(platform_message.Plain(text=msg_data['data']['text']))
@@ -290,9 +286,7 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert
image_msg = platform_message.Face(face_id=face_id, face_name=face_name)
else:
image_base64, image_format = await image.qq_image_url_to_base64(msg.data['url'])
image_msg = platform_message.Image(
url=msg.data['url'], base64=f'data:image/{image_format};base64,{image_base64}'
)
image_msg = platform_message.Image(base64=f'data:image/{image_format};base64,{image_base64}')
yiri_msg_list.append(image_msg)
elif msg.type == 'forward':
# 暂时不太合理
+1 -3
View File
@@ -764,9 +764,7 @@ class DiscordMessageConverter(abstract_platform_adapter.AbstractMessageConverter
)
image_base64 = (await asyncio.to_thread(base64.b64encode, image_data)).decode('utf-8')
image_format = response.headers['Content-Type']
element_list.append(
platform_message.Image(url=attachment.url, base64=f'data:{image_format};base64,{image_base64}')
)
element_list.append(platform_message.Image(base64=f'data:{image_format};base64,{image_base64}'))
return platform_message.MessageChain(element_list)
@@ -102,7 +102,7 @@ class QQOfficialMessageConverter(abstract_platform_adapter.AbstractMessageConver
yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now()))
if pic_url is not None:
base64_url = await image.get_qq_official_image_base64(pic_url=pic_url, content_type=content_type)
yiri_msg_list.append(platform_message.Image(url=pic_url, base64=base64_url))
yiri_msg_list.append(platform_message.Image(base64=base64_url))
yiri_msg_list.append(platform_message.Plain(text=message))
chain = platform_message.MessageChain(yiri_msg_list)
+1 -1
View File
@@ -47,7 +47,7 @@ class SlackMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now()))
if pic_url is not None:
base64_url = await image.get_slack_image_to_base64(pic_url=pic_url, bot_token=bot.bot_token)
yiri_msg_list.append(platform_message.Image(url=pic_url, base64=base64_url))
yiri_msg_list.append(platform_message.Image(base64=base64_url))
yiri_msg_list.append(platform_message.Plain(text=message))
chain = platform_message.MessageChain(yiri_msg_list)
+1 -4
View File
@@ -181,10 +181,7 @@ class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverte
encoded = await asyncio.to_thread(base64.b64encode, file_bytes)
message_components.append(
platform_message.Image(
url=file.file_path,
base64=f'data:{file_format};base64,{encoded.decode("utf-8")}',
)
platform_message.Image(base64=f'data:{file_format};base64,{encoded.decode("utf-8")}')
)
if message.voice:
+1 -3
View File
@@ -133,9 +133,7 @@ class WecomMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
yiri_msg_list = []
yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now()))
image_base64, image_format = await image.get_wecom_image_base64(pic_url=picurl)
yiri_msg_list.append(
platform_message.Image(url=picurl, base64=f'data:image/{image_format};base64,{image_base64}')
)
yiri_msg_list.append(platform_message.Image(base64=f'data:image/{image_format};base64,{image_base64}'))
chain = platform_message.MessageChain(yiri_msg_list)
return chain
-22
View File
@@ -19,11 +19,6 @@ from urllib.parse import urljoin, urlparse
from langbot_plugin.api.entities.builtin.pipeline.query import provider_session
from ..core import app
from ..cloud.quotas import (
lock_workspace_for_quota,
require_resource_capacity,
resolve_workspace_quota,
)
from . import handler
from .archive import inspect_plugin_archive_metadata
from .github import (
@@ -1300,11 +1295,6 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
install_info: dict[str, Any],
artifact_digest: str,
) -> tuple[InstallationBinding, str | None, bool]:
quota = await resolve_workspace_quota(
self.ap,
execution_context.workspace_uuid,
'plugins.max',
)
safe_install_info = {
key: value
for key, value in install_info.items()
@@ -1326,19 +1316,9 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
)
async def persist(execute):
if quota.requires_transaction_lock:
await lock_workspace_for_quota(execute, execution_context.workspace_uuid)
result = await execute(statement)
setting = result.first()
if setting is None:
await require_resource_capacity(
execute,
workspace_uuid=execution_context.workspace_uuid,
model=persistence_plugin.PluginSetting,
quota=quota,
resource_name='plugins',
workspace_locked=quota.requires_transaction_lock,
)
installation_uuid = str(uuid.uuid4())
runtime_revision = 1
previous_digest = None
@@ -1393,8 +1373,6 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
)
tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
if quota.requires_transaction_lock and not callable(tenant_uow):
raise RuntimeError('Cloud plugin quota enforcement requires transactional persistence')
if callable(tenant_uow):
async with tenant_uow(execution_context.workspace_uuid) as uow:
return await persist(uow.execute)
@@ -529,7 +529,6 @@ class ModelManager:
model['uuid']: model
for model in await self.ap.embedding_models_service.get_embedding_models(context, include_secret=True)
}
existing_rerank_models = {m['uuid']: m for m in await self.ap.rerank_models_service.get_rerank_models(context)}
created = 0
updated = 0
@@ -598,36 +597,6 @@ class ModelManager:
)
updated += 1
elif space_model.category == 'rerank':
existing = existing_rerank_models.get(space_model.uuid)
if existing is None:
await self.ap.rerank_models_service.create_rerank_model(
context,
{
'uuid': space_model.uuid,
'name': space_model.model_id,
'provider_uuid': space_model_provider.uuid,
'extra_args': {},
'prefered_ranking': space_model.featured_order,
},
preserve_uuid=True,
)
created += 1
elif existing.get('provider_uuid') == space_model_provider.uuid:
desired = {
'name': space_model.model_id,
'provider_uuid': space_model_provider.uuid,
'prefered_ranking': space_model.featured_order,
}
if (
existing.get('name') != desired['name']
or existing.get('prefered_ranking') != desired['prefered_ranking']
):
await self.ap.rerank_models_service.update_rerank_model(
context, space_model.uuid, dict(desired)
)
updated += 1
if created or updated:
self.ap.logger.info(f'Synced models from LangBot Space: {created} added, {updated} updated.')
@@ -944,21 +944,16 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
if api_key:
headers['Authorization'] = f'Bearer {api_key}'
request_args = dict(extra_args)
rerank_url = request_args.pop('rerank_url', None)
rerank_path = request_args.pop('rerank_path', 'rerank')
payload: dict[str, typing.Any] = {
'model': model_name,
'query': query,
'documents': documents,
'top_n': top_n,
}
if request_args:
payload.update(request_args)
if extra_args:
payload.update(extra_args)
if not rerank_url:
rerank_url = f'{base_url}/{str(rerank_path).strip("/")}'
rerank_url = f'{base_url}/rerank'
try:
async with httpx.AsyncClient(
-45
View File
@@ -52,47 +52,6 @@ async def _count(
return -1
async def _cloud_workspace_resource_counts(ap: core_app.Application) -> list[dict]:
"""Summarize already-loaded Cloud registries without per-tenant SQL."""
persistence_mgr = ap.persistence_mgr
if getattr(getattr(persistence_mgr, 'mode', None), 'value', None) != 'cloud_runtime':
return []
bindings = await ap.workspace_service.list_active_execution_bindings()
counts = {
binding.workspace_uuid: {
'workspace_uuid': binding.workspace_uuid,
'bot_count': 0,
'pipeline_count': 0,
'knowledge_base_count': 0,
'plugin_count': 0,
'mcp_server_count': 0,
'extension_count': 0,
}
for binding in bindings
}
for key in getattr(ap.platform_mgr, '_bots_by_key', {}):
if len(key) >= 2 and key[1] in counts:
counts[key[1]]['bot_count'] += 1
for key in getattr(ap.pipeline_mgr, '_pipelines_by_key', {}):
if len(key) >= 2 and key[1] in counts:
counts[key[1]]['pipeline_count'] += 1
for key in getattr(ap.rag_mgr, 'knowledge_bases', {}):
if len(key) >= 1 and key[0] in counts:
counts[key[0]]['knowledge_base_count'] += 1
for key in getattr(ap.tool_mgr.mcp_tool_loader, '_sessions', {}):
if len(key) >= 2 and key[1] in counts:
counts[key[1]]['mcp_server_count'] += 1
for workspace_uuid, installations in getattr(ap.plugin_connector, '_workspace_installations', {}).items():
if workspace_uuid in counts:
counts[workspace_uuid]['plugin_count'] = len(installations)
for resource in counts.values():
resource['extension_count'] = resource['plugin_count'] + resource['mcp_server_count']
return list(counts.values())
async def build_heartbeat_payload(ap: core_app.Application) -> dict:
"""Collect the anonymous instance profile snapshot."""
from ..entity.persistence import bot as persistence_bot
@@ -177,10 +136,6 @@ async def build_heartbeat_payload(ap: core_app.Application) -> dict:
except Exception:
pass
workspace_resources = await _cloud_workspace_resource_counts(ap)
if workspace_resources:
features['workspace_resources'] = workspace_resources
return {
'event_type': 'instance_heartbeat',
'query_id': '',
+5 -5
View File
@@ -499,14 +499,14 @@ class WorkspaceCollaborationService:
return await self._run(operation, session=session)
@asynccontextmanager
async def _invitation_lock(self, lock_key: str):
"""Serialize one token within workspace scope while retaining only active lock entries."""
async def _invitation_lock(self, token_digest: str):
"""Serialize one token while retaining only active lock entries."""
async with self._invitation_locks_guard:
entry = self._invitation_locks.get(lock_key)
entry = self._invitation_locks.get(token_digest)
if entry is None:
entry = _InvitationLockEntry(lock=asyncio.Lock())
self._invitation_locks[lock_key] = entry
self._invitation_locks[token_digest] = entry
entry.users += 1
await entry.lock.acquire()
@@ -517,7 +517,7 @@ class WorkspaceCollaborationService:
async with self._invitation_locks_guard:
entry.users -= 1
if entry.users == 0:
self._invitation_locks.pop(lock_key, None)
self._invitation_locks.pop(token_digest, None)
async def revoke_invitation(
self,
-1
View File
@@ -105,7 +105,6 @@ system:
max_bots: -1
max_pipelines: -1
max_extensions: -1
max_knowledge_bases: -1
# When set to a non-empty string, every pipeline is forced to use this
# Box sandbox-scope template regardless of its own configuration, and
# the per-pipeline "Sandbox Scope" selector is locked in the web UI.
@@ -1,79 +0,0 @@
"""Skills API behavior when a workspace plan has no managed sandbox."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
import quart
from langbot.pkg.api.http.controller.groups.skills import SkillsRouterGroup
from langbot.pkg.cloud.entitlements import (
EntitlementFeatureUnavailableError,
EntitlementUnavailableError,
)
pytestmark = pytest.mark.integration
WORKSPACE_UUID = '11111111-1111-4111-8111-111111111111'
@pytest.fixture
async def skills_api():
account = SimpleNamespace(uuid='owner-account', user='owner@example.com')
access = SimpleNamespace(
workspace=SimpleNamespace(uuid=WORKSPACE_UUID),
membership=SimpleNamespace(uuid='member-owner', role='owner', projection_revision=1),
execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=1),
)
application = Mock()
application.deployment = SimpleNamespace(multi_workspace_enabled=False)
application.persistence_mgr = SimpleNamespace(tenant_uow=None)
application.user_service.get_authenticated_account = AsyncMock(return_value=account)
application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(return_value=access)
application.skill_service.list_skills = AsyncMock(
side_effect=EntitlementFeatureUnavailableError(
'managed_sandbox',
entitlement_revision=1,
)
)
quart_app = quart.Quart(__name__)
router = SkillsRouterGroup(application, quart_app)
await router.initialize()
return application, quart_app.test_client()
@pytest.mark.asyncio
async def test_list_skills_is_empty_when_plan_has_no_managed_sandbox(skills_api):
application, client = skills_api
response = await client.get(
'/api/v1/skills',
headers={
'Authorization': 'Bearer owner-token',
'X-Workspace-Id': WORKSPACE_UUID,
},
)
assert response.status_code == 200
payload = await response.get_json()
assert payload['data'] == {'skills': []}
application.skill_service.list_skills.assert_awaited_once()
@pytest.mark.asyncio
async def test_list_skills_does_not_hide_other_entitlement_failures(skills_api):
application, client = skills_api
application.skill_service.list_skills.side_effect = EntitlementUnavailableError(
'Workspace entitlement revision rolled back'
)
response = await client.get(
'/api/v1/skills',
headers={
'Authorization': 'Bearer owner-token',
'X-Workspace-Id': WORKSPACE_UUID,
},
)
assert response.status_code == 500
@@ -1,137 +0,0 @@
"""PostgreSQL integration coverage for durable workspace quota locking.
Run with TEST_POSTGRES_URL=postgresql+asyncpg://... pytest ...
"""
from __future__ import annotations
import asyncio
import os
import uuid
import pytest
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from langbot.pkg.cloud import quotas as quota_module
from langbot.pkg.cloud.quotas import WorkspaceQuota, WorkspaceQuotaExceededError, require_resource_capacity
pytestmark = [pytest.mark.integration, pytest.mark.slow, pytest.mark.asyncio]
class _Base(DeclarativeBase):
pass
class _Workspace(_Base):
__tablename__ = 'quota_integration_workspaces'
uuid: Mapped[str] = mapped_column(sa.String(36), primary_key=True)
class _Resource(_Base):
__tablename__ = 'quota_integration_resources'
uuid: Mapped[str] = mapped_column(sa.String(36), primary_key=True)
workspace_uuid: Mapped[str] = mapped_column(
sa.String(36),
sa.ForeignKey('quota_integration_workspaces.uuid', ondelete='CASCADE'),
nullable=False,
index=True,
)
@pytest.fixture
async def quota_postgres(monkeypatch):
url = os.environ.get('TEST_POSTGRES_URL')
if not url:
pytest.skip('TEST_POSTGRES_URL not set')
if url.startswith('postgresql://'):
url = url.replace('postgresql://', 'postgresql+asyncpg://', 1)
engine = create_async_engine(url, pool_size=5, max_overflow=0)
monkeypatch.setattr(quota_module.persistence_workspace, 'Workspace', _Workspace)
async with engine.begin() as connection:
await connection.run_sync(_Base.metadata.drop_all)
await connection.run_sync(_Base.metadata.create_all)
try:
yield url, engine
finally:
async with engine.begin() as connection:
await connection.run_sync(_Base.metadata.drop_all)
await engine.dispose()
async def test_workspace_row_lock_is_atomic_isolated_and_survives_pool_restart(quota_postgres) -> None:
url, engine = quota_postgres
workspace_a = str(uuid.uuid4())
workspace_b = str(uuid.uuid4())
quota = WorkspaceQuota(limit=1, requires_transaction_lock=True)
sessions = async_sessionmaker(engine, expire_on_commit=False)
async with engine.begin() as connection:
await connection.execute(sa.insert(_Workspace), [{'uuid': workspace_a}, {'uuid': workspace_b}])
lock_acquired = asyncio.Event()
release_first = asyncio.Event()
async def admit(workspace_uuid: str, *, hold: bool = False) -> None:
async with sessions() as session:
async with session.begin():
await require_resource_capacity(
session.execute,
workspace_uuid=workspace_uuid,
model=_Resource,
quota=quota,
resource_name='resources',
)
if hold:
lock_acquired.set()
await release_first.wait()
await session.execute(
sa.insert(_Resource).values(uuid=str(uuid.uuid4()), workspace_uuid=workspace_uuid)
)
first = asyncio.create_task(admit(workspace_a, hold=True))
await asyncio.wait_for(lock_acquired.wait(), timeout=2)
same_workspace = asyncio.create_task(admit(workspace_a))
other_workspace = asyncio.create_task(admit(workspace_b))
await asyncio.wait_for(other_workspace, timeout=2)
assert not same_workspace.done(), 'same-workspace transaction bypassed SELECT FOR UPDATE'
release_first.set()
await first
with pytest.raises(WorkspaceQuotaExceededError, match=r'Maximum number of resources \(1\) reached'):
await same_workspace
async with sessions() as session:
counts = dict(
(
await session.execute(
sa.select(_Resource.workspace_uuid, sa.func.count())
.group_by(_Resource.workspace_uuid)
.order_by(_Resource.workspace_uuid)
)
).all()
)
assert counts == {workspace_a: 1, workspace_b: 1}
await engine.dispose()
restarted_engine = create_async_engine(url, pool_size=2, max_overflow=0)
restarted_sessions = async_sessionmaker(restarted_engine, expire_on_commit=False)
try:
async with restarted_sessions() as session:
async with session.begin():
with pytest.raises(WorkspaceQuotaExceededError):
await require_resource_capacity(
session.execute,
workspace_uuid=workspace_a,
model=_Resource,
quota=quota,
resource_name='resources',
)
finally:
await restarted_engine.dispose()
@@ -9,7 +9,6 @@ import quart
from langbot.pkg.api.http.controller import group
from langbot.pkg.api.http.controller.groups.webhooks import WebhookRouterGroup
from langbot.pkg.cloud.quotas import WorkspaceQuotaExceededError
from langbot.pkg.utils.bounded_executor import (
BlockingWorkCapacityError,
current_blocking_work_scope,
@@ -49,16 +48,6 @@ class _BlockingCapacityRouterGroup(group.RouterGroup):
raise BlockingWorkCapacityError('Workspace blocking executor capacity reached')
class _QuotaRouterGroup(group.RouterGroup):
name = 'quota-test'
path = '/quota-test'
async def initialize(self) -> None:
@self.route('', methods=['POST'], auth_type=group.AuthType.NONE)
async def _():
raise WorkspaceQuotaExceededError('bots', 2)
class _InvalidAccountRouterGroup(group.RouterGroup):
name = 'invalid-account-test'
path = '/invalid-account-test'
@@ -141,20 +130,6 @@ async def test_blocking_work_capacity_maps_to_retryable_http_response():
}
async def test_workspace_quota_maps_to_stable_conflict_response():
application = SimpleNamespace(logger=Mock())
quart_app = quart.Quart(__name__)
await _QuotaRouterGroup(application, quart_app).initialize()
response = await quart_app.test_client().post('/quota-test')
assert response.status_code == 409
assert await response.get_json() == {
'code': 'workspace_quota_exceeded',
'msg': 'Maximum number of bots (2) reached',
}
async def test_public_webhook_carries_scope_without_holding_database_session():
class ScopeOnlyPersistenceManager:
mode = SimpleNamespace(value='cloud_runtime')
@@ -311,9 +311,10 @@ class TestBotServiceCreateBot:
ap.platform_mgr = SimpleNamespace()
ap.platform_mgr.load_bot = AsyncMock()
# Mock the atomic count query to report 2 existing bots.
mock_result = _create_mock_result()
mock_result.scalar_one = Mock(return_value=2)
# Mock get_bots to return 2 bots already
bot1 = _create_mock_bot(bot_uuid='uuid-1')
bot2 = _create_mock_bot(bot_uuid='uuid-2')
mock_result = _create_mock_result([bot1, bot2])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'uuid-1', 'name': 'Bot 1'})
@@ -1,129 +0,0 @@
from __future__ import annotations
import asyncio
from collections import defaultdict
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
import sqlalchemy
from langbot.pkg.api.http.service.bot import BotService
from langbot.pkg.cloud.entitlements import EntitlementResolver, EntitlementSnapshot
INSTANCE_UUID = 'cloud-instance'
WORKSPACE_A = '11111111-1111-1111-1111-111111111111'
WORKSPACE_B = '22222222-2222-2222-2222-222222222222'
class _Provider:
async def get_workspace_entitlement(self, workspace_uuid: str) -> EntitlementSnapshot:
return EntitlementSnapshot(
instance_uuid=INSTANCE_UUID,
workspace_uuid=workspace_uuid,
entitlement_revision=1,
status='active',
not_before=0,
expires_at=4_102_444_800,
features={},
limits={'bots.max': 2},
)
class _Result:
def __init__(self, *, first=None, scalar=None) -> None:
self._first = first
self._scalar = scalar
def first(self):
return self._first
def scalar_one(self):
return self._scalar
class _TenantUow:
def __init__(self, manager: '_Persistence', workspace_uuid: str) -> None:
self.manager = manager
self.workspace_uuid = workspace_uuid
self.lock = manager.locks[workspace_uuid]
async def __aenter__(self):
await self.lock.acquire()
return self
async def __aexit__(self, exc_type, exc, tb):
self.lock.release()
async def execute(self, statement):
sql = str(statement)
if isinstance(statement, sqlalchemy.sql.dml.Insert):
assert statement.table.name == 'bots'
self.manager.bots[self.workspace_uuid].append(statement.compile().params)
return _Result()
if 'FROM workspaces' in sql:
assert statement._for_update_arg is not None
self.manager.workspace_locks_seen += 1
return _Result(first=(self.workspace_uuid,))
if 'count(' in sql.lower() and 'FROM bots' in sql:
return _Result(scalar=len(self.manager.bots[self.workspace_uuid]))
if 'FROM legacy_pipelines' in sql:
return _Result(first=None)
raise AssertionError(f'unexpected statement: {sql}')
class _Persistence:
def __init__(self) -> None:
self.locks = defaultdict(asyncio.Lock)
self.bots = defaultdict(list)
self.workspace_locks_seen = 0
def tenant_uow(self, workspace_uuid: str) -> _TenantUow:
return _TenantUow(self, workspace_uuid)
async def execute_async(self, statement):
assert 'FROM legacy_pipelines' in str(statement)
return _Result(first=None)
async def _service(manager: _Persistence) -> BotService:
resolver = EntitlementResolver(INSTANCE_UUID, _Provider())
await resolver.reconcile_active_workspaces({WORKSPACE_A, WORKSPACE_B})
ap = SimpleNamespace(
entitlement_resolver=resolver,
persistence_mgr=manager,
instance_config=SimpleNamespace(data={'system': {'limitation': {'max_bots': 99}}}),
platform_mgr=SimpleNamespace(load_bot=AsyncMock()),
)
service = BotService(ap)
service.get_bot = AsyncMock(return_value={'uuid': 'created'})
return service
@pytest.mark.asyncio
async def test_cloud_bot_quota_is_atomic_isolated_and_persists_across_service_restart() -> None:
manager = _Persistence()
service = await _service(manager)
async def create(workspace_uuid: str, index: int):
return await service.create_bot(workspace_uuid, {'name': f'bot-{index}'})
results = await asyncio.gather(
*(create(WORKSPACE_A, index) for index in range(8)),
*(create(WORKSPACE_B, index) for index in range(8)),
return_exceptions=True,
)
successes = [result for result in results if isinstance(result, str)]
failures = [result for result in results if isinstance(result, ValueError)]
assert len(successes) == 4
assert len(failures) == 12
assert len(manager.bots[WORKSPACE_A]) == 2
assert len(manager.bots[WORKSPACE_B]) == 2
assert manager.workspace_locks_seen == 16
restarted_service = await _service(manager)
with pytest.raises(ValueError, match=r'Maximum number of bots \(2\) reached'):
await restarted_service.create_bot(WORKSPACE_A, {'name': 'after-restart'})
assert len(manager.bots[WORKSPACE_A]) == 2
@@ -34,7 +34,6 @@ class _Rows:
def _app():
return SimpleNamespace(
logger=Mock(),
instance_config=SimpleNamespace(data={}),
rag_mgr=SimpleNamespace(
get_all_knowledge_base_details=AsyncMock(return_value=[]),
get_knowledge_base_details=AsyncMock(return_value=None),
@@ -120,21 +119,6 @@ async def test_create_validates_schema_and_binds_context():
)
@pytest.mark.asyncio
async def test_create_enforces_workspace_knowledge_base_limit():
app = _app()
app.instance_config.data = {'system': {'limitation': {'max_knowledge_bases': 2}}}
app.rag_mgr.get_all_knowledge_base_details.return_value = [{'uuid': 'kb-a'}, {'uuid': 'kb-b'}]
service = KnowledgeService(app)
with pytest.raises(ValueError, match=r'Maximum number of knowledge bases \(2\) reached'):
await service.create_knowledge_base(
CONTEXT,
{'knowledge_engine_plugin_id': 'author/engine'},
)
app.rag_mgr.create_knowledge_base.assert_not_awaited()
@pytest.mark.asyncio
async def test_update_rejects_guessed_uuid_and_scopes_reload():
app = _app()
@@ -833,44 +833,6 @@ class TestModelProviderServiceScanProviderModels:
assert len(result['models']) == 1
assert result['models'][0]['type'] == 'llm'
async def test_scan_provider_marks_existing_rerank_model(self):
"""Rerank scan results use the rerank service when computing already_added."""
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.model_mgr = SimpleNamespace()
ap.llm_model_service = SimpleNamespace()
ap.embedding_models_service = SimpleNamespace()
ap.rerank_models_service = SimpleNamespace()
provider = _create_mock_provider(provider_uuid='rerank-scan-uuid')
ap.persistence_mgr.execute_async = AsyncMock(return_value=_create_mock_result([], first_item=provider))
ap.persistence_mgr.serialize_model = Mock(
return_value={
'uuid': 'rerank-scan-uuid',
'name': 'New API',
'requester': 'new-api-chat-completions',
'base_url': 'https://new-api.example.com/v1',
'api_keys': ['key'],
}
)
runtime_provider = Mock()
runtime_provider.token_mgr.get_token.return_value = 'token'
runtime_provider.requester.scan_models = AsyncMock(
return_value={'models': [{'id': 'Qwen3-Reranker-8B', 'type': 'rerank'}]}
)
ap.model_mgr.load_provider = AsyncMock(return_value=runtime_provider)
ap.llm_model_service.get_llm_models_by_provider = AsyncMock(return_value=[])
ap.embedding_models_service.get_embedding_models_by_provider = AsyncMock(return_value=[])
ap.rerank_models_service.get_rerank_models_by_provider = AsyncMock(return_value=[{'name': 'Qwen3-Reranker-8B'}])
result = await ModelProviderService(ap).scan_provider_models(
WORKSPACE_UUID, 'rerank-scan-uuid', model_type='rerank'
)
assert result['models'][0]['type'] == 'rerank'
assert result['models'][0]['already_added'] is True
async def test_scan_provider_not_implemented_raises_error(self):
"""Raises ValueError when scan not implemented."""
# Setup
@@ -756,7 +756,7 @@ class TestSpaceServiceGetModels:
'uuid': 'uuid-2',
'model_id': 'model-2',
'provider': 'provider-2',
'category': 'rerank',
'category': 'chat',
'status': 'active',
},
]
@@ -778,7 +778,6 @@ class TestSpaceServiceGetModels:
# Verify
assert len(result) == 2
assert result[1].category == 'rerank'
async def test_get_models_api_error(self):
"""Raises ValueError on API error."""
@@ -46,20 +46,6 @@ def plugin_router_cls():
yield PluginsRouterGroup
@pytest.mark.asyncio
async def test_authenticated_plugin_resource_fences_injected_workspace_context(plugin_router_cls):
connector = SimpleNamespace(
require_workspace_context=AsyncMock(return_value=CONTEXT),
)
router = object.__new__(plugin_router_cls)
router.ap = SimpleNamespace(plugin_connector=connector)
result = await router._require_authenticated_plugin_runtime_context(CONTEXT)
assert result == CONTEXT
connector.require_workspace_context.assert_awaited_once_with(CONTEXT)
@pytest.mark.asyncio
async def test_public_plugin_asset_route_is_disabled_for_multi_workspace_policy(plugin_router_cls):
connector = SimpleNamespace(
+1 -14
View File
@@ -228,23 +228,10 @@ async def test_cloud_pgvector_contract_is_fail_closed(pgvector_config, message):
)
async def test_cloud_runtime_allows_explicitly_disabled_box():
config = _cloud_config()
config['box']['enabled'] = False
deployment = await resolve_deployment(
instance_uuid='instance-a',
instance_config=config,
entry_points=lambda: _EntryPoints([_EntryPoint(_Provider())]),
now=1_000,
)
assert isinstance(deployment, VerifiedCloudDeployment)
@pytest.mark.parametrize(
('mutate', 'message'),
[
(lambda config: config['box'].update(enabled=False), 'box.enabled=true'),
(lambda config: config['box'].update(backend='docker'), 'box.backend=nsjail'),
(lambda config: config['box']['runtime'].update(endpoint=''), 'box.runtime.endpoint'),
(
+1 -34
View File
@@ -48,7 +48,6 @@ def _claims(*, now: int, jti: str | None = None, workspace_uuid: str = WORKSPACE
'payload': {
'account_uuid': ACCOUNT_UUID,
'workspace_uuid': workspace_uuid,
'return_path': '/',
},
}
@@ -82,27 +81,7 @@ async def test_consumes_valid_workspace_launch_assertion_once():
launch = await service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID)
assert launch == {
'account_uuid': ACCOUNT_UUID,
'workspace_uuid': WORKSPACE_UUID,
'return_path': '/',
}
with pytest.raises(SpaceLaunchError, match='already been consumed'):
await service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID)
async def test_consumed_assertion_remains_blocked_through_clock_skew_window():
private_key = Ed25519PrivateKey.generate()
now = int(time.time())
service = _service(private_key, now=now)
claims = _claims(now=now)
claims['iat'] = now - 10
claims['nbf'] = now - 10
claims['exp'] = now - 1
token = _sign(private_key, claims)
await service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID)
assert launch == {'account_uuid': ACCOUNT_UUID, 'workspace_uuid': WORKSPACE_UUID}
with pytest.raises(SpaceLaunchError, match='already been consumed'):
await service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID)
@@ -183,15 +162,3 @@ async def test_rejects_invalid_signature_and_non_cloud_mode():
oss_service.ap.deployment.multi_workspace_enabled = False
with pytest.raises(SpaceLaunchError, match='verified Cloud mode'):
await oss_service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID)
@pytest.mark.asyncio
async def test_rejects_unsafe_signed_return_path() -> None:
private_key = Ed25519PrivateKey.generate()
now = int(time.time())
service = _service(private_key, now=now)
claims = _claims(now=now)
claims['payload']['return_path'] = '//evil.example'
with pytest.raises(SpaceLaunchError, match='return path'):
await service.consume_assertion(_sign(private_key, claims))
@@ -1,102 +0,0 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
import sqlalchemy
from langbot.pkg.cloud.entitlements import EntitlementResolver, EntitlementSnapshot
from langbot.pkg.cloud.quotas import (
WorkspaceQuota,
require_resource_capacity,
resolve_workspace_quota,
)
from langbot.pkg.entity.persistence.bot import Bot
WORKSPACE_UUID = '11111111-1111-1111-1111-111111111111'
INSTANCE_UUID = 'cloud-instance'
class _Provider:
def __init__(self, limits: dict[str, int]) -> None:
self.limits = limits
async def get_workspace_entitlement(self, workspace_uuid: str) -> EntitlementSnapshot:
return EntitlementSnapshot(
instance_uuid=INSTANCE_UUID,
workspace_uuid=workspace_uuid,
entitlement_revision=1,
status='active',
not_before=0,
expires_at=4_102_444_800,
features={},
limits=self.limits,
plan_name='test',
)
async def _resolver(limits: dict[str, int]) -> EntitlementResolver:
resolver = EntitlementResolver(INSTANCE_UUID, _Provider(limits))
await resolver.reconcile_active_workspaces({WORKSPACE_UUID})
return resolver
@pytest.mark.asyncio
async def test_resolve_workspace_quota_uses_signed_cloud_limit() -> None:
ap = SimpleNamespace(entitlement_resolver=await _resolver({'bots.max': 2}))
quota = await resolve_workspace_quota(ap, WORKSPACE_UUID, 'bots.max', fallback=99)
assert quota == WorkspaceQuota(limit=2, requires_transaction_lock=True)
@pytest.mark.asyncio
async def test_resolve_workspace_quota_preserves_oss_fallback() -> None:
quota = await resolve_workspace_quota(SimpleNamespace(), WORKSPACE_UUID, 'bots.max', fallback=7)
assert quota == WorkspaceQuota(limit=7, requires_transaction_lock=False)
@pytest.mark.asyncio
async def test_require_resource_capacity_locks_workspace_before_counting() -> None:
statements: list[object] = []
lock_result = Mock()
lock_result.first.return_value = (WORKSPACE_UUID,)
count_result = Mock()
count_result.scalar_one.return_value = 1
execute = AsyncMock(side_effect=[lock_result, count_result])
await require_resource_capacity(
execute,
workspace_uuid=WORKSPACE_UUID,
model=Bot,
quota=WorkspaceQuota(limit=2, requires_transaction_lock=True),
resource_name='bots',
)
statements.extend(call.args[0] for call in execute.await_args_list)
assert len(statements) == 2
assert isinstance(statements[0], sqlalchemy.sql.Select)
assert statements[0]._for_update_arg is not None
assert 'workspaces' in str(statements[0])
assert 'count' in str(statements[1]).lower()
@pytest.mark.asyncio
async def test_require_resource_capacity_rejects_at_boundary() -> None:
lock_result = Mock()
lock_result.first.return_value = (WORKSPACE_UUID,)
count_result = Mock()
count_result.scalar_one.return_value = 2
execute = AsyncMock(side_effect=[lock_result, count_result])
with pytest.raises(ValueError, match=r'Maximum number of bots \(2\) reached'):
await require_resource_capacity(
execute,
workspace_uuid=WORKSPACE_UUID,
model=Bot,
quota=WorkspaceQuota(limit=2, requires_transaction_lock=True),
resource_name='bots',
)
-13
View File
@@ -152,19 +152,6 @@ class TestApplyEnvOverridesToConfig:
assert result['system']['disabled_adapters'] == ['aiocqhttp', 'dingtalk', 'telegram']
def test_override_integer_list_preserves_item_type(self):
"""Comma-separated overrides inherit the existing list item type."""
load_config = get_load_config_module()
cfg = {'vdb': {'pgvector': {'allowed_dimensions': [384, 512]}}}
env = {'VDB__PGVECTOR__ALLOWED_DIMENSIONS': '384,512,768'}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
assert result['vdb']['pgvector']['allowed_dimensions'] == [384, 512, 768]
assert all(isinstance(item, int) for item in result['vdb']['pgvector']['allowed_dimensions'])
def test_override_list_value_empty_items(self):
"""Test that empty items in comma-separated list are filtered."""
load_config = get_load_config_module()
@@ -158,21 +158,17 @@ class TestCommandHandlerReal:
@pytest.mark.asyncio
async def test_admin_privilege_check(self, fake_app, mock_event_ctx, mock_execute_factory):
"""A per-bot admin from the database is marked as admin in command events."""
"""Admin users get privilege level 2."""
from langbot_plugin.api.entities.builtin.provider.session import LauncherTypes
command = get_command_handler()
admin_result = Mock()
admin_result.first.return_value = Mock()
fake_app.persistence_mgr.execute_async = AsyncMock(return_value=admin_result)
fake_app.instance_config.data = {}
fake_app.instance_config.data = {'admins': ['person_12345']}
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
fake_app.cmd_mgr.execute = mock_execute_factory()
handler = command.CommandHandler(fake_app)
query = command_query('status')
query.bot_uuid = 'bot-1'
query.launcher_type = LauncherTypes.PERSON
query.launcher_id = 12345
@@ -180,28 +176,23 @@ class TestCommandHandlerReal:
async for result in handler.handle(query):
results.append(result)
fake_app.persistence_mgr.execute_async.assert_awaited_once()
call_args = fake_app.plugin_connector.emit_event.call_args
event = call_args[0][0]
assert event.is_admin is True
@pytest.mark.asyncio
async def test_non_admin_privilege_check(self, fake_app, mock_event_ctx, mock_execute_factory):
"""A launcher absent from the per-bot admin table is not an admin."""
"""Non-admin users get privilege level 1."""
from langbot_plugin.api.entities.builtin.provider.session import LauncherTypes
command = get_command_handler()
admin_result = Mock()
admin_result.first.return_value = None
fake_app.persistence_mgr.execute_async = AsyncMock(return_value=admin_result)
fake_app.instance_config.data = {}
fake_app.instance_config.data = {'admins': ['person_12345']}
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
fake_app.cmd_mgr.execute = mock_execute_factory()
handler = command.CommandHandler(fake_app)
query = command_query('status')
query.bot_uuid = 'bot-1'
query.launcher_type = LauncherTypes.PERSON
query.launcher_id = 67890
@@ -209,7 +200,6 @@ class TestCommandHandlerReal:
async for result in handler.handle(query):
results.append(result)
fake_app.persistence_mgr.execute_async.assert_awaited_once()
call_args = fake_app.plugin_connector.emit_event.call_args
event = call_args[0][0]
assert event.is_admin is False
@@ -1,191 +0,0 @@
from __future__ import annotations
import asyncio
from collections import defaultdict
from types import SimpleNamespace
import pytest
import sqlalchemy
from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.cloud.entitlements import EntitlementResolver, EntitlementSnapshot
from langbot.pkg.cloud.quotas import WorkspaceQuotaExceededError
from langbot.pkg.plugin.connector import PluginRuntimeConnector
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
INSTANCE_UUID = 'cloud-instance'
WORKSPACE_A = '11111111-1111-1111-1111-111111111111'
WORKSPACE_B = '22222222-2222-2222-2222-222222222222'
class _Provider:
async def get_workspace_entitlement(self, workspace_uuid: str) -> EntitlementSnapshot:
return EntitlementSnapshot(
instance_uuid=INSTANCE_UUID,
workspace_uuid=workspace_uuid,
entitlement_revision=1,
status='active',
not_before=0,
expires_at=4_102_444_800,
features={},
limits={'plugins.max': 3},
)
class _Result:
def __init__(self, *, first=None, scalar=None) -> None:
self._first = first
self._scalar = scalar
def first(self):
return self._first
def scalar_one(self):
return self._scalar
class _TenantUow:
def __init__(self, manager: '_Persistence', workspace_uuid: str) -> None:
self.manager = manager
self.workspace_uuid = workspace_uuid
self.lock = manager.locks[workspace_uuid]
async def __aenter__(self):
await self.lock.acquire()
return self
async def __aexit__(self, exc_type, exc, tb):
self.lock.release()
async def execute(self, statement):
sql = str(statement)
params = statement.compile().params
if isinstance(statement, sqlalchemy.sql.dml.Insert):
assert statement.table.name == 'plugin_settings'
key = (params['plugin_author'], params['plugin_name'])
self.manager.plugins[self.workspace_uuid][key] = dict(params)
return _Result()
if isinstance(statement, sqlalchemy.sql.dml.Update):
return _Result()
if 'FROM workspaces' in sql:
assert statement._for_update_arg is not None
self.manager.workspace_locks_seen += 1
return _Result(first=(self.workspace_uuid,))
if 'count(' in sql.lower() and 'FROM plugin_settings' in sql:
return _Result(scalar=len(self.manager.plugins[self.workspace_uuid]))
if 'FROM plugin_settings' in sql:
author = next(value for name, value in params.items() if 'plugin_author' in name)
name = next(value for param, value in params.items() if 'plugin_name' in param)
row = self.manager.plugins[self.workspace_uuid].get((author, name))
if row is None:
return _Result(first=None)
return _Result(
first=SimpleNamespace(
installation_uuid=row['installation_uuid'],
runtime_revision=row['runtime_revision'],
artifact_digest=row['artifact_digest'],
install_info=row['install_info'],
)
)
raise AssertionError(f'unexpected statement: {sql}')
class _Persistence:
def __init__(self) -> None:
self.locks = defaultdict(asyncio.Lock)
self.plugins = defaultdict(dict)
self.workspace_locks_seen = 0
def tenant_uow(self, workspace_uuid: str) -> _TenantUow:
return _TenantUow(self, workspace_uuid)
async def _connector(manager: _Persistence) -> PluginRuntimeConnector:
resolver = EntitlementResolver(INSTANCE_UUID, _Provider())
await resolver.reconcile_active_workspaces({WORKSPACE_A, WORKSPACE_B})
connector = object.__new__(PluginRuntimeConnector)
connector.ap = SimpleNamespace(entitlement_resolver=resolver, persistence_mgr=manager)
return connector
def _context(workspace_uuid: str) -> ExecutionContext:
return ExecutionContext(
instance_uuid=INSTANCE_UUID,
workspace_uuid=workspace_uuid,
placement_generation=1,
entitlement_revision=1,
)
@pytest.mark.asyncio
async def test_cloud_plugin_quota_is_atomic_isolated_and_persists_across_connector_restart() -> None:
manager = _Persistence()
connector = await _connector(manager)
async def install(workspace_uuid: str, index: int):
return await connector._persist_installation_package(
_context(workspace_uuid),
plugin_author='test-author',
plugin_name=f'plugin-{index}',
install_source=PluginInstallSource.MARKETPLACE,
install_info={'author': 'test-author', 'name': f'plugin-{index}'},
artifact_digest=f'{index:064x}',
)
results = await asyncio.gather(
*(install(WORKSPACE_A, index) for index in range(10)),
*(install(WORKSPACE_B, index) for index in range(10)),
return_exceptions=True,
)
successes = [result for result in results if isinstance(result, tuple)]
failures = [result for result in results if isinstance(result, WorkspaceQuotaExceededError)]
assert len(successes) == 6
assert len(failures) == 14
assert len(manager.plugins[WORKSPACE_A]) == 3
assert len(manager.plugins[WORKSPACE_B]) == 3
assert manager.workspace_locks_seen == 20
restarted_connector = await _connector(manager)
with pytest.raises(WorkspaceQuotaExceededError, match=r'Maximum number of plugins \(3\) reached'):
await restarted_connector._persist_installation_package(
_context(WORKSPACE_A),
plugin_author='test-author',
plugin_name='after-restart',
install_source=PluginInstallSource.MARKETPLACE,
install_info={},
artifact_digest='f' * 64,
)
assert len(manager.plugins[WORKSPACE_A]) == 3
installed_name = next(iter(manager.plugins[WORKSPACE_A]))[1]
async def reinstall():
return await restarted_connector._persist_installation_package(
_context(WORKSPACE_A),
plugin_author='test-author',
plugin_name=installed_name,
install_source=PluginInstallSource.MARKETPLACE,
install_info={'author': 'test-author', 'name': installed_name, 'revision': 2},
artifact_digest='e' * 64,
)
reinstall_results = await asyncio.gather(reinstall(), reinstall())
assert all(result[2] is True for result in reinstall_results)
mixed_results = await asyncio.gather(
reinstall(),
restarted_connector._persist_installation_package(
_context(WORKSPACE_A),
plugin_author='test-author',
plugin_name='new-at-capacity',
install_source=PluginInstallSource.MARKETPLACE,
install_info={},
artifact_digest='d' * 64,
),
return_exceptions=True,
)
assert isinstance(mixed_results[0], tuple)
assert isinstance(mixed_results[1], WorkspaceQuotaExceededError)
assert len(manager.plugins[WORKSPACE_A]) == 3
@@ -1147,46 +1147,6 @@ class TestInvokeRerank:
assert results[0]['relevance_score'] == 1.0
assert results[1]['relevance_score'] == 0.0
@pytest.mark.asyncio
@pytest.mark.parametrize(
('model_extra_args', 'expected_url'),
[
({'rerank_path': 'reranks'}, 'https://gateway.example.com/v1/reranks'),
({'rerank_url': 'https://rerank.example.com/api/rerank'}, 'https://rerank.example.com/api/rerank'),
],
)
async def test_invoke_rerank_openai_compatible_endpoint_override(self, model_extra_args, expected_url):
"""Endpoint configuration controls routing and is not sent in the Cohere body."""
requester = litellmchat.LiteLLMRequester(
ap=Mock(),
config={
'base_url': 'https://gateway.example.com/v1/',
'custom_llm_provider': 'openai',
},
)
model = MockRuntimeRerankModel('Qwen3-Reranker-8B', 'test-api-key')
model.model_entity.extra_args = model_extra_args
mock_resp = Mock()
mock_resp.raise_for_status = Mock()
mock_resp.json = Mock(return_value={'results': [{'index': 0, 'relevance_score': 0.8}]})
mock_client = AsyncMock()
mock_client.post = AsyncMock(return_value=mock_resp)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
with patch('httpx.AsyncClient', return_value=mock_client):
await requester.invoke_rerank(model=model, query='query', documents=['document'])
assert mock_client.post.call_args.args[0] == expected_url
payload = mock_client.post.call_args.kwargs['json']
assert payload == {
'model': 'Qwen3-Reranker-8B',
'query': 'query',
'documents': ['document'],
'top_n': 1,
}
class TestConvertMessages:
"""Test _convert_messages method"""
@@ -86,49 +86,6 @@ async def test_model_manager_skips_legacy_space_sync_in_cloud_runtime(mock_app_f
app.workspace_service.get_local_execution_binding.assert_not_awaited()
@pytest.mark.asyncio
async def test_sync_new_models_from_space_creates_rerank_models(mock_app_for_modelmgr):
"""Space rerank entries are discovered and persisted under the shared provider."""
app = mock_app_for_modelmgr
provider = persistence_model.ModelProvider(
uuid='space-provider',
name='LangBot Space',
requester='space-chat-completions',
base_url='https://api.langbot.cloud/v1',
api_keys=['space-key'],
)
app.persistence_mgr.execute_async = AsyncMock(return_value=_make_mock_result([provider], first_item=provider))
app.space_service.get_models = AsyncMock(
return_value=[
SimpleNamespace(
uuid='rerank-model-uuid',
model_id='Qwen3-Reranker-8B',
category='rerank',
featured_order=10,
)
]
)
app.llm_model_service.get_llm_models = AsyncMock(return_value=[])
app.embedding_models_service.get_embedding_models = AsyncMock(return_value=[])
app.rerank_models_service = AsyncMock()
app.rerank_models_service.get_rerank_models = AsyncMock(return_value=[])
model_mgr = ModelManager(app)
await model_mgr.sync_new_models_from_space(TEST_EXECUTION_CONTEXT)
app.rerank_models_service.create_rerank_model.assert_awaited_once_with(
TEST_EXECUTION_CONTEXT,
{
'uuid': 'rerank-model-uuid',
'name': 'Qwen3-Reranker-8B',
'provider_uuid': 'space-provider',
'extra_args': {},
'prefered_ranking': 10,
},
preserve_uuid=True,
)
# ============================================================================
# Model Loading Tests
# ============================================================================
+3 -30
View File
@@ -106,31 +106,15 @@ class TestBuildHeartbeatPayload:
side_effect=AssertionError('Cloud heartbeat must not issue per-tenant COUNTs')
)
ap.pipeline_mgr = SimpleNamespace(
_pipelines_by_key={
('instance-a', 'workspace-a', 'pipeline-a'): object(),
('instance-a', 'workspace-a', 'pipeline-b'): object(),
},
_pipelines_by_key={'pipeline-a': object(), 'pipeline-b': object()},
)
ap.platform_mgr._bots_by_key = {
('instance-a', 'workspace-a', 'bot-a'): object(),
}
ap.tool_mgr = SimpleNamespace(
mcp_tool_loader=SimpleNamespace(
_sessions={
('instance-a', 'workspace-a', 1, 'mcp-a'): object(),
('instance-a', 'workspace-a', 1, 'mcp-b'): object(),
('instance-a', 'workspace-a', 1, 'mcp-c'): object(),
},
_sessions={'mcp-a': object(), 'mcp-b': object(), 'mcp-c': object()},
),
)
ap.rag_mgr = SimpleNamespace(
knowledge_bases={('workspace-a', 'kb-a'): object()},
)
ap.plugin_connector._workspace_installations = {
'workspace-a': {'plugin-a', 'plugin-b'},
}
ap.workspace_service.list_active_execution_bindings = AsyncMock(
return_value=[SimpleNamespace(workspace_uuid='workspace-a')],
knowledge_bases={'kb-a': object()},
)
payload = await heartbeat.build_heartbeat_payload(ap)
@@ -140,17 +124,6 @@ class TestBuildHeartbeatPayload:
assert features['mcp_server_count'] == 3
assert features['knowledge_base_count'] == 1
assert features['bot_count'] == 1
assert features['workspace_resources'] == [
{
'workspace_uuid': 'workspace-a',
'bot_count': 1,
'pipeline_count': 2,
'knowledge_base_count': 1,
'plugin_count': 2,
'mcp_server_count': 3,
'extension_count': 5,
}
]
ap.persistence_mgr.execute_async.assert_not_awaited()
@pytest.mark.asyncio
+37
View File
@@ -8,6 +8,7 @@ import pytest
from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.api.http.service.skill import SkillService
from langbot.pkg.cloud.entitlements import EntitlementFeatureUnavailableError, EntitlementUnavailableError
_CONTEXT = ExecutionContext(
@@ -113,6 +114,42 @@ class TestRequireBoxForWrite:
service = SkillService(self._ap_with_disabled_box())
assert await service.list_skills(_CONTEXT) == []
@pytest.mark.asyncio
async def test_list_skills_returns_empty_when_managed_sandbox_is_not_granted(self):
box_service = SimpleNamespace(
available=True,
list_skills=AsyncMock(
side_effect=EntitlementFeatureUnavailableError(
'Workspace entitlement does not grant managed_sandbox',
feature='managed_sandbox',
)
),
)
service = SkillService(
SimpleNamespace(
workspace_service=_workspace_service(),
box_service=box_service,
)
)
assert await service.list_skills(_CONTEXT) == []
@pytest.mark.asyncio
async def test_list_skills_preserves_other_entitlement_failures(self):
box_service = SimpleNamespace(
available=True,
list_skills=AsyncMock(side_effect=EntitlementUnavailableError('control plane unavailable')),
)
service = SkillService(
SimpleNamespace(
workspace_service=_workspace_service(),
box_service=box_service,
)
)
with pytest.raises(EntitlementUnavailableError, match='control plane unavailable'):
await service.list_skills(_CONTEXT)
@pytest.mark.asyncio
async def test_read_skill_file_refused_when_box_unavailable(self):
service = SkillService(self._ap_with_disabled_box())
Generated
+3 -7
View File
@@ -2116,7 +2116,7 @@ requires-dist = [
{ name = "ebooklib", specifier = ">=0.18" },
{ name = "gewechat-client", specifier = ">=0.1.5" },
{ name = "html2text", specifier = ">=2024.2.26" },
{ name = "langbot-plugin", specifier = "==0.5.0" },
{ name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=1d65ed301a6afc52150a998043f73cd6032c8162" },
{ name = "langchain", specifier = ">=1.3.9" },
{ name = "langchain-core", specifier = ">=1.3.3" },
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
@@ -2182,8 +2182,8 @@ dev = [
[[package]]
name = "langbot-plugin"
version = "0.5.0"
source = { registry = "https://pypi.org/simple" }
version = "0.4.18"
source = { git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=1d65ed301a6afc52150a998043f73cd6032c8162#1d65ed301a6afc52150a998043f73cd6032c8162" }
dependencies = [
{ name = "aiofiles" },
{ name = "aiohttp" },
@@ -2203,10 +2203,6 @@ dependencies = [
{ name = "watchdog" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5c/09/697037dea617a235c9b3df85174badfb44886e3c67149a928049839a959a/langbot_plugin-0.5.0.tar.gz", hash = "sha256:9b81fa0f73cde1fe199a746e03ad891f8ced8f4fa0d05aeb6d78bfe7d755a976", size = 464033, upload-time = "2026-07-30T19:22:57.503Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/70/93e544c3a120953f4871266db9ef84ea9a58daf01d493fafa7c45674ad36/langbot_plugin-0.5.0-py3-none-any.whl", hash = "sha256:2b078db96d869de55d08304465974f51765e3cfe582ff79b32f09161292fb877", size = 300758, upload-time = "2026-07-30T19:22:56.248Z" },
]
[[package]]
name = "langchain"
+3 -35
View File
@@ -29,7 +29,6 @@ type SpaceOAuthLoginResult = {
token: string;
user: string;
workspace_uuid?: string;
return_path?: string;
};
const pendingSpaceOAuthLogins = new Map<
@@ -64,10 +63,6 @@ function SpaceOAuthCallbackContent() {
const [searchParams] = useSearchParams();
const { t } = useTranslation();
const isMountedRef = useRef(true);
const directLaunchFragmentRef = useRef<{
workspaceUuid: string | null;
launchAssertion: string | null;
} | null>(null);
const [status, setStatus] = useState<
'loading' | 'confirm' | 'success' | 'error'
@@ -111,13 +106,7 @@ function SpaceOAuthCallbackContent() {
throw new Error('No Workspace is available for this Account');
}
if (response.workspace_uuid) {
const returnPath =
typeof response.return_path === 'string' &&
response.return_path.startsWith('/') &&
!response.return_path.startsWith('//')
? response.return_path
: '/home';
navigate(returnPath, { replace: true });
navigate('/home', { replace: true });
return;
}
setStatus('success');
@@ -218,29 +207,8 @@ function SpaceOAuthCallbackContent() {
const errorDescription = searchParams.get('error_description');
const mode = searchParams.get('mode');
const state = searchParams.get('state');
if (directLaunchFragmentRef.current === null) {
const fragmentParams = new URLSearchParams(
window.location.hash.startsWith('#')
? window.location.hash.slice(1)
: window.location.hash,
);
directLaunchFragmentRef.current = {
workspaceUuid: fragmentParams.get('workspace_uuid'),
launchAssertion: fragmentParams.get('launch_assertion'),
};
if (window.location.hash) {
window.history.replaceState(
null,
'',
`${window.location.pathname}${window.location.search}`,
);
}
}
const workspaceUuid =
directLaunchFragmentRef.current.workspaceUuid ??
searchParams.get('workspace_uuid');
const launchAssertion =
directLaunchFragmentRef.current.launchAssertion;
const workspaceUuid = searchParams.get('workspace_uuid');
const launchAssertion = searchParams.get('launch_assertion');
if (error) {
setStatus('error');
@@ -166,19 +166,6 @@ export function SidebarDataProvider({
// Deduplicate plugins by composite key (prefer debug over installed)
const pluginMap = new Map<string, SidebarEntityItem>();
const pluginIconURLs = new Map<string, string>(
await Promise.all(
pluginsResp.plugins.map(async (plugin) => {
const meta = plugin.manifest.manifest.metadata;
const author = meta.author ?? '';
const name = meta.name;
const url = await httpClient
.getAuthenticatedPluginIconURL(author, name)
.catch(() => '');
return [`${author}/${name}`, url] as const;
}),
),
);
for (const plugin of pluginsResp.plugins) {
const meta = plugin.manifest.manifest.metadata;
const author = meta.author ?? '';
@@ -197,7 +184,7 @@ export function SidebarDataProvider({
const item: SidebarEntityItem = {
id: compositeKey,
name: extractI18nObject(meta.label),
iconURL: pluginIconURLs.get(compositeKey) || '',
iconURL: httpClient.getPluginIconURL(author, name),
installSource: plugin.install_source,
installInfo: plugin.install_info,
hasUpdate,
@@ -231,7 +218,7 @@ export function SidebarDataProvider({
pluginAuthor: author,
pluginName: name,
pluginLabel: label,
pluginIconURL: pluginIconURLs.get(`${author}/${name}`) || '',
pluginIconURL: httpClient.getPluginIconURL(author, name),
pageId: page.id,
path: page.path,
});
@@ -4,7 +4,6 @@ import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useTranslation } from 'react-i18next';
import { AuthenticatedPluginIcon } from '@/components/AuthenticatedPluginIcon';
import { Input } from '@/components/ui/input';
import EmojiPicker from '@/components/ui/emoji-picker';
import {
@@ -429,9 +428,12 @@ export default function KBForm({
);
return (
<div className="flex items-center gap-2">
<AuthenticatedPluginIcon
author={author}
name={name}
<img
src={httpClient.getPluginIconURL(
author,
name,
)}
alt=""
className="h-5 w-5 rounded"
/>
<span>
@@ -457,9 +459,12 @@ export default function KBForm({
value={engine.plugin_id}
>
<div className="flex items-center gap-2">
<AuthenticatedPluginIcon
author={author}
name={name}
<img
src={httpClient.getPluginIconURL(
author,
name,
)}
alt=""
className="h-5 w-5 rounded"
/>
<span>{extractI18nObject(engine.name)}</span>
@@ -13,7 +13,7 @@ import {
Puzzle,
} from 'lucide-react';
import { getCloudServiceClientSync, systemInfo } from '@/app/infra/http';
import { useAuthenticatedPluginIcon } from '@/hooks/useAuthenticatedPluginResource';
import { httpClient } from '@/app/infra/http/HttpClient';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import {
@@ -39,11 +39,6 @@ export default function ExtensionCardComponent({
const { t } = useTranslation();
const [dropdownOpen, setDropdownOpen] = useState(false);
const [iconFailed, setIconFailed] = useState(false);
const authenticatedIcon = useAuthenticatedPluginIcon(
cardVO.author,
cardVO.name,
cardVO.type === 'plugin',
);
const FallbackIcon =
cardVO.type === 'mcp'
@@ -52,8 +47,8 @@ export default function ExtensionCardComponent({
? Sparkles
: Puzzle;
const iconSrc =
cardVO.type === 'plugin' ? authenticatedIcon.url : cardVO.iconURL;
const showFallback = iconFailed || authenticatedIcon.error || !iconSrc;
cardVO.iconURL || httpClient.getPluginIconURL(cardVO.author, cardVO.name);
const showFallback = iconFailed || !iconSrc;
const getTypeLabel = (type: ExtensionType) => {
switch (type) {
@@ -10,74 +10,6 @@ import rehypeSlug from 'rehype-slug';
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
import { getAPILanguageCode } from '@/i18n/I18nProvider';
import '@/styles/github-markdown.css';
import { useAuthenticatedPluginAsset } from '@/hooks/useAuthenticatedPluginResource';
function AuthenticatedReadmeImage({
author,
name,
filepath,
alt,
...props
}: {
author: string;
name: string;
filepath: string;
alt?: string;
} & React.ImgHTMLAttributes<HTMLImageElement>) {
const { url, error } = useAuthenticatedPluginAsset(author, name, filepath);
if (error)
return (
<span className="text-sm text-muted-foreground">{alt || filepath}</span>
);
if (!url)
return (
<span className="inline-block h-6 w-24 animate-pulse rounded bg-muted" />
);
return (
<img
src={url}
alt={alt || ''}
className="max-w-lg h-auto my-4"
{...props}
/>
);
}
function PluginReadmeImage({
author,
name,
src,
alt,
...props
}: {
author: string;
name: string;
src?: string;
alt?: string;
} & React.ImgHTMLAttributes<HTMLImageElement>) {
const imageSrc = typeof src === 'string' ? src : '';
if (!imageSrc || /^(https?:\/\/|data:)/i.test(imageSrc)) {
return (
<img
src={imageSrc}
alt={alt || ''}
className="max-w-lg h-auto my-4"
{...props}
/>
);
}
let filepath = imageSrc.replace(/^(\.\/|\/)+/, '');
filepath = filepath.replace(/^assets\//, '');
return (
<AuthenticatedReadmeImage
author={author}
name={name}
filepath={filepath}
alt={alt}
{...props}
/>
);
}
export default function PluginReadme({
pluginAuthor,
@@ -139,15 +71,49 @@ export default function PluginReadme({
<ol className="list-decimal">{children}</ol>
),
li: ({ children }) => <li className="ml-4">{children}</li>,
img: ({ src, alt, ...props }) => (
<PluginReadmeImage
author={pluginAuthor}
name={pluginName}
src={typeof src === 'string' ? src : undefined}
alt={alt}
{...props}
/>
),
img: ({ src, alt, ...props }) => {
let imageSrc = src || '';
if (typeof imageSrc !== 'string') {
return (
<img
src={src}
alt={alt || ''}
className="max-w-full h-auto rounded-lg my-4"
{...props}
/>
);
}
if (
imageSrc &&
!imageSrc.startsWith('http://') &&
!imageSrc.startsWith('https://') &&
!imageSrc.startsWith('data:')
) {
imageSrc = imageSrc.replace(/^(\.\/|\/)+/, '');
if (!imageSrc.startsWith('assets/')) {
imageSrc = `assets/${imageSrc}`;
}
const assetPath = imageSrc.replace(/^assets\//, '');
imageSrc = httpClient.getPluginAssetURL(
pluginAuthor,
pluginName,
assetPath,
);
}
return (
<img
src={imageSrc}
alt={alt || ''}
className="max-w-lg h-auto my-4"
{...props}
/>
);
},
}}
>
{readme}
-26
View File
@@ -710,32 +710,6 @@ export class BackendClient extends BaseHttpClient {
);
}
private async getAuthenticatedObjectURL(path: string): Promise<string> {
const response = await this.instance.get<Blob>(path, {
responseType: 'blob',
});
return URL.createObjectURL(response.data);
}
public getAuthenticatedPluginAssetURL(
author: string,
name: string,
filepath: string,
): Promise<string> {
return this.getAuthenticatedObjectURL(
`/api/v1/plugins/${author}/${name}/authenticated-assets/${filepath}`,
);
}
public getAuthenticatedPluginIconURL(
author: string,
name: string,
): Promise<string> {
return this.getAuthenticatedObjectURL(
`/api/v1/plugins/${author}/${name}/authenticated-icon`,
);
}
public async pluginPageApi(
author: string,
name: string,
@@ -1,32 +0,0 @@
import { useAuthenticatedPluginIcon } from '@/hooks/useAuthenticatedPluginResource';
import { cn } from '@/lib/utils';
export function AuthenticatedPluginIcon({
author,
name,
alt = '',
className,
}: {
author: string;
name: string;
alt?: string;
className?: string;
}) {
const icon = useAuthenticatedPluginIcon(
author,
name,
Boolean(author && name),
);
if (!icon.url || icon.error) {
return (
<span
aria-hidden={alt ? undefined : true}
aria-label={alt || undefined}
className={cn('inline-block bg-muted', className)}
/>
);
}
return <img src={icon.url} alt={alt} className={className} />;
}
@@ -1,71 +0,0 @@
import { useEffect, useState } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
export function useAuthenticatedPluginIcon(
author: string,
name: string,
enabled = true,
): { url: string; error: boolean } {
const [url, setURL] = useState('');
const [error, setError] = useState(false);
useEffect(() => {
if (!enabled) {
setURL('');
setError(false);
return;
}
let active = true;
let objectURL = '';
setURL('');
setError(false);
httpClient
.getAuthenticatedPluginIconURL(author, name)
.then((nextURL) => {
objectURL = nextURL;
if (active) setURL(nextURL);
else URL.revokeObjectURL(nextURL);
})
.catch(() => {
if (active) setError(true);
});
return () => {
active = false;
if (objectURL) URL.revokeObjectURL(objectURL);
};
}, [author, enabled, name]);
return { url, error };
}
export function useAuthenticatedPluginAsset(
author: string,
name: string,
filepath: string,
): { url: string; error: boolean } {
const [url, setURL] = useState('');
const [error, setError] = useState(false);
useEffect(() => {
let active = true;
let objectURL = '';
setURL('');
setError(false);
httpClient
.getAuthenticatedPluginAssetURL(author, name, filepath)
.then((nextURL) => {
objectURL = nextURL;
if (active) setURL(nextURL);
else URL.revokeObjectURL(nextURL);
})
.catch(() => {
if (active) setError(true);
});
return () => {
active = false;
if (objectURL) URL.revokeObjectURL(objectURL);
};
}, [author, name, filepath]);
return { url, error };
}
@@ -1,24 +0,0 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
const source = fs.readFileSync(
new URL('../../src/app/auth/space/callback/page.tsx', import.meta.url),
'utf8',
);
test('direct launch assertion is fragment-only and removed before exchange', () => {
assert.doesNotMatch(source, /searchParams\.get\(['"]launch_assertion['"]\)/);
const readIndex = source.indexOf("fragmentParams.get('launch_assertion')");
const clearIndex = source.indexOf('window.history.replaceState');
const exchangeIndex = source.indexOf('handleOAuthCallback(', clearIndex);
assert.ok(readIndex >= 0, 'fragment assertion read is missing');
assert.ok(clearIndex > readIndex, 'URL fragment is not cleared after copying the assertion');
assert.ok(exchangeIndex > clearIndex, 'assertion exchange starts before the fragment is cleared');
});
test('direct launch honors only a local signed return path', () => {
assert.match(source, /response\.return_path\.startsWith\(['"]\/['"]\)/);
assert.match(source, /!response\.return_path\.startsWith\(['"]\/\/['"]\)/);
assert.match(source, /navigate\(returnPath, \{ replace: true \}\)/);
});