mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-08 20:30:59 +00:00
Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8737b818b6 | |||
| 4b2a628db6 | |||
| 610915b9c5 | |||
| e8d90c4259 | |||
| 2dfbe78271 | |||
| c89e6f3bd2 | |||
| e52d6880f5 | |||
| aa342d9347 | |||
| ae85ac2b16 | |||
| 32abbb636f | |||
| f247a9d183 | |||
| 624a197655 | |||
| 712f79ed77 | |||
| 602e10649b | |||
| d71bd571b1 | |||
| e90a1546de | |||
| ff068564ab | |||
| 94ff4fcd2d | |||
| 97b3e58884 | |||
| 66a1ceac25 | |||
| 8a445bfb22 | |||
| 276791e7af | |||
| baf7e86335 | |||
| 741c20af07 | |||
| 5ac1ab3eac | |||
| f96116a050 | |||
| 40abb03928 | |||
| 59f68b8fb4 | |||
| 7c64cd9d51 | |||
| 9ea1a81048 | |||
| d3f08a90b1 | |||
| 64e772e32d | |||
| 84440df47f | |||
| c860159446 | |||
| f977629a90 | |||
| ff13d52602 | |||
| 5beab49577 | |||
| e8a09b7537 | |||
| 98f45aa88e | |||
| d7cdd206c2 | |||
| ac72563664 | |||
| 64dc887b20 | |||
| 627eb6b8ef | |||
| 3f01ffe63b | |||
| d7adbeec1e | |||
| abf77cecfa | |||
| 270622ae9d | |||
| 30f414a534 | |||
| 8b7ce77cec | |||
| 37099ddf7e | |||
| ee59e2d3fd | |||
| a4550350c0 |
@@ -1,59 +0,0 @@
|
|||||||
name: Build and deploy production
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [deploy/prod]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: langbot-production
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
env:
|
|
||||||
CORE_IMAGE: ${{ secrets.DOCKER_USERNAME }}/langbot
|
|
||||||
CLOUD_IMAGE: ${{ secrets.DOCKER_USERNAME }}/langbot-cloud-core
|
|
||||||
SPACE_REF: 58253c53933f95d81b035fbe2efedb55b6c1a82b
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
environment: production
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: docker/setup-buildx-action@v3
|
|
||||||
- uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
|
||||||
- name: Build exact Core image
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
${{ env.CORE_IMAGE }}:prod-${{ github.sha }}
|
|
||||||
${{ env.CORE_IMAGE }}:deploy-prod
|
|
||||||
cache-from: type=gha,scope=core-prod
|
|
||||||
cache-to: type=gha,mode=max,scope=core-prod
|
|
||||||
- name: Checkout production Cloud adapter
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
repository: langbot-app/langbot-space
|
|
||||||
ref: ${{ env.SPACE_REF }}
|
|
||||||
token: ${{ secrets.CLA_PAT }}
|
|
||||||
path: .space
|
|
||||||
- name: Build exact Cloud Core image
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .space
|
|
||||||
file: .space/Dockerfile.cloud
|
|
||||||
push: true
|
|
||||||
build-args: LANGBOT_CORE_IMAGE=${{ env.CORE_IMAGE }}:prod-${{ github.sha }}
|
|
||||||
tags: |
|
|
||||||
${{ env.CLOUD_IMAGE }}:prod-${{ github.sha }}
|
|
||||||
${{ env.CLOUD_IMAGE }}:deploy-prod
|
|
||||||
cache-from: type=gha,scope=cloud-core-prod
|
|
||||||
cache-to: type=gha,mode=max,scope=cloud-core-prod
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -Eeuo pipefail
|
|
||||||
|
|
||||||
cd /opt/langbot-cloud-prod
|
|
||||||
TAG=${1:?usage: deploy.sh prod-<40-char-sha>}
|
|
||||||
[[ "$TAG" =~ ^prod-[0-9a-f]{40}$ ]] || { echo 'invalid immutable image tag' >&2; exit 2; }
|
|
||||||
[[ -s .env ]] || { echo '/opt/langbot-cloud-prod/.env is missing' >&2; exit 3; }
|
|
||||||
|
|
||||||
rendered_compose=$(docker compose config)
|
|
||||||
grep -Fq 'LANGBOT_SPACE_CONTROL_PLANE_URL: https://space.langbot.app' <<<"$rendered_compose" || {
|
|
||||||
echo 'Cloud control-plane URL must be https://space.langbot.app' >&2
|
|
||||||
exit 4
|
|
||||||
}
|
|
||||||
grep -Fq 'SPACE__URL: https://space.langbot.app' <<<"$rendered_compose" || {
|
|
||||||
echo 'Cloud user-facing Space URL must be https://space.langbot.app' >&2
|
|
||||||
exit 5
|
|
||||||
}
|
|
||||||
grep -Eq 'LANGBOT_TELEMETRY_INGEST_TOKEN: .+' <<<"$rendered_compose" || {
|
|
||||||
echo 'Cloud telemetry ingest token must be configured' >&2
|
|
||||||
exit 6
|
|
||||||
}
|
|
||||||
|
|
||||||
update_env() {
|
|
||||||
local key=$1 value=$2
|
|
||||||
python3 - "$key" "$value" <<'PY'
|
|
||||||
from pathlib import Path
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
path = Path('.env')
|
|
||||||
key, value = sys.argv[1:]
|
|
||||||
lines = path.read_text().splitlines()
|
|
||||||
updated = False
|
|
||||||
for index, line in enumerate(lines):
|
|
||||||
if line.startswith(f'{key}='):
|
|
||||||
lines[index] = f'{key}={value}'
|
|
||||||
updated = True
|
|
||||||
break
|
|
||||||
if not updated:
|
|
||||||
lines.append(f'{key}={value}')
|
|
||||||
temporary = Path('.env.tmp')
|
|
||||||
temporary.write_text('\n'.join(lines) + '\n')
|
|
||||||
os.chmod(temporary, 0o600)
|
|
||||||
temporary.replace(path)
|
|
||||||
PY
|
|
||||||
}
|
|
||||||
update_env LANGBOT_IMAGE_TAG "$TAG"
|
|
||||||
set -a
|
|
||||||
. ./.env
|
|
||||||
set +a
|
|
||||||
: "${CLOUD_V2_CONTROL_PLANE_TOKEN:?CLOUD_V2_CONTROL_PLANE_TOKEN is required}"
|
|
||||||
|
|
||||||
for attempt in 1 2 3 4 5; do
|
|
||||||
if docker compose pull postgres redis migrate plugin-runtime core; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
if [ "$attempt" -eq 5 ]; then
|
|
||||||
echo "docker compose pull failed after $attempt attempts" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
delay=$((attempt * 10))
|
|
||||||
echo "docker compose pull failed (attempt $attempt/5); retrying in ${delay}s" >&2
|
|
||||||
sleep "$delay"
|
|
||||||
done
|
|
||||||
docker compose up -d postgres redis
|
|
||||||
for _ in $(seq 1 60); do
|
|
||||||
if docker compose exec -T postgres pg_isready -U langbot_operator -d langbot >/dev/null 2>&1; then break; fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
docker compose exec -T postgres pg_isready -U langbot_operator -d langbot >/dev/null
|
|
||||||
|
|
||||||
docker compose exec -T postgres psql -v ON_ERROR_STOP=1 -U langbot_operator -d langbot \
|
|
||||||
-v runtime_password="$POSTGRES_RUNTIME_PASSWORD" <<'SQL'
|
|
||||||
SELECT format('CREATE ROLE langbot_runtime LOGIN PASSWORD %L', :'runtime_password')
|
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'langbot_runtime')\gexec
|
|
||||||
ALTER ROLE langbot_runtime PASSWORD :'runtime_password';
|
|
||||||
GRANT CONNECT ON DATABASE langbot TO langbot_runtime;
|
|
||||||
REVOKE CREATE ON SCHEMA public FROM PUBLIC, langbot_runtime;
|
|
||||||
REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM langbot_runtime;
|
|
||||||
REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public FROM langbot_runtime;
|
|
||||||
ALTER DEFAULT PRIVILEGES FOR ROLE langbot_operator IN SCHEMA public REVOKE ALL ON TABLES FROM langbot_runtime;
|
|
||||||
ALTER DEFAULT PRIVILEGES FOR ROLE langbot_operator IN SCHEMA public REVOKE ALL ON SEQUENCES FROM langbot_runtime;
|
|
||||||
GRANT USAGE ON SCHEMA public TO langbot_runtime;
|
|
||||||
SQL
|
|
||||||
|
|
||||||
docker compose --profile tools run --rm migrate
|
|
||||||
|
|
||||||
docker compose up -d --remove-orphans plugin-runtime core
|
|
||||||
for _ in $(seq 1 90); do
|
|
||||||
if docker compose exec -T core python -c 'import urllib.request; urllib.request.urlopen("http://127.0.0.1:5300/healthz", timeout=3)' >/dev/null 2>&1; then
|
|
||||||
docker compose ps
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
docker compose logs --tail=200 core plugin-runtime >&2
|
|
||||||
exit 1
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: pgvector/pgvector:pg17
|
|
||||||
container_name: langbot-cloud-postgres
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
POSTGRES_DB: langbot
|
|
||||||
POSTGRES_USER: langbot_operator
|
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_OPERATOR_PASSWORD}
|
|
||||||
volumes:
|
|
||||||
- postgres-data:/var/lib/postgresql/data
|
|
||||||
healthcheck:
|
|
||||||
test: [CMD-SHELL, "pg_isready -U langbot_operator -d langbot"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 30
|
|
||||||
networks: [internal]
|
|
||||||
|
|
||||||
redis:
|
|
||||||
image: redis:7.4-alpine
|
|
||||||
container_name: langbot-cloud-redis
|
|
||||||
restart: unless-stopped
|
|
||||||
command: [redis-server, --appendonly, "yes", --requirepass, "${REDIS_PASSWORD}"]
|
|
||||||
volumes:
|
|
||||||
- redis-data:/data
|
|
||||||
healthcheck:
|
|
||||||
test: [CMD-SHELL, "redis-cli -a \"$${REDIS_PASSWORD}\" ping | grep PONG"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 20
|
|
||||||
environment:
|
|
||||||
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
|
||||||
networks: [internal]
|
|
||||||
|
|
||||||
migrate:
|
|
||||||
image: rockchin/langbot-cloud-core:${LANGBOT_IMAGE_TAG}
|
|
||||||
profiles: [tools]
|
|
||||||
command: [uv, run, langbot, migrate, --cloud]
|
|
||||||
environment: &core-env
|
|
||||||
TZ: Asia/Shanghai
|
|
||||||
SYSTEM__INSTANCE_ID: ${CLOUD_V2_INSTANCE_UUID}
|
|
||||||
SYSTEM__EDITION: cloud
|
|
||||||
SYSTEM__RECOVERY_KEY: ${SYSTEM_RECOVERY_KEY}
|
|
||||||
SYSTEM__JWT__SECRET: ${JWT_SECRET}
|
|
||||||
SYSTEM__LIMITATION__MAX_BOTS: "2"
|
|
||||||
SYSTEM__LIMITATION__MAX_PIPELINES: "3"
|
|
||||||
SYSTEM__LIMITATION__MAX_EXTENSIONS: "3"
|
|
||||||
SYSTEM__LIMITATION__MAX_KNOWLEDGE_BASES: "2"
|
|
||||||
API__WEBHOOK_PREFIX: https://cloud.langbot.app
|
|
||||||
API__WEBUI_URL: https://cloud.langbot.app
|
|
||||||
WORKSPACE__INVITATIONS__PUBLIC_WEB_URL: https://cloud.langbot.app
|
|
||||||
DATABASE__USE: postgresql
|
|
||||||
DATABASE__POSTGRESQL__URL: postgresql+asyncpg://langbot_runtime:${POSTGRES_RUNTIME_PASSWORD}@postgres:5432/langbot
|
|
||||||
DATABASE__CLOUD_MIGRATION__OPERATOR_DSN_ENV: LANGBOT_CLOUD_MIGRATION_DSN
|
|
||||||
LANGBOT_CLOUD_MIGRATION_DSN: postgresql://langbot_operator:${POSTGRES_OPERATOR_PASSWORD}@postgres:5432/langbot
|
|
||||||
VDB__USE: pgvector
|
|
||||||
VDB__PGVECTOR__USE_BUSINESS_DATABASE: "true"
|
|
||||||
VDB__PGVECTOR__ALLOWED_DIMENSIONS: "384,512,768,1024,1536"
|
|
||||||
PLUGIN__ENABLE: "true"
|
|
||||||
PLUGIN__RUNTIME_WS_URL: ws://plugin-runtime:5400/control/ws
|
|
||||||
PLUGIN__DISPLAY_PLUGIN_DEBUG_URL: wss://cloud.langbot.app/plugin/debug/ws
|
|
||||||
PLUGIN__WORKER__MAX_CPUS: "0.25"
|
|
||||||
PLUGIN__WORKER__MAX_MEMORY_MB: "256"
|
|
||||||
PLUGIN__WORKER__MAX_PIDS: "128"
|
|
||||||
PLUGIN__WORKER__MAX_WORKERS: "16"
|
|
||||||
PLUGIN__WORKER__MAX_TOTAL_CPUS: "4.0"
|
|
||||||
PLUGIN__WORKER__MAX_TOTAL_MEMORY_MB: "4096"
|
|
||||||
PLUGIN__WORKER__REQUIRE_HARD_LIMITS: "true"
|
|
||||||
LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN: ${PLUGIN_RUNTIME_CONTROL_TOKEN}
|
|
||||||
# Cloud v2 currently grants no managed Box capability. Keep the shared
|
|
||||||
# runtime deployed but disable Core integration until a hard-quota-capable
|
|
||||||
# backend can satisfy the fail-closed Cloud readiness contract.
|
|
||||||
BOX__ENABLED: "false"
|
|
||||||
BOX__BACKEND: nsjail
|
|
||||||
BOX__RUNTIME__ENDPOINT: ws://box:5410
|
|
||||||
BOX__ADMISSION__REQUIRED: "true"
|
|
||||||
BOX__ADMISSION__LOGICAL_SESSION_ID: global
|
|
||||||
BOX__ADMISSION__REQUIRED_BACKEND: nsjail
|
|
||||||
BOX__ADMISSION__MAX_SESSIONS: "1"
|
|
||||||
BOX__ADMISSION__MAX_MANAGED_PROCESSES: "0"
|
|
||||||
BOX__ADMISSION__CPUS: "0.25"
|
|
||||||
BOX__ADMISSION__MEMORY_MB: "256"
|
|
||||||
BOX__ADMISSION__WORKSPACE_QUOTA_MB: "256"
|
|
||||||
BOX__LOCAL__HOST_ROOT: /app/data/box
|
|
||||||
BOX__LOCAL__DEFAULT_WORKSPACE: /app/data/box
|
|
||||||
BOX__LOCAL__ALLOWED_MOUNT_ROOTS: /app/data/box
|
|
||||||
LANGBOT_BOX_CONTROL_TOKEN: ${BOX_CONTROL_TOKEN}
|
|
||||||
MCP__STDIO__ENABLED: "false"
|
|
||||||
LANGBOT_SPACE_CONTROL_PLANE_URL: https://space.langbot.app
|
|
||||||
LANGBOT_SPACE_CONTROL_PLANE_TOKEN: ${CLOUD_V2_CONTROL_PLANE_TOKEN}
|
|
||||||
LANGBOT_TELEMETRY_INGEST_TOKEN: ${CLOUD_V2_CONTROL_PLANE_TOKEN}
|
|
||||||
LANGBOT_SPACE_CONTROL_PLANE_PUBLIC_KEY: ${CLOUD_V2_MANIFEST_PUBLIC_KEY}
|
|
||||||
LANGBOT_SPACE_CONTROL_PLANE_KEY_ID: ${CLOUD_V2_MANIFEST_KEY_ID}
|
|
||||||
SPACE__URL: https://space.langbot.app
|
|
||||||
depends_on:
|
|
||||||
postgres: {condition: service_healthy}
|
|
||||||
networks: [internal]
|
|
||||||
|
|
||||||
plugin-runtime:
|
|
||||||
image: rockchin/langbot:${LANGBOT_IMAGE_TAG}
|
|
||||||
container_name: langbot-cloud-plugin-runtime
|
|
||||||
restart: unless-stopped
|
|
||||||
command: [uv, run, python, -m, langbot_plugin.cli.__init__, rt]
|
|
||||||
environment:
|
|
||||||
LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN: ${PLUGIN_RUNTIME_CONTROL_TOKEN}
|
|
||||||
volumes:
|
|
||||||
- plugin-data:/app/data
|
|
||||||
- /sys/fs/cgroup:/sys/fs/cgroup:rw
|
|
||||||
cgroup: host
|
|
||||||
privileged: true
|
|
||||||
expose: ["5400"]
|
|
||||||
networks: [internal]
|
|
||||||
|
|
||||||
box:
|
|
||||||
image: rockchin/langbot:${LANGBOT_IMAGE_TAG}
|
|
||||||
container_name: langbot-cloud-box
|
|
||||||
restart: unless-stopped
|
|
||||||
command: [uv, run, lbp, box, --host, 0.0.0.0, --ws-control-port, "5410"]
|
|
||||||
environment:
|
|
||||||
LANGBOT_BOX_CONTROL_TOKEN: ${BOX_CONTROL_TOKEN}
|
|
||||||
LANGBOT_BOX_ROOT: /app/data/box
|
|
||||||
volumes:
|
|
||||||
- box-data:/app/data/box
|
|
||||||
- /sys/fs/cgroup:/sys/fs/cgroup:rw
|
|
||||||
cgroup: host
|
|
||||||
privileged: true
|
|
||||||
expose: ["5410"]
|
|
||||||
networks: [internal]
|
|
||||||
|
|
||||||
core:
|
|
||||||
image: rockchin/langbot-cloud-core:${LANGBOT_IMAGE_TAG}
|
|
||||||
container_name: langbot-cloud-core
|
|
||||||
restart: unless-stopped
|
|
||||||
environment: *core-env
|
|
||||||
volumes:
|
|
||||||
- core-data:/app/data
|
|
||||||
- box-data:/app/data/box
|
|
||||||
depends_on:
|
|
||||||
postgres: {condition: service_healthy}
|
|
||||||
redis: {condition: service_healthy}
|
|
||||||
plugin-runtime: {condition: service_started}
|
|
||||||
box: {condition: service_started}
|
|
||||||
expose: ["5300"]
|
|
||||||
healthcheck:
|
|
||||||
test: [CMD-SHELL, "python -c 'import urllib.request; urllib.request.urlopen(\"http://127.0.0.1:5300/healthz\", timeout=3)'" ]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 30
|
|
||||||
start_period: 30s
|
|
||||||
networks: [internal, shared-network]
|
|
||||||
|
|
||||||
networks:
|
|
||||||
internal:
|
|
||||||
shared-network:
|
|
||||||
external: true
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
postgres-data:
|
|
||||||
redis-data:
|
|
||||||
plugin-data:
|
|
||||||
box-data:
|
|
||||||
core-data:
|
|
||||||
@@ -14,8 +14,8 @@ services:
|
|||||||
restart: on-failure
|
restart: on-failure
|
||||||
environment:
|
environment:
|
||||||
- TZ=Asia/Shanghai
|
- TZ=Asia/Shanghai
|
||||||
# Optional. Leave unset on both OSS services, or set the same value on
|
# Shared with the langbot service and sent only as a WebSocket handshake
|
||||||
# both to protect the control WebSocket. Generate with: openssl rand -hex 32
|
# header. Generate with: openssl rand -hex 32
|
||||||
- LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN=${LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN:-}
|
- LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN=${LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN:-}
|
||||||
# Process-wide admission for every asyncio.to_thread() call.
|
# Process-wide admission for every asyncio.to_thread() call.
|
||||||
- LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS=${LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS:-8}
|
- LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS=${LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS:-8}
|
||||||
@@ -77,7 +77,8 @@ services:
|
|||||||
restart: on-failure
|
restart: on-failure
|
||||||
environment:
|
environment:
|
||||||
- TZ=Asia/Shanghai
|
- TZ=Asia/Shanghai
|
||||||
# Optional. Leave unset on both OSS services, or match plugin Runtime.
|
# Must match langbot_plugin_runtime. Empty/missing values make the
|
||||||
|
# external control channel fail closed.
|
||||||
- LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN=${LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN:-}
|
- LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN=${LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN:-}
|
||||||
# Must match the value supplied to langbot_box. The token is sent only
|
# Must match the value supplied to langbot_box. The token is sent only
|
||||||
# in WebSocket handshake headers, never in URLs or action payloads.
|
# in WebSocket handshake headers, never in URLs or action payloads.
|
||||||
|
|||||||
@@ -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
|
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.10.7"
|
version = "4.10.6"
|
||||||
description = "Production-grade platform for building agentic IM bots"
|
description = "Production-grade platform for building agentic IM bots"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
@@ -71,7 +71,7 @@ dependencies = [
|
|||||||
"chromadb>=1.0.0,<2.0.0",
|
"chromadb>=1.0.0,<2.0.0",
|
||||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||||
"pyseekdb==1.1.0.post3",
|
"pyseekdb==1.1.0.post3",
|
||||||
"langbot-plugin @ git+https://github.com/langbot-app/langbot-plugin-sdk.git@9d216208cdfb41f0cb7fcb64632e2a46816d6dc6",
|
"langbot-plugin @ git+https://github.com/langbot-app/langbot-plugin-sdk.git@1d65ed301a6afc52150a998043f73cd6032c8162",
|
||||||
"asyncpg>=0.30.0",
|
"asyncpg>=0.30.0",
|
||||||
"line-bot-sdk>=3.19.0",
|
"line-bot-sdk>=3.19.0",
|
||||||
"matrix-nio>=0.25.2",
|
"matrix-nio>=0.25.2",
|
||||||
|
|||||||
@@ -32,13 +32,12 @@ The `all` / `box` profile starts three services:
|
|||||||
the LangBot and Box containers. Generate it once with `openssl rand -hex 32`;
|
the LangBot and Box containers. Generate it once with `openssl rand -hex 32`;
|
||||||
never put it in `box.runtime.endpoint` or commit it to config.
|
never put it in `box.runtime.endpoint` or commit it to config.
|
||||||
|
|
||||||
A Compose deployment may optionally set
|
Every Compose deployment also needs one
|
||||||
`LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN` on both `langbot` and
|
`LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN` shared by `langbot` and
|
||||||
`langbot_plugin_runtime` when port 5400 needs shared-secret protection. OSS
|
`langbot_plugin_runtime`. Generate it with `openssl rand -hex 32` and export it
|
||||||
defaults to leaving it unset on both sides. If enabled, generate one value with
|
before `docker compose up`; the external Plugin Runtime fails closed when the
|
||||||
`openssl rand -hex 32`; configuring only one side causes the control connection
|
token is empty or weak. Kubernetes uses the `langbot-plugin-runtime-control`
|
||||||
to fail. Kubernetes may use the `langbot-plugin-runtime-control` Secret shown in
|
Secret shown in `docker/kubernetes.yaml`.
|
||||||
`docker/kubernetes.yaml`.
|
|
||||||
|
|
||||||
With Box off, the dashboard/skills list stays visible (read-only) but sandbox
|
With Box off, the dashboard/skills list stays visible (read-only) but sandbox
|
||||||
tools, skill add/edit, and stdio MCP are disabled. Set `box.enabled: false`
|
tools, skill add/edit, and stdio MCP are disabled. Set `box.enabled: false`
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class Permission(enum.StrEnum):
|
|||||||
WORKSPACE_VIEW = 'workspace.view'
|
WORKSPACE_VIEW = 'workspace.view'
|
||||||
WORKSPACE_UPDATE = 'workspace.update'
|
WORKSPACE_UPDATE = 'workspace.update'
|
||||||
WORKSPACE_DELETE = 'workspace.delete'
|
WORKSPACE_DELETE = 'workspace.delete'
|
||||||
|
OWNER_TRANSFER = 'owner.transfer'
|
||||||
MEMBER_VIEW = 'member.view'
|
MEMBER_VIEW = 'member.view'
|
||||||
MEMBER_INVITE = 'member.invite'
|
MEMBER_INVITE = 'member.invite'
|
||||||
MEMBER_UPDATE_ROLE = 'member.update_role'
|
MEMBER_UPDATE_ROLE = 'member.update_role'
|
||||||
@@ -48,6 +49,7 @@ _ROLE_PERMISSIONS: typing.Final = types.MappingProxyType(
|
|||||||
if permission
|
if permission
|
||||||
not in {
|
not in {
|
||||||
Permission.WORKSPACE_DELETE,
|
Permission.WORKSPACE_DELETE,
|
||||||
|
Permission.OWNER_TRANSFER,
|
||||||
Permission.BILLING_LINK_MANAGE,
|
Permission.BILLING_LINK_MANAGE,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
@@ -72,11 +74,6 @@ class AuthorizationError(Exception):
|
|||||||
error_code = 'forbidden'
|
error_code = 'forbidden'
|
||||||
|
|
||||||
|
|
||||||
class AuthenticationDeniedError(AuthorizationError):
|
|
||||||
status_code = 401
|
|
||||||
error_code = 'invalid_authentication'
|
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceRequiredError(AuthorizationError):
|
class WorkspaceRequiredError(AuthorizationError):
|
||||||
status_code = 400
|
status_code = 400
|
||||||
error_code = 'workspace_required'
|
error_code = 'workspace_required'
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ class PrincipalType(enum.StrEnum):
|
|||||||
|
|
||||||
ACCOUNT = 'account'
|
ACCOUNT = 'account'
|
||||||
API_KEY = 'api_key'
|
API_KEY = 'api_key'
|
||||||
SUPPORT_ADMIN = 'support_admin'
|
|
||||||
SYSTEM = 'system'
|
SYSTEM = 'system'
|
||||||
PUBLIC_BOT = 'public_bot'
|
PUBLIC_BOT = 'public_bot'
|
||||||
|
|
||||||
@@ -20,9 +19,7 @@ class PrincipalContext:
|
|||||||
|
|
||||||
principal_type: PrincipalType
|
principal_type: PrincipalType
|
||||||
account_uuid: str | None = None
|
account_uuid: str | None = None
|
||||||
actor_account_uuid: str | None = None
|
|
||||||
api_key_uuid: str | None = None
|
api_key_uuid: str | None = None
|
||||||
support_session_id: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass(frozen=True, slots=True)
|
@dataclasses.dataclass(frozen=True, slots=True)
|
||||||
|
|||||||
@@ -15,17 +15,8 @@ from ....workspace.collaboration import MembershipPermissionError, WorkspaceColl
|
|||||||
from ....workspace.errors import WorkspaceNotFoundError
|
from ....workspace.errors import WorkspaceNotFoundError
|
||||||
from ....cloud.entitlements import EntitlementUnavailableError
|
from ....cloud.entitlements import EntitlementUnavailableError
|
||||||
from ....core.errors import TaskCapacityError
|
from ....core.errors import TaskCapacityError
|
||||||
from ..authz import (
|
from ..authz import AuthorizationError, Permission, permissions_for_role, require_permission
|
||||||
AuthenticationDeniedError,
|
|
||||||
AuthorizationError,
|
|
||||||
Permission,
|
|
||||||
PermissionDeniedError,
|
|
||||||
WorkspaceRequiredError,
|
|
||||||
permissions_for_role,
|
|
||||||
require_permission,
|
|
||||||
)
|
|
||||||
from ..context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext
|
from ..context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext
|
||||||
from ....cloud.support_admin import SupportAdminSessionError
|
|
||||||
|
|
||||||
if typing.TYPE_CHECKING:
|
if typing.TYPE_CHECKING:
|
||||||
from ....core.app import Application
|
from ....core.app import Application
|
||||||
@@ -60,16 +51,6 @@ class AuthType(enum.Enum):
|
|||||||
USER_TOKEN_OR_API_KEY = 'user-token-or-api-key'
|
USER_TOKEN_OR_API_KEY = 'user-token-or-api-key'
|
||||||
|
|
||||||
|
|
||||||
_SUPPORT_ADMIN_DENIED_PERMISSIONS = frozenset(
|
|
||||||
{
|
|
||||||
Permission.MEMBER_VIEW.value,
|
|
||||||
Permission.MEMBER_INVITE.value,
|
|
||||||
Permission.MEMBER_UPDATE_ROLE.value,
|
|
||||||
Permission.MEMBER_REMOVE.value,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class RouterGroup(abc.ABC):
|
class RouterGroup(abc.ABC):
|
||||||
name: str
|
name: str
|
||||||
|
|
||||||
@@ -114,10 +95,6 @@ class RouterGroup(abc.ABC):
|
|||||||
return self.http_status(401, -1, 'No valid user token provided')
|
return self.http_status(401, -1, 'No valid user token provided')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if self._is_support_admin_token(token):
|
|
||||||
raise AuthenticationDeniedError(
|
|
||||||
'Support admin tokens cannot be refreshed or used on account endpoints'
|
|
||||||
)
|
|
||||||
account, user_email = await self._authenticate_account(token)
|
account, user_email = await self._authenticate_account(token)
|
||||||
# Account-token routes deliberately stop before Workspace
|
# Account-token routes deliberately stop before Workspace
|
||||||
# selection. They may bootstrap a selector, but cannot
|
# selection. They may bootstrap a selector, but cannot
|
||||||
@@ -134,13 +111,8 @@ class RouterGroup(abc.ABC):
|
|||||||
return self.http_status(401, -1, 'No valid user token provided')
|
return self.http_status(401, -1, 'No valid user token provided')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
request_context = await self._authenticate_support_admin(token, auth_type)
|
account, user_email = await self._authenticate_account(token)
|
||||||
if request_context is not None:
|
request_context = await self._resolve_account_context(account, auth_type)
|
||||||
self._require_support_admin_route_allowed(rule, f, permission)
|
|
||||||
user_email = None
|
|
||||||
else:
|
|
||||||
account, user_email = await self._authenticate_account(token)
|
|
||||||
request_context = await self._resolve_account_context(account, auth_type)
|
|
||||||
if permission is not None:
|
if permission is not None:
|
||||||
if request_context is None:
|
if request_context is None:
|
||||||
raise AuthorizationError('Workspace authorization is unavailable')
|
raise AuthorizationError('Workspace authorization is unavailable')
|
||||||
@@ -169,20 +141,10 @@ class RouterGroup(abc.ABC):
|
|||||||
return self._auth_error_response(e)
|
return self._auth_error_response(e)
|
||||||
|
|
||||||
elif auth_type == AuthType.USER_TOKEN_OR_API_KEY:
|
elif auth_type == AuthType.USER_TOKEN_OR_API_KEY:
|
||||||
token = quart.request.headers.get('Authorization', '').replace('Bearer ', '')
|
|
||||||
if token and self._is_support_admin_token(token):
|
|
||||||
try:
|
|
||||||
request_context = await self._authenticate_support_admin(token, auth_type)
|
|
||||||
if request_context is None:
|
|
||||||
raise AuthenticationDeniedError('Invalid support admin token')
|
|
||||||
self._require_support_admin_route_allowed(rule, f, permission)
|
|
||||||
if permission is not None:
|
|
||||||
require_permission(request_context, permission)
|
|
||||||
self._inject_handler_context(f, kwargs, None, request_context)
|
|
||||||
except Exception as e:
|
|
||||||
return self._auth_error_response(e)
|
|
||||||
# Try API key first (check X-API-Key header)
|
# Try API key first (check X-API-Key header)
|
||||||
elif api_key := quart.request.headers.get('X-API-Key', ''):
|
api_key = quart.request.headers.get('X-API-Key', '')
|
||||||
|
|
||||||
|
if api_key:
|
||||||
# API key authentication
|
# API key authentication
|
||||||
try:
|
try:
|
||||||
request_context = await self._authenticate_api_key(api_key, auth_type)
|
request_context = await self._authenticate_api_key(api_key, auth_type)
|
||||||
@@ -193,6 +155,8 @@ class RouterGroup(abc.ABC):
|
|||||||
return self._auth_error_response(e)
|
return self._auth_error_response(e)
|
||||||
else:
|
else:
|
||||||
# Try user token authentication (Authorization header)
|
# Try user token authentication (Authorization header)
|
||||||
|
token = quart.request.headers.get('Authorization', '').replace('Bearer ', '')
|
||||||
|
|
||||||
if not token:
|
if not token:
|
||||||
return self.http_status(
|
return self.http_status(
|
||||||
401, -1, 'No valid authentication provided (user token or API key required)'
|
401, -1, 'No valid authentication provided (user token or API key required)'
|
||||||
@@ -304,83 +268,10 @@ class RouterGroup(abc.ABC):
|
|||||||
raise ValueError('User not found')
|
raise ValueError('User not found')
|
||||||
return account, account.user
|
return account, account.user
|
||||||
|
|
||||||
def _is_support_admin_token(self, token: str) -> bool:
|
|
||||||
service = getattr(self.ap, 'support_admin_session_service', None)
|
|
||||||
detector = getattr(service, 'is_support_admin_token', None)
|
|
||||||
return callable(detector) and detector(token) is True
|
|
||||||
|
|
||||||
async def _authenticate_support_admin(
|
|
||||||
self,
|
|
||||||
token: str,
|
|
||||||
auth_type: AuthType,
|
|
||||||
*,
|
|
||||||
workspace_uuid: str | None = None,
|
|
||||||
request_id: str | None = None,
|
|
||||||
) -> RequestContext | None:
|
|
||||||
service = getattr(self.ap, 'support_admin_session_service', None)
|
|
||||||
detector = getattr(service, 'is_support_admin_token', None)
|
|
||||||
if service is None or not callable(detector) or detector(token) is not True:
|
|
||||||
return None
|
|
||||||
|
|
||||||
requested_workspace_uuid = (
|
|
||||||
workspace_uuid if workspace_uuid is not None else quart.request.headers.get('X-Workspace-Id')
|
|
||||||
)
|
|
||||||
if not requested_workspace_uuid:
|
|
||||||
raise WorkspaceRequiredError('Support admin token requires an explicit Workspace selector')
|
|
||||||
try:
|
|
||||||
identity = await service.authenticate_token(
|
|
||||||
token,
|
|
||||||
requested_workspace_uuid=requested_workspace_uuid,
|
|
||||||
)
|
|
||||||
except SupportAdminSessionError as exc:
|
|
||||||
raise AuthenticationDeniedError(str(exc)) from exc
|
|
||||||
|
|
||||||
entitlement_revision = await self._resolve_entitlement_revision(
|
|
||||||
identity.instance_uuid,
|
|
||||||
identity.workspace_uuid,
|
|
||||||
)
|
|
||||||
request_context = RequestContext(
|
|
||||||
instance_uuid=identity.instance_uuid,
|
|
||||||
placement_generation=identity.placement_generation,
|
|
||||||
request_id=request_id or self.request_id(),
|
|
||||||
auth_type=auth_type.value,
|
|
||||||
principal=PrincipalContext(
|
|
||||||
principal_type=PrincipalType.SUPPORT_ADMIN,
|
|
||||||
actor_account_uuid=identity.actor_account_uuid,
|
|
||||||
support_session_id=identity.grant_jti_hash,
|
|
||||||
),
|
|
||||||
workspace=WorkspaceContext(
|
|
||||||
workspace_uuid=identity.workspace_uuid,
|
|
||||||
membership_uuid=None,
|
|
||||||
role='owner',
|
|
||||||
permissions=permissions_for_role('owner') - _SUPPORT_ADMIN_DENIED_PERMISSIONS,
|
|
||||||
membership_revision=0,
|
|
||||||
),
|
|
||||||
entitlement_revision=entitlement_revision,
|
|
||||||
)
|
|
||||||
quart.g.request_context = request_context
|
|
||||||
quart.g.workspace_membership = None
|
|
||||||
return request_context
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _require_support_admin_route_allowed(
|
|
||||||
rule: str,
|
|
||||||
handler: RouteCallable,
|
|
||||||
permission: Permission | str | None,
|
|
||||||
) -> None:
|
|
||||||
parameters = inspect.signature(handler).parameters
|
|
||||||
if rule.startswith('/api/v1/user/') or 'account' in parameters or 'user_email' in parameters:
|
|
||||||
raise AuthenticationDeniedError('Support admin tokens are not permitted on account endpoints')
|
|
||||||
permission_value = permission.value if isinstance(permission, Permission) else permission
|
|
||||||
if permission_value in _SUPPORT_ADMIN_DENIED_PERMISSIONS:
|
|
||||||
raise PermissionDeniedError(permission_value)
|
|
||||||
|
|
||||||
async def _resolve_account_context(
|
async def _resolve_account_context(
|
||||||
self,
|
self,
|
||||||
account: typing.Any,
|
account: typing.Any,
|
||||||
auth_type: AuthType,
|
auth_type: AuthType,
|
||||||
*,
|
|
||||||
token: str | None = None,
|
|
||||||
) -> RequestContext | None:
|
) -> RequestContext | None:
|
||||||
collaboration_service = getattr(self.ap, 'workspace_collaboration_service', None)
|
collaboration_service = getattr(self.ap, 'workspace_collaboration_service', None)
|
||||||
account_uuid = getattr(account, 'uuid', None)
|
account_uuid = getattr(account, 'uuid', None)
|
||||||
|
|||||||
@@ -23,13 +23,8 @@ def _storage_owner(context: RequestContext) -> str:
|
|||||||
@group.group_class('files', '/api/v1/files')
|
@group.group_class('files', '/api/v1/files')
|
||||||
class FilesRouterGroup(group.RouterGroup):
|
class FilesRouterGroup(group.RouterGroup):
|
||||||
async def initialize(self) -> None:
|
async def initialize(self) -> None:
|
||||||
@self.route(
|
@self.route('/image/<path:image_key>', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||||
'/image/<path:image_key>',
|
async def _(image_key: str) -> quart.Response:
|
||||||
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:
|
|
||||||
image_bytes = await self.ap.storage_mgr.resolve_public_object(
|
image_bytes = await self.ap.storage_mgr.resolve_public_object(
|
||||||
image_key,
|
image_key,
|
||||||
expected_owner_type='upload_image',
|
expected_owner_type='upload_image',
|
||||||
|
|||||||
@@ -97,16 +97,6 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
|||||||
if not token or not workspace_uuid:
|
if not token or not workspace_uuid:
|
||||||
raise ValueError('Authentication is required')
|
raise ValueError('Authentication is required')
|
||||||
|
|
||||||
support_context = await self._authenticate_support_admin(
|
|
||||||
token,
|
|
||||||
group.AuthType.USER_TOKEN,
|
|
||||||
workspace_uuid=workspace_uuid,
|
|
||||||
request_id=quart.websocket.headers.get('X-Request-Id') or str(uuid.uuid4()),
|
|
||||||
)
|
|
||||||
if support_context is not None:
|
|
||||||
require_permission(support_context, Permission.RUNTIME_OPERATE)
|
|
||||||
return support_context, token
|
|
||||||
|
|
||||||
account, _ = await self._authenticate_account(token)
|
account, _ = await self._authenticate_account(token)
|
||||||
account_uuid = getattr(account, 'uuid', None)
|
account_uuid = getattr(account, 'uuid', None)
|
||||||
collaboration_service = getattr(self.ap, 'workspace_collaboration_service', None)
|
collaboration_service = getattr(self.ap, 'workspace_collaboration_service', None)
|
||||||
@@ -138,26 +128,9 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
|||||||
self,
|
self,
|
||||||
request_context: RequestContext,
|
request_context: RequestContext,
|
||||||
token: str,
|
token: str,
|
||||||
) -> RequestContext:
|
) -> None:
|
||||||
"""Recheck revocable account, membership, permission, and placement state."""
|
"""Recheck revocable account, membership, permission, and placement state."""
|
||||||
|
|
||||||
if request_context.principal.principal_type == PrincipalType.SUPPORT_ADMIN:
|
|
||||||
current_context = await self._authenticate_support_admin(
|
|
||||||
token,
|
|
||||||
group.AuthType.USER_TOKEN,
|
|
||||||
workspace_uuid=request_context.workspace_uuid,
|
|
||||||
request_id=request_context.request_id,
|
|
||||||
)
|
|
||||||
if current_context is None or current_context.principal != request_context.principal:
|
|
||||||
raise ValueError('WebSocket support admin session changed')
|
|
||||||
if (
|
|
||||||
current_context.instance_uuid != request_context.instance_uuid
|
|
||||||
or current_context.placement_generation != request_context.placement_generation
|
|
||||||
):
|
|
||||||
raise ValueError('WebSocket authorization changed')
|
|
||||||
require_permission(current_context, Permission.RUNTIME_OPERATE)
|
|
||||||
return current_context
|
|
||||||
|
|
||||||
account, _ = await self._authenticate_account(token)
|
account, _ = await self._authenticate_account(token)
|
||||||
account_uuid = getattr(account, 'uuid', None)
|
account_uuid = getattr(account, 'uuid', None)
|
||||||
if account_uuid != request_context.account_uuid:
|
if account_uuid != request_context.account_uuid:
|
||||||
@@ -195,7 +168,6 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
|||||||
entitlement_revision=request_context.entitlement_revision,
|
entitlement_revision=request_context.entitlement_revision,
|
||||||
)
|
)
|
||||||
require_permission(current_context, Permission.RUNTIME_OPERATE)
|
require_permission(current_context, Permission.RUNTIME_OPERATE)
|
||||||
return current_context
|
|
||||||
|
|
||||||
async def _get_scoped_adapter(self, request_context: RequestContext, pipeline_uuid: str):
|
async def _get_scoped_adapter(self, request_context: RequestContext, pipeline_uuid: str):
|
||||||
pipeline = await run_in_workspace_uow(
|
pipeline = await run_in_workspace_uow(
|
||||||
@@ -238,7 +210,6 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
|||||||
scope=WebSocketScope.from_context(request_context),
|
scope=WebSocketScope.from_context(request_context),
|
||||||
pipeline_uuid=pipeline_uuid,
|
pipeline_uuid=pipeline_uuid,
|
||||||
session_type=session_type,
|
session_type=session_type,
|
||||||
trigger_principal=request_context.principal,
|
|
||||||
metadata={'user_agent': quart.websocket.headers.get('User-Agent', '')},
|
metadata={'user_agent': quart.websocket.headers.get('User-Agent', '')},
|
||||||
send_queue_size=(
|
send_queue_size=(
|
||||||
self.ap.instance_config.data.get('system', {})
|
self.ap.instance_config.data.get('system', {})
|
||||||
@@ -419,7 +390,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
|||||||
)
|
)
|
||||||
elif message_type == 'message':
|
elif message_type == 'message':
|
||||||
try:
|
try:
|
||||||
request_context = await self._revalidate_websocket_authorization(request_context, token)
|
await self._revalidate_websocket_authorization(request_context, token)
|
||||||
except Exception:
|
except Exception:
|
||||||
await connection.send_queue.put({'type': 'error', 'message': 'Unauthorized'})
|
await connection.send_queue.put({'type': 'error', 'message': 'Unauthorized'})
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ class _AdapterSessionScope:
|
|||||||
principal_type: str
|
principal_type: str
|
||||||
account_uuid: str | None
|
account_uuid: str | None
|
||||||
api_key_uuid: str | None
|
api_key_uuid: str | None
|
||||||
support_session_id: str | None
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_request_context(cls, request_context: RequestContext) -> '_AdapterSessionScope':
|
def from_request_context(cls, request_context: RequestContext) -> '_AdapterSessionScope':
|
||||||
@@ -34,7 +33,6 @@ class _AdapterSessionScope:
|
|||||||
principal_type=principal.principal_type.value,
|
principal_type=principal.principal_type.value,
|
||||||
account_uuid=principal.account_uuid,
|
account_uuid=principal.account_uuid,
|
||||||
api_key_uuid=principal.api_key_uuid,
|
api_key_uuid=principal.api_key_uuid,
|
||||||
support_session_id=principal.support_session_id,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def matches(self, request_context: RequestContext) -> bool:
|
def matches(self, request_context: RequestContext) -> bool:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import posixpath
|
|||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
|
|
||||||
from .....core import taskmgr
|
from .....core import taskmgr
|
||||||
|
from .....core.task_boundary import run_in_workspace_uow
|
||||||
from .....entity.persistence import plugin as persistence_plugin
|
from .....entity.persistence import plugin as persistence_plugin
|
||||||
from ...authz import Permission
|
from ...authz import Permission
|
||||||
from ...context import ExecutionContext, RequestContext
|
from ...context import ExecutionContext, RequestContext
|
||||||
@@ -310,22 +311,13 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
):
|
):
|
||||||
"""Revalidate a captured task context immediately before Runtime I/O."""
|
"""Revalidate a captured task context immediately before Runtime I/O."""
|
||||||
|
|
||||||
persistence_mgr = getattr(self.ap, 'persistence_mgr', None)
|
await run_in_workspace_uow(
|
||||||
tenant_scope = getattr(persistence_mgr, 'tenant_scope', None)
|
self.ap,
|
||||||
if callable(tenant_scope):
|
execution_context.workspace_uuid,
|
||||||
async with tenant_scope(execution_context.workspace_uuid):
|
lambda: self.ap.plugin_connector.require_workspace_context(execution_context),
|
||||||
await self.ap.plugin_connector.require_workspace_context(execution_context)
|
)
|
||||||
return await operation()
|
|
||||||
await self.ap.plugin_connector.require_workspace_context(execution_context)
|
|
||||||
return await operation()
|
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:
|
async def _require_public_plugin_runtime_context(self) -> ExecutionContext:
|
||||||
"""Resolve public assets only for the OSS singleton Workspace.
|
"""Resolve public assets only for the OSS singleton Workspace.
|
||||||
|
|
||||||
@@ -380,7 +372,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
permission=Permission.RESOURCE_VIEW,
|
permission=Permission.RESOURCE_VIEW,
|
||||||
)
|
)
|
||||||
async def _(request_context: RequestContext) -> str:
|
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()
|
plugins = await self.ap.plugin_connector.list_plugins()
|
||||||
|
|
||||||
return self.success(data={'plugins': redact_plugin_secrets(plugins)})
|
return self.success(data={'plugins': redact_plugin_secrets(plugins)})
|
||||||
@@ -393,8 +385,8 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
)
|
)
|
||||||
async def _(request_context: RequestContext) -> str:
|
async def _(request_context: RequestContext) -> str:
|
||||||
"""Get plugin debug information including debug URL and key"""
|
"""Get plugin debug information including debug URL and key"""
|
||||||
execution_context = 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(execution_context)
|
debug_info = await self.ap.plugin_connector.get_debug_info()
|
||||||
|
|
||||||
# Get debug URL from config
|
# Get debug URL from config
|
||||||
plugin_config = self.ap.instance_config.data.get('plugin', {})
|
plugin_config = self.ap.instance_config.data.get('plugin', {})
|
||||||
@@ -404,7 +396,6 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
data={
|
data={
|
||||||
'debug_url': debug_url,
|
'debug_url': debug_url,
|
||||||
'plugin_debug_key': debug_info.get('plugin_debug_key', ''),
|
'plugin_debug_key': debug_info.get('plugin_debug_key', ''),
|
||||||
'expires_at': debug_info.get('expires_at', ''),
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -437,7 +428,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
permission=Permission.RESOURCE_VIEW,
|
permission=Permission.RESOURCE_VIEW,
|
||||||
)
|
)
|
||||||
async def _(author: str, plugin_name: str, request_context: RequestContext) -> str:
|
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)
|
plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name)
|
||||||
if plugin is None:
|
if plugin is None:
|
||||||
return self.http_status(404, -1, 'plugin not found')
|
return self.http_status(404, -1, 'plugin not found')
|
||||||
@@ -478,7 +469,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
permission=Permission.RESOURCE_VIEW,
|
permission=Permission.RESOURCE_VIEW,
|
||||||
)
|
)
|
||||||
async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response:
|
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)
|
plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name)
|
||||||
if plugin is None:
|
if plugin is None:
|
||||||
return self.http_status(404, -1, 'plugin not found')
|
return self.http_status(404, -1, 'plugin not found')
|
||||||
@@ -498,7 +489,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
permission=Permission.RESOURCE_MANAGE,
|
permission=Permission.RESOURCE_MANAGE,
|
||||||
)
|
)
|
||||||
async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response:
|
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)
|
plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name)
|
||||||
if plugin is None:
|
if plugin is None:
|
||||||
return self.http_status(404, -1, 'plugin not found')
|
return self.http_status(404, -1, 'plugin not found')
|
||||||
@@ -515,7 +506,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return self.http_status(400, -1, str(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)
|
await self.ap.plugin_connector.set_plugin_config(author, plugin_name, config)
|
||||||
return self.success(data={})
|
return self.success(data={})
|
||||||
|
|
||||||
@@ -526,7 +517,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
permission=Permission.RESOURCE_VIEW,
|
permission=Permission.RESOURCE_VIEW,
|
||||||
)
|
)
|
||||||
async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response:
|
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')
|
language = quart.request.args.get('language', 'en')
|
||||||
readme = await self.ap.plugin_connector.get_plugin_readme(author, plugin_name, language=language)
|
readme = await self.ap.plugin_connector.get_plugin_readme(author, plugin_name, language=language)
|
||||||
return self.success(data={'readme': readme})
|
return self.success(data={'readme': readme})
|
||||||
@@ -538,7 +529,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
permission=Permission.AUDIT_VIEW,
|
permission=Permission.AUDIT_VIEW,
|
||||||
)
|
)
|
||||||
async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response:
|
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:
|
try:
|
||||||
limit = int(quart.request.args.get('limit', 200))
|
limit = int(quart.request.args.get('limit', 200))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
@@ -547,44 +538,6 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
logs = await self.ap.plugin_connector.get_plugin_logs(author, plugin_name, limit=limit, level=level)
|
logs = await self.ap.plugin_connector.get_plugin_logs(author, plugin_name, limit=limit, level=level)
|
||||||
return self.success(data={'logs': logs})
|
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(
|
@self.route(
|
||||||
'/<author>/<plugin_name>/icon',
|
'/<author>/<plugin_name>/icon',
|
||||||
methods=['GET'],
|
methods=['GET'],
|
||||||
@@ -643,7 +596,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
)
|
)
|
||||||
async def _(author: str, plugin_name: str, request_context: RequestContext) -> str:
|
async def _(author: str, plugin_name: str, request_context: RequestContext) -> str:
|
||||||
"""Forward a page API request to the plugin."""
|
"""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
|
data = await quart.request.json
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
return self.http_status(400, -1, 'invalid request body')
|
return self.http_status(400, -1, 'invalid request body')
|
||||||
@@ -672,7 +625,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
)
|
)
|
||||||
async def _(request_context: RequestContext) -> str:
|
async def _(request_context: RequestContext) -> str:
|
||||||
"""Get releases from a GitHub repository URL"""
|
"""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
|
data = await quart.request.json
|
||||||
repo_url = data.get('repo_url', '')
|
repo_url = data.get('repo_url', '')
|
||||||
|
|
||||||
@@ -752,7 +705,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
)
|
)
|
||||||
async def _(request_context: RequestContext) -> str:
|
async def _(request_context: RequestContext) -> str:
|
||||||
"""Get assets from a specific GitHub release"""
|
"""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
|
data = await quart.request.json
|
||||||
owner = data.get('owner', '')
|
owner = data.get('owner', '')
|
||||||
repo = data.get('repo', '')
|
repo = data.get('repo', '')
|
||||||
@@ -948,7 +901,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
permission=Permission.RESOURCE_MANAGE,
|
permission=Permission.RESOURCE_MANAGE,
|
||||||
)
|
)
|
||||||
async def _(request_context: RequestContext) -> str:
|
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')
|
file = (await quart.request.files).get('file')
|
||||||
if file is None:
|
if file is None:
|
||||||
return self.http_status(400, -1, 'file is required')
|
return self.http_status(400, -1, 'file is required')
|
||||||
@@ -989,7 +942,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
)
|
)
|
||||||
async def _(request_context: RequestContext) -> str:
|
async def _(request_context: RequestContext) -> str:
|
||||||
"""Upload a file for plugin configuration"""
|
"""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')
|
file = (await quart.request.files).get('file')
|
||||||
if file is None:
|
if file is None:
|
||||||
return self.http_status(400, -1, 'file is required')
|
return self.http_status(400, -1, 'file is required')
|
||||||
@@ -1021,7 +974,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
)
|
)
|
||||||
async def _(file_key: str, request_context: RequestContext) -> str:
|
async def _(file_key: str, request_context: RequestContext) -> str:
|
||||||
"""Delete a plugin configuration file"""
|
"""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'):
|
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')
|
return self.http_status(400, -1, 'invalid file key')
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import quart
|
import quart
|
||||||
|
|
||||||
from langbot.pkg.cloud.entitlements import EntitlementFeatureUnavailableError
|
|
||||||
from langbot_plugin.box.errors import BoxError
|
from langbot_plugin.box.errors import BoxError
|
||||||
|
|
||||||
from ...authz import Permission
|
from ...authz import Permission
|
||||||
@@ -24,11 +23,6 @@ class SkillsRouterGroup(group.RouterGroup):
|
|||||||
async def list_skills(request_context: RequestContext) -> quart.Response:
|
async def list_skills(request_context: RequestContext) -> quart.Response:
|
||||||
try:
|
try:
|
||||||
skills = await self.ap.skill_service.list_skills(request_context)
|
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:
|
except (ValueError, BoxError) as exc:
|
||||||
return self.http_status(400, -1, str(exc))
|
return self.http_status(400, -1, str(exc))
|
||||||
return self.success(data={'skills': skills})
|
return self.success(data={'skills': skills})
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import quart
|
import quart
|
||||||
import argon2
|
import argon2
|
||||||
import asyncio
|
import asyncio
|
||||||
import datetime
|
|
||||||
import uuid
|
import uuid
|
||||||
from urllib.parse import parse_qs, urlsplit
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
@@ -219,22 +218,7 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
try:
|
try:
|
||||||
consumed_state = await self.ap.user_service.consume_space_oauth_state_details(state, 'login')
|
consumed_state = await self.ap.user_service.consume_space_oauth_state_details(state, 'login')
|
||||||
# Exchange code for tokens
|
# Exchange code for tokens
|
||||||
launch_workspace_uuid = consumed_state.launch_workspace_uuid
|
token_data = await self.ap.space_service.exchange_oauth_code(code)
|
||||||
workspace_uuids = [launch_workspace_uuid] if launch_workspace_uuid else []
|
|
||||||
workspace_created_ats: dict[str, int] = {}
|
|
||||||
if not workspace_uuids and getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') != 'cloud':
|
|
||||||
binding = await self.ap.workspace_service.get_execution_binding()
|
|
||||||
workspace_uuids = [binding.workspace_uuid]
|
|
||||||
workspace_created_at = binding.workspace_created_at
|
|
||||||
if workspace_created_at is not None:
|
|
||||||
if workspace_created_at.tzinfo is None:
|
|
||||||
workspace_created_at = workspace_created_at.replace(tzinfo=datetime.UTC)
|
|
||||||
workspace_created_ats[binding.workspace_uuid] = int(workspace_created_at.timestamp())
|
|
||||||
token_data = await self.ap.space_service.exchange_oauth_code(
|
|
||||||
code,
|
|
||||||
workspace_uuids,
|
|
||||||
workspace_created_ats,
|
|
||||||
)
|
|
||||||
access_token = token_data.get('access_token')
|
access_token = token_data.get('access_token')
|
||||||
refresh_token = token_data.get('refresh_token')
|
refresh_token = token_data.get('refresh_token')
|
||||||
expires_in = token_data.get('expires_in', 0)
|
expires_in = token_data.get('expires_in', 0)
|
||||||
@@ -247,6 +231,7 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
access_token, refresh_token, expires_in
|
access_token, refresh_token, expires_in
|
||||||
)
|
)
|
||||||
|
|
||||||
|
launch_workspace_uuid = consumed_state.launch_workspace_uuid
|
||||||
if launch_workspace_uuid:
|
if launch_workspace_uuid:
|
||||||
try:
|
try:
|
||||||
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
|
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
|
||||||
@@ -300,25 +285,8 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
request_context.workspace_uuid,
|
request_context.workspace_uuid,
|
||||||
)
|
)
|
||||||
owner = await self.ap.user_service.get_workspace_owner(access.workspace.uuid)
|
owner = await self.ap.user_service.get_workspace_owner(access.workspace.uuid)
|
||||||
cloud_mode = getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud'
|
owner_space_bound = bool(owner and owner.space_account_uuid)
|
||||||
owner_has_local_space_credentials = bool(owner and owner.space_account_uuid)
|
credits = await self.ap.space_service.get_credits(owner.user) if owner_space_bound else None
|
||||||
# Cloud Accounts authenticate through LangBot Account, so every projected
|
|
||||||
# Workspace owner is already bound even when this Core has no local OAuth
|
|
||||||
# token row (model billing uses the owner's control-plane API key).
|
|
||||||
owner_space_bound = cloud_mode or owner_has_local_space_credentials
|
|
||||||
if cloud_mode:
|
|
||||||
catalog_service = getattr(self.ap, 'cloud_model_catalog_service', None)
|
|
||||||
credits = (
|
|
||||||
catalog_service.get_workspace_credits(access.workspace.uuid)
|
|
||||||
if catalog_service is not None
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
credits = (
|
|
||||||
await self.ap.space_service.get_credits(owner.user)
|
|
||||||
if owner is not None and owner.space_account_uuid
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
return self.success(
|
return self.success(
|
||||||
data={
|
data={
|
||||||
'credits': credits,
|
'credits': credits,
|
||||||
@@ -334,10 +302,8 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
return self.success(data={'initialized': False})
|
return self.success(data={'initialized': False})
|
||||||
|
|
||||||
capabilities = await self.ap.user_service.get_login_capabilities()
|
capabilities = await self.ap.user_service.get_login_capabilities()
|
||||||
cloud_mode = getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud'
|
if getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud':
|
||||||
if cloud_mode:
|
|
||||||
capabilities['password_login_enabled'] = False
|
capabilities['password_login_enabled'] = False
|
||||||
capabilities['authenticated_invitation_acceptance_enabled'] = cloud_mode
|
|
||||||
return self.success(data={'initialized': True, **capabilities})
|
return self.success(data={'initialized': True, **capabilities})
|
||||||
|
|
||||||
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
@@ -430,19 +396,6 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
launch_assertion,
|
launch_assertion,
|
||||||
expected_workspace_uuid=workspace_uuid,
|
expected_workspace_uuid=workspace_uuid,
|
||||||
)
|
)
|
||||||
if launch.get('launch_mode') == 'support_admin':
|
|
||||||
token = launch.get('support_admin_token')
|
|
||||||
if not token:
|
|
||||||
raise SpaceLaunchError('Support admin launch session was not issued')
|
|
||||||
return self.success(
|
|
||||||
data={
|
|
||||||
'token': token,
|
|
||||||
'workspace_uuid': launch['workspace_uuid'],
|
|
||||||
'principal_type': 'support_admin',
|
|
||||||
'actor_account_uuid': launch['actor_account_uuid'],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
account = await self.ap.user_service.get_user_by_uuid(launch['account_uuid'])
|
account = await self.ap.user_service.get_user_by_uuid(launch['account_uuid'])
|
||||||
if account is None:
|
if account is None:
|
||||||
raise SpaceLaunchError('Launch Account is not projected into Core')
|
raise SpaceLaunchError('Launch Account is not projected into Core')
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import typing
|
|||||||
import quart
|
import quart
|
||||||
|
|
||||||
from ...authz import Permission, permissions_for_role
|
from ...authz import Permission, permissions_for_role
|
||||||
from ...context import PrincipalType, RequestContext
|
from ...context import RequestContext
|
||||||
from ...service.user import AccountExistsLoginRequiredError, ControlPlaneDirectoryRequiredError
|
from ...service.user import AccountExistsLoginRequiredError, ControlPlaneDirectoryRequiredError
|
||||||
from .....entity.persistence.workspace import Workspace, WorkspaceInvitation, WorkspaceMembership
|
from .....entity.persistence.workspace import Workspace, WorkspaceInvitation, WorkspaceMembership
|
||||||
from .....entity.persistence.workspace import WorkspaceSource
|
from .....entity.persistence.workspace import WorkspaceSource
|
||||||
@@ -30,14 +30,12 @@ def _workspace_payload(workspace: Workspace) -> dict[str, typing.Any]:
|
|||||||
def _membership_payload(
|
def _membership_payload(
|
||||||
membership: WorkspaceMembership,
|
membership: WorkspaceMembership,
|
||||||
*,
|
*,
|
||||||
display_name: str,
|
|
||||||
email: str,
|
email: str,
|
||||||
) -> dict[str, typing.Any]:
|
) -> dict[str, typing.Any]:
|
||||||
return {
|
return {
|
||||||
'uuid': membership.uuid,
|
'uuid': membership.uuid,
|
||||||
'workspace_uuid': membership.workspace_uuid,
|
'workspace_uuid': membership.workspace_uuid,
|
||||||
'account_uuid': membership.account_uuid,
|
'account_uuid': membership.account_uuid,
|
||||||
'display_name': display_name,
|
|
||||||
'email': email,
|
'email': email,
|
||||||
'role': membership.role,
|
'role': membership.role,
|
||||||
'status': membership.status,
|
'status': membership.status,
|
||||||
@@ -96,11 +94,7 @@ class WorkspacesRouterGroup(group.RouterGroup):
|
|||||||
workspaces.append(
|
workspaces.append(
|
||||||
{
|
{
|
||||||
'workspace': _workspace_payload(access.workspace),
|
'workspace': _workspace_payload(access.workspace),
|
||||||
'membership': _membership_payload(
|
'membership': _membership_payload(access.membership, email=account.user),
|
||||||
access.membership,
|
|
||||||
display_name=account.user,
|
|
||||||
email=account.normalized_email,
|
|
||||||
),
|
|
||||||
'permissions': sorted(permissions_for_role(access.membership.role)),
|
'permissions': sorted(permissions_for_role(access.membership.role)),
|
||||||
'placement_generation': access.execution.placement_generation,
|
'placement_generation': access.execution.placement_generation,
|
||||||
'plan_name': plan_name,
|
'plan_name': plan_name,
|
||||||
@@ -126,6 +120,9 @@ class WorkspacesRouterGroup(group.RouterGroup):
|
|||||||
@self.route('/current', methods=['GET'], permission=Permission.WORKSPACE_VIEW)
|
@self.route('/current', methods=['GET'], permission=Permission.WORKSPACE_VIEW)
|
||||||
async def _(request_context: RequestContext) -> typing.Any:
|
async def _(request_context: RequestContext) -> typing.Any:
|
||||||
membership = quart.g.workspace_membership
|
membership = quart.g.workspace_membership
|
||||||
|
account = await self.ap.user_service.get_user_by_uuid(request_context.account_uuid)
|
||||||
|
if account is None:
|
||||||
|
return self.http_status(401, 'invalid_authentication', 'Account not found')
|
||||||
workspace = await self.ap.workspace_service.get_workspace(request_context.workspace_uuid)
|
workspace = await self.ap.workspace_service.get_workspace(request_context.workspace_uuid)
|
||||||
plan_name: str | None = None
|
plan_name: str | None = None
|
||||||
resolver = getattr(self.ap, 'entitlement_resolver', None)
|
resolver = getattr(self.ap, 'entitlement_resolver', None)
|
||||||
@@ -135,37 +132,10 @@ class WorkspacesRouterGroup(group.RouterGroup):
|
|||||||
minimum_revision=request_context.entitlement_revision,
|
minimum_revision=request_context.entitlement_revision,
|
||||||
)
|
)
|
||||||
plan_name = entitlement.plan_name
|
plan_name = entitlement.plan_name
|
||||||
if request_context.principal.principal_type == PrincipalType.SUPPORT_ADMIN:
|
|
||||||
return self.success(
|
|
||||||
data={
|
|
||||||
'workspace': _workspace_payload(workspace),
|
|
||||||
'membership': {
|
|
||||||
'uuid': None,
|
|
||||||
'workspace_uuid': request_context.workspace_uuid,
|
|
||||||
'account_uuid': None,
|
|
||||||
'display_name': None,
|
|
||||||
'email': None,
|
|
||||||
'role': 'owner',
|
|
||||||
'status': 'active',
|
|
||||||
'joined_at': None,
|
|
||||||
'created_at': None,
|
|
||||||
},
|
|
||||||
'permissions': sorted(request_context.workspace.permissions),
|
|
||||||
'placement_generation': request_context.placement_generation,
|
|
||||||
'plan_name': plan_name,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
account = await self.ap.user_service.get_user_by_uuid(request_context.account_uuid)
|
|
||||||
if account is None:
|
|
||||||
return self.http_status(401, 'invalid_authentication', 'Account not found')
|
|
||||||
return self.success(
|
return self.success(
|
||||||
data={
|
data={
|
||||||
'workspace': _workspace_payload(workspace),
|
'workspace': _workspace_payload(workspace),
|
||||||
'membership': _membership_payload(
|
'membership': _membership_payload(membership, email=account.user),
|
||||||
membership,
|
|
||||||
display_name=account.user,
|
|
||||||
email=account.normalized_email,
|
|
||||||
),
|
|
||||||
'permissions': sorted(request_context.workspace.permissions),
|
'permissions': sorted(request_context.workspace.permissions),
|
||||||
'placement_generation': request_context.placement_generation,
|
'placement_generation': request_context.placement_generation,
|
||||||
'plan_name': plan_name,
|
'plan_name': plan_name,
|
||||||
@@ -294,8 +264,7 @@ class WorkspacesRouterGroup(group.RouterGroup):
|
|||||||
data={
|
data={
|
||||||
'member': _membership_payload(
|
'member': _membership_payload(
|
||||||
member,
|
member,
|
||||||
display_name=account.user if account is not None else '',
|
email=account.user if account is not None else '',
|
||||||
email=account.normalized_email if account is not None else '',
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -314,11 +283,7 @@ class WorkspacesRouterGroup(group.RouterGroup):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _member_view_payload(view: WorkspaceMemberView) -> dict[str, typing.Any]:
|
def _member_view_payload(view: WorkspaceMemberView) -> dict[str, typing.Any]:
|
||||||
return _membership_payload(
|
return _membership_payload(view.membership, email=view.email)
|
||||||
view.membership,
|
|
||||||
display_name=view.display_name,
|
|
||||||
email=view.email,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@group.group_class('invitations', '/api/v1/invitations')
|
@group.group_class('invitations', '/api/v1/invitations')
|
||||||
|
|||||||
@@ -56,14 +56,6 @@ class KnowledgeService:
|
|||||||
require_workspace_uuid(context)
|
require_workspace_uuid(context)
|
||||||
# In new architecture, we delegate entirely to RAGManager which uses plugins.
|
# In new architecture, we delegate entirely to RAGManager which uses plugins.
|
||||||
# Legacy internal KB creation is removed.
|
# 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')
|
knowledge_engine_plugin_id = kb_data.get('knowledge_engine_plugin_id')
|
||||||
if not knowledge_engine_plugin_id:
|
if not knowledge_engine_plugin_id:
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import uuid
|
|||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
from langbot_plugin.api.entities.builtin.provider import message as provider_message
|
from langbot_plugin.api.entities.builtin.provider import message as provider_message
|
||||||
|
|
||||||
from ....cloud.model_catalog import LANGBOT_MODELS_PROVIDER_REQUESTER
|
|
||||||
from ....core import app
|
from ....core import app
|
||||||
from ....entity.persistence import model as persistence_model
|
from ....entity.persistence import model as persistence_model
|
||||||
from ....entity.persistence import pipeline as persistence_pipeline
|
from ....entity.persistence import pipeline as persistence_pipeline
|
||||||
@@ -114,23 +113,6 @@ async def _require_workspace_provider(
|
|||||||
return provider
|
return provider
|
||||||
|
|
||||||
|
|
||||||
def _is_cloud_runtime(ap: app.Application) -> bool:
|
|
||||||
mode = getattr(ap.persistence_mgr, 'mode', None)
|
|
||||||
return getattr(mode, 'value', None) == 'cloud_runtime'
|
|
||||||
|
|
||||||
|
|
||||||
async def _assert_cloud_managed_provider_mutable(
|
|
||||||
ap: app.Application,
|
|
||||||
context: TenantContext,
|
|
||||||
provider_uuid: str,
|
|
||||||
) -> None:
|
|
||||||
if not _is_cloud_runtime(ap):
|
|
||||||
return
|
|
||||||
provider = await _require_workspace_provider(ap, context, provider_uuid)
|
|
||||||
if provider.get('requester') == LANGBOT_MODELS_PROVIDER_REQUESTER:
|
|
||||||
raise ValueError('LangBot Models is managed by Cloud and cannot be modified')
|
|
||||||
|
|
||||||
|
|
||||||
async def _require_runtime_provider(
|
async def _require_runtime_provider(
|
||||||
ap: app.Application,
|
ap: app.Application,
|
||||||
context: TenantContext,
|
context: TenantContext,
|
||||||
@@ -231,7 +213,6 @@ class LLMModelsService:
|
|||||||
model_data['provider_uuid'] = provider_uuid
|
model_data['provider_uuid'] = provider_uuid
|
||||||
|
|
||||||
await _require_workspace_provider(self.ap, context, model_data['provider_uuid'])
|
await _require_workspace_provider(self.ap, context, model_data['provider_uuid'])
|
||||||
await _assert_cloud_managed_provider_mutable(self.ap, context, model_data['provider_uuid'])
|
|
||||||
await _validate_provider_supports(self.ap, context, model_data['provider_uuid'], 'llm')
|
await _validate_provider_supports(self.ap, context, model_data['provider_uuid'], 'llm')
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_model.LLMModel).values(**model_data))
|
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_model.LLMModel).values(**model_data))
|
||||||
@@ -310,17 +291,11 @@ class LLMModelsService:
|
|||||||
|
|
||||||
return model_dict
|
return model_dict
|
||||||
|
|
||||||
async def update_llm_model(
|
async def update_llm_model(self, context: TenantContext, model_uuid: str, model_data: dict) -> None:
|
||||||
self,
|
|
||||||
context: TenantContext,
|
|
||||||
model_uuid: str,
|
|
||||||
model_data: dict,
|
|
||||||
) -> None:
|
|
||||||
"""Update an existing LLM model"""
|
"""Update an existing LLM model"""
|
||||||
existing_model = await self.get_llm_model(context, model_uuid, include_secret=True)
|
existing_model = await self.get_llm_model(context, model_uuid, include_secret=True)
|
||||||
if existing_model is None:
|
if existing_model is None:
|
||||||
raise WorkspaceNotFoundError('Model not found')
|
raise WorkspaceNotFoundError('Model not found')
|
||||||
await _assert_cloud_managed_provider_mutable(self.ap, context, existing_model['provider_uuid'])
|
|
||||||
model_data = model_data.copy()
|
model_data = model_data.copy()
|
||||||
model_data.pop('uuid', None)
|
model_data.pop('uuid', None)
|
||||||
model_data.pop('workspace_uuid', None)
|
model_data.pop('workspace_uuid', None)
|
||||||
@@ -346,7 +321,6 @@ class LLMModelsService:
|
|||||||
|
|
||||||
provider_uuid = model_data.get('provider_uuid', existing_model['provider_uuid'])
|
provider_uuid = model_data.get('provider_uuid', existing_model['provider_uuid'])
|
||||||
await _require_workspace_provider(self.ap, context, provider_uuid)
|
await _require_workspace_provider(self.ap, context, provider_uuid)
|
||||||
await _assert_cloud_managed_provider_mutable(self.ap, context, provider_uuid)
|
|
||||||
await _validate_provider_supports(self.ap, context, provider_uuid, 'llm')
|
await _validate_provider_supports(self.ap, context, provider_uuid, 'llm')
|
||||||
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
@@ -381,11 +355,6 @@ class LLMModelsService:
|
|||||||
|
|
||||||
async def delete_llm_model(self, context: TenantContext, model_uuid: str) -> None:
|
async def delete_llm_model(self, context: TenantContext, model_uuid: str) -> None:
|
||||||
"""Delete an LLM model"""
|
"""Delete an LLM model"""
|
||||||
if _is_cloud_runtime(self.ap):
|
|
||||||
existing_model = await self.get_llm_model(context, model_uuid, include_secret=True)
|
|
||||||
if existing_model is None:
|
|
||||||
raise WorkspaceNotFoundError('Model not found')
|
|
||||||
await _assert_cloud_managed_provider_mutable(self.ap, context, existing_model['provider_uuid'])
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
scope_statement(
|
scope_statement(
|
||||||
sqlalchemy.delete(persistence_model.LLMModel).where(persistence_model.LLMModel.uuid == model_uuid),
|
sqlalchemy.delete(persistence_model.LLMModel).where(persistence_model.LLMModel.uuid == model_uuid),
|
||||||
@@ -479,10 +448,7 @@ class EmbeddingModelsService:
|
|||||||
return serialized if include_secret else [_redact_model_secrets(model) for model in serialized]
|
return serialized if include_secret else [_redact_model_secrets(model) for model in serialized]
|
||||||
|
|
||||||
async def create_embedding_model(
|
async def create_embedding_model(
|
||||||
self,
|
self, context: TenantContext, model_data: dict, preserve_uuid: bool = False
|
||||||
context: TenantContext,
|
|
||||||
model_data: dict,
|
|
||||||
preserve_uuid: bool = False,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Create a new embedding model"""
|
"""Create a new embedding model"""
|
||||||
model_data = model_data.copy()
|
model_data = model_data.copy()
|
||||||
@@ -506,7 +472,6 @@ class EmbeddingModelsService:
|
|||||||
model_data['provider_uuid'] = provider_uuid
|
model_data['provider_uuid'] = provider_uuid
|
||||||
|
|
||||||
await _require_workspace_provider(self.ap, context, model_data['provider_uuid'])
|
await _require_workspace_provider(self.ap, context, model_data['provider_uuid'])
|
||||||
await _assert_cloud_managed_provider_mutable(self.ap, context, model_data['provider_uuid'])
|
|
||||||
await _validate_provider_supports(self.ap, context, model_data['provider_uuid'], 'text-embedding')
|
await _validate_provider_supports(self.ap, context, model_data['provider_uuid'], 'text-embedding')
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
@@ -565,17 +530,11 @@ class EmbeddingModelsService:
|
|||||||
|
|
||||||
return model_dict
|
return model_dict
|
||||||
|
|
||||||
async def update_embedding_model(
|
async def update_embedding_model(self, context: TenantContext, model_uuid: str, model_data: dict) -> None:
|
||||||
self,
|
|
||||||
context: TenantContext,
|
|
||||||
model_uuid: str,
|
|
||||||
model_data: dict,
|
|
||||||
) -> None:
|
|
||||||
"""Update an existing embedding model"""
|
"""Update an existing embedding model"""
|
||||||
existing_model = await self.get_embedding_model(context, model_uuid, include_secret=True)
|
existing_model = await self.get_embedding_model(context, model_uuid, include_secret=True)
|
||||||
if existing_model is None:
|
if existing_model is None:
|
||||||
raise WorkspaceNotFoundError('Model not found')
|
raise WorkspaceNotFoundError('Model not found')
|
||||||
await _assert_cloud_managed_provider_mutable(self.ap, context, existing_model['provider_uuid'])
|
|
||||||
model_data = model_data.copy()
|
model_data = model_data.copy()
|
||||||
model_data.pop('uuid', None)
|
model_data.pop('uuid', None)
|
||||||
model_data.pop('workspace_uuid', None)
|
model_data.pop('workspace_uuid', None)
|
||||||
@@ -600,7 +559,6 @@ class EmbeddingModelsService:
|
|||||||
|
|
||||||
provider_uuid = model_data.get('provider_uuid', existing_model['provider_uuid'])
|
provider_uuid = model_data.get('provider_uuid', existing_model['provider_uuid'])
|
||||||
await _require_workspace_provider(self.ap, context, provider_uuid)
|
await _require_workspace_provider(self.ap, context, provider_uuid)
|
||||||
await _assert_cloud_managed_provider_mutable(self.ap, context, provider_uuid)
|
|
||||||
await _validate_provider_supports(self.ap, context, provider_uuid, 'text-embedding')
|
await _validate_provider_supports(self.ap, context, provider_uuid, 'text-embedding')
|
||||||
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
@@ -635,11 +593,6 @@ class EmbeddingModelsService:
|
|||||||
|
|
||||||
async def delete_embedding_model(self, context: TenantContext, model_uuid: str) -> None:
|
async def delete_embedding_model(self, context: TenantContext, model_uuid: str) -> None:
|
||||||
"""Delete an embedding model"""
|
"""Delete an embedding model"""
|
||||||
if _is_cloud_runtime(self.ap):
|
|
||||||
existing_model = await self.get_embedding_model(context, model_uuid, include_secret=True)
|
|
||||||
if existing_model is None:
|
|
||||||
raise WorkspaceNotFoundError('Model not found')
|
|
||||||
await _assert_cloud_managed_provider_mutable(self.ap, context, existing_model['provider_uuid'])
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
scope_statement(
|
scope_statement(
|
||||||
sqlalchemy.delete(persistence_model.EmbeddingModel).where(
|
sqlalchemy.delete(persistence_model.EmbeddingModel).where(
|
||||||
@@ -732,12 +685,7 @@ class RerankModelsService:
|
|||||||
serialized = [self.ap.persistence_mgr.serialize_model(persistence_model.RerankModel, m) for m in models]
|
serialized = [self.ap.persistence_mgr.serialize_model(persistence_model.RerankModel, m) for m in models]
|
||||||
return serialized if include_secret else [_redact_model_secrets(model) for model in serialized]
|
return serialized if include_secret else [_redact_model_secrets(model) for model in serialized]
|
||||||
|
|
||||||
async def create_rerank_model(
|
async def create_rerank_model(self, context: TenantContext, model_data: dict, preserve_uuid: bool = False) -> str:
|
||||||
self,
|
|
||||||
context: TenantContext,
|
|
||||||
model_data: dict,
|
|
||||||
preserve_uuid: bool = False,
|
|
||||||
) -> str:
|
|
||||||
"""Create a new rerank model"""
|
"""Create a new rerank model"""
|
||||||
model_data = model_data.copy()
|
model_data = model_data.copy()
|
||||||
if not preserve_uuid:
|
if not preserve_uuid:
|
||||||
@@ -760,7 +708,6 @@ class RerankModelsService:
|
|||||||
model_data['provider_uuid'] = provider_uuid
|
model_data['provider_uuid'] = provider_uuid
|
||||||
|
|
||||||
await _require_workspace_provider(self.ap, context, model_data['provider_uuid'])
|
await _require_workspace_provider(self.ap, context, model_data['provider_uuid'])
|
||||||
await _assert_cloud_managed_provider_mutable(self.ap, context, model_data['provider_uuid'])
|
|
||||||
await _validate_provider_supports(self.ap, context, model_data['provider_uuid'], 'rerank')
|
await _validate_provider_supports(self.ap, context, model_data['provider_uuid'], 'rerank')
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
@@ -819,17 +766,11 @@ class RerankModelsService:
|
|||||||
|
|
||||||
return model_dict
|
return model_dict
|
||||||
|
|
||||||
async def update_rerank_model(
|
async def update_rerank_model(self, context: TenantContext, model_uuid: str, model_data: dict) -> None:
|
||||||
self,
|
|
||||||
context: TenantContext,
|
|
||||||
model_uuid: str,
|
|
||||||
model_data: dict,
|
|
||||||
) -> None:
|
|
||||||
"""Update an existing rerank model"""
|
"""Update an existing rerank model"""
|
||||||
existing_model = await self.get_rerank_model(context, model_uuid, include_secret=True)
|
existing_model = await self.get_rerank_model(context, model_uuid, include_secret=True)
|
||||||
if existing_model is None:
|
if existing_model is None:
|
||||||
raise WorkspaceNotFoundError('Model not found')
|
raise WorkspaceNotFoundError('Model not found')
|
||||||
await _assert_cloud_managed_provider_mutable(self.ap, context, existing_model['provider_uuid'])
|
|
||||||
model_data = model_data.copy()
|
model_data = model_data.copy()
|
||||||
model_data.pop('uuid', None)
|
model_data.pop('uuid', None)
|
||||||
model_data.pop('workspace_uuid', None)
|
model_data.pop('workspace_uuid', None)
|
||||||
@@ -854,7 +795,6 @@ class RerankModelsService:
|
|||||||
|
|
||||||
provider_uuid = model_data.get('provider_uuid', existing_model['provider_uuid'])
|
provider_uuid = model_data.get('provider_uuid', existing_model['provider_uuid'])
|
||||||
await _require_workspace_provider(self.ap, context, provider_uuid)
|
await _require_workspace_provider(self.ap, context, provider_uuid)
|
||||||
await _assert_cloud_managed_provider_mutable(self.ap, context, provider_uuid)
|
|
||||||
await _validate_provider_supports(self.ap, context, provider_uuid, 'rerank')
|
await _validate_provider_supports(self.ap, context, provider_uuid, 'rerank')
|
||||||
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
@@ -889,11 +829,6 @@ class RerankModelsService:
|
|||||||
|
|
||||||
async def delete_rerank_model(self, context: TenantContext, model_uuid: str) -> None:
|
async def delete_rerank_model(self, context: TenantContext, model_uuid: str) -> None:
|
||||||
"""Delete a rerank model"""
|
"""Delete a rerank model"""
|
||||||
if _is_cloud_runtime(self.ap):
|
|
||||||
existing_model = await self.get_rerank_model(context, model_uuid, include_secret=True)
|
|
||||||
if existing_model is None:
|
|
||||||
raise WorkspaceNotFoundError('Model not found')
|
|
||||||
await _assert_cloud_managed_provider_mutable(self.ap, context, existing_model['provider_uuid'])
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
scope_statement(
|
scope_statement(
|
||||||
sqlalchemy.delete(persistence_model.RerankModel).where(
|
sqlalchemy.delete(persistence_model.RerankModel).where(
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import traceback
|
|||||||
|
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
|
|
||||||
from ....cloud.model_catalog import LANGBOT_MODELS_PROVIDER_REQUESTER
|
|
||||||
from ....core import app
|
from ....core import app
|
||||||
from ....entity.persistence import model as persistence_model
|
from ....entity.persistence import model as persistence_model
|
||||||
from ....workspace.errors import WorkspaceNotFoundError
|
from ....workspace.errors import WorkspaceNotFoundError
|
||||||
@@ -21,20 +20,6 @@ class ModelProviderService:
|
|||||||
def __init__(self, ap: app.Application) -> None:
|
def __init__(self, ap: app.Application) -> None:
|
||||||
self.ap = ap
|
self.ap = ap
|
||||||
|
|
||||||
def _is_cloud_runtime(self) -> bool:
|
|
||||||
mode = getattr(self.ap.persistence_mgr, 'mode', None)
|
|
||||||
return getattr(mode, 'value', None) == 'cloud_runtime'
|
|
||||||
|
|
||||||
def _system_requester_is_reserved(self, requester: object) -> bool:
|
|
||||||
return self._is_cloud_runtime() and requester == LANGBOT_MODELS_PROVIDER_REQUESTER
|
|
||||||
|
|
||||||
async def _assert_provider_mutable(self, context: TenantContext, provider_uuid: str) -> None:
|
|
||||||
if not self._is_cloud_runtime():
|
|
||||||
return
|
|
||||||
provider = await self.get_provider(context, provider_uuid)
|
|
||||||
if provider is not None and self._system_requester_is_reserved(provider.get('requester')):
|
|
||||||
raise ValueError('LangBot Models is managed by Cloud and cannot be modified')
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_api_keys(api_keys: str | list[str] | tuple[str, ...] | None) -> list[str]:
|
def _normalize_api_keys(api_keys: str | list[str] | tuple[str, ...] | None) -> list[str]:
|
||||||
if api_keys is None:
|
if api_keys is None:
|
||||||
@@ -114,8 +99,6 @@ class ModelProviderService:
|
|||||||
async def create_provider(self, context: TenantContext, provider_data: dict) -> str:
|
async def create_provider(self, context: TenantContext, provider_data: dict) -> str:
|
||||||
"""Create a new provider"""
|
"""Create a new provider"""
|
||||||
provider_data = provider_data.copy()
|
provider_data = provider_data.copy()
|
||||||
if self._system_requester_is_reserved(provider_data.get('requester')):
|
|
||||||
raise ValueError('space-chat-completions is reserved for the Cloud-managed LangBot Models provider')
|
|
||||||
provider_data['uuid'] = str(uuid.uuid4())
|
provider_data['uuid'] = str(uuid.uuid4())
|
||||||
provider_data['workspace_uuid'] = require_workspace_uuid(context)
|
provider_data['workspace_uuid'] = require_workspace_uuid(context)
|
||||||
provider_data['api_keys'] = self._normalize_api_keys(
|
provider_data['api_keys'] = self._normalize_api_keys(
|
||||||
@@ -132,10 +115,7 @@ class ModelProviderService:
|
|||||||
|
|
||||||
async def update_provider(self, context: TenantContext, provider_uuid: str, provider_data: dict) -> None:
|
async def update_provider(self, context: TenantContext, provider_uuid: str, provider_data: dict) -> None:
|
||||||
"""Update an existing provider"""
|
"""Update an existing provider"""
|
||||||
await self._assert_provider_mutable(context, provider_uuid)
|
|
||||||
provider_data = provider_data.copy()
|
provider_data = provider_data.copy()
|
||||||
if self._system_requester_is_reserved(provider_data.get('requester')):
|
|
||||||
raise ValueError('space-chat-completions is reserved for the Cloud-managed LangBot Models provider')
|
|
||||||
provider_data.pop('uuid', None)
|
provider_data.pop('uuid', None)
|
||||||
provider_data.pop('workspace_uuid', None)
|
provider_data.pop('workspace_uuid', None)
|
||||||
if 'api_keys' in provider_data:
|
if 'api_keys' in provider_data:
|
||||||
@@ -165,7 +145,6 @@ class ModelProviderService:
|
|||||||
|
|
||||||
async def delete_provider(self, context: TenantContext, provider_uuid: str) -> None:
|
async def delete_provider(self, context: TenantContext, provider_uuid: str) -> None:
|
||||||
"""Delete a provider (only if no models reference it)"""
|
"""Delete a provider (only if no models reference it)"""
|
||||||
await self._assert_provider_mutable(context, provider_uuid)
|
|
||||||
workspace_uuid = require_workspace_uuid(context)
|
workspace_uuid = require_workspace_uuid(context)
|
||||||
# Check if any models use this provider
|
# Check if any models use this provider
|
||||||
llm_result = await self.ap.persistence_mgr.execute_async(
|
llm_result = await self.ap.persistence_mgr.execute_async(
|
||||||
@@ -266,8 +245,6 @@ class ModelProviderService:
|
|||||||
api_keys: list,
|
api_keys: list,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Find existing provider or create new one"""
|
"""Find existing provider or create new one"""
|
||||||
if self._system_requester_is_reserved(requester):
|
|
||||||
raise ValueError('space-chat-completions is reserved for the Cloud-managed LangBot Models provider')
|
|
||||||
workspace_uuid = require_workspace_uuid(context)
|
workspace_uuid = require_workspace_uuid(context)
|
||||||
api_keys = self._normalize_api_keys(restore_secret_placeholders(api_keys, sensitive=True))
|
api_keys = self._normalize_api_keys(restore_secret_placeholders(api_keys, sensitive=True))
|
||||||
|
|
||||||
@@ -354,15 +331,8 @@ class ModelProviderService:
|
|||||||
embedding_models = await self.ap.embedding_models_service.get_embedding_models_by_provider(
|
embedding_models = await self.ap.embedding_models_service.get_embedding_models_by_provider(
|
||||||
context, provider_uuid
|
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_llm_names = {model['name'] for model in llm_models}
|
||||||
existing_embedding_names = {model['name'] for model in embedding_models}
|
existing_embedding_names = {model['name'] for model in embedding_models}
|
||||||
existing_rerank_names = {model['name'] for model in rerank_models}
|
|
||||||
|
|
||||||
filtered_models = []
|
filtered_models = []
|
||||||
for model in scanned_models:
|
for model in scanned_models:
|
||||||
@@ -389,8 +359,6 @@ class ModelProviderService:
|
|||||||
'already_added': (
|
'already_added': (
|
||||||
model_name in existing_embedding_names
|
model_name in existing_embedding_names
|
||||||
if scanned_type == 'embedding'
|
if scanned_type == 'embedding'
|
||||||
else model_name in existing_rerank_names
|
|
||||||
if scanned_type == 'rerank'
|
|
||||||
else model_name in existing_llm_names
|
else model_name in existing_llm_names
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from urllib.parse import quote, unquote, urlparse
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
from ....cloud.entitlements import EntitlementFeatureUnavailableError
|
||||||
from ....core import app
|
from ....core import app
|
||||||
from ....skill.utils import parse_frontmatter
|
from ....skill.utils import parse_frontmatter
|
||||||
from ....utils import httpclient
|
from ....utils import httpclient
|
||||||
@@ -119,7 +120,13 @@ class SkillService:
|
|||||||
box_service = self._box_service()
|
box_service = self._box_service()
|
||||||
if box_service is None:
|
if box_service is None:
|
||||||
return []
|
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]:
|
async def get_skill(self, context: TenantContext, skill_name: str) -> Optional[dict]:
|
||||||
execution_context = await self._execution_context(context)
|
execution_context = await self._execution_context(context)
|
||||||
|
|||||||
@@ -59,10 +59,6 @@ class SpaceService:
|
|||||||
result_list = result.all()
|
result_list = result.all()
|
||||||
return result_list[0] if result_list else None
|
return result_list[0] if result_list else None
|
||||||
|
|
||||||
async def get_valid_access_token(self, user_email: str) -> str | None:
|
|
||||||
"""Return a current Space bearer, refreshing and persisting it when needed."""
|
|
||||||
return await self._ensure_valid_token(user_email)
|
|
||||||
|
|
||||||
async def _ensure_valid_token(self, user_email: str) -> str | None:
|
async def _ensure_valid_token(self, user_email: str) -> str | None:
|
||||||
"""Ensure access token is valid, refresh if expired. Returns valid access_token or None."""
|
"""Ensure access token is valid, refresh if expired. Returns valid access_token or None."""
|
||||||
user_obj = await self._get_user_by_email(user_email)
|
user_obj = await self._get_user_by_email(user_email)
|
||||||
@@ -121,12 +117,7 @@ class SpaceService:
|
|||||||
params['state'] = state
|
params['state'] = state
|
||||||
return f'{authorize_url}?{urlencode(params)}'
|
return f'{authorize_url}?{urlencode(params)}'
|
||||||
|
|
||||||
async def exchange_oauth_code(
|
async def exchange_oauth_code(self, code: str) -> typing.Dict:
|
||||||
self,
|
|
||||||
code: str,
|
|
||||||
workspace_uuids: list[str] | None = None,
|
|
||||||
workspace_created_ats: dict[str, int] | None = None,
|
|
||||||
) -> typing.Dict:
|
|
||||||
"""Exchange OAuth authorization code for tokens"""
|
"""Exchange OAuth authorization code for tokens"""
|
||||||
from langbot.pkg.utils import constants
|
from langbot.pkg.utils import constants
|
||||||
|
|
||||||
@@ -136,14 +127,7 @@ class SpaceService:
|
|||||||
session = httpclient.get_session()
|
session = httpclient.get_session()
|
||||||
async with session.post(
|
async with session.post(
|
||||||
f'{space_url}/api/v1/accounts/oauth/token',
|
f'{space_url}/api/v1/accounts/oauth/token',
|
||||||
json={
|
json={'code': code, 'instance_id': constants.instance_id},
|
||||||
'code': code,
|
|
||||||
'instance_id': constants.instance_id,
|
|
||||||
# Sending an explicit empty list tells new Space servers not to
|
|
||||||
# synthesize a legacy instance-derived Workspace binding.
|
|
||||||
'workspace_uuids': workspace_uuids if workspace_uuids is not None else [],
|
|
||||||
'workspace_created_ats': workspace_created_ats or {},
|
|
||||||
},
|
|
||||||
) as response:
|
) as response:
|
||||||
if response.status != 200:
|
if response.status != 200:
|
||||||
error = await httpclient.read_text_limited(response)
|
error = await httpclient.read_text_limited(response)
|
||||||
|
|||||||
@@ -400,10 +400,7 @@ class UserService:
|
|||||||
|
|
||||||
return await self.generate_jwt_token(user_obj)
|
return await self.generate_jwt_token(user_obj)
|
||||||
|
|
||||||
async def generate_jwt_token(
|
async def generate_jwt_token(self, account: user.User | str) -> str:
|
||||||
self,
|
|
||||||
account: user.User | str,
|
|
||||||
) -> str:
|
|
||||||
jwt_secret = self.ap.instance_config.data['system']['jwt']['secret']
|
jwt_secret = self.ap.instance_config.data['system']['jwt']['secret']
|
||||||
jwt_expire = self.ap.instance_config.data['system']['jwt']['expire']
|
jwt_expire = self.ap.instance_config.data['system']['jwt']['expire']
|
||||||
|
|
||||||
@@ -416,7 +413,7 @@ class UserService:
|
|||||||
# Lightweight unit-test and bootstrap callers may not have persistence wired.
|
# Lightweight unit-test and bootstrap callers may not have persistence wired.
|
||||||
account_obj = None
|
account_obj = None
|
||||||
|
|
||||||
payload: dict[str, typing.Any] = {
|
payload = {
|
||||||
'user': user_email,
|
'user': user_email,
|
||||||
'iss': self._jwt_identity()[0],
|
'iss': self._jwt_identity()[0],
|
||||||
'aud': self._jwt_identity()[1],
|
'aud': self._jwt_identity()[1],
|
||||||
@@ -779,27 +776,8 @@ class UserService:
|
|||||||
local_account = await self.get_user_by_email(user_email)
|
local_account = await self.get_user_by_email(user_email)
|
||||||
if local_account is None:
|
if local_account is None:
|
||||||
raise ValueError('User not found')
|
raise ValueError('User not found')
|
||||||
# Exchange code for tokens and bind both installation and the active
|
# Exchange code for tokens
|
||||||
# OSS Workspace as independent identities.
|
token_data = await self.ap.space_service.exchange_oauth_code(code)
|
||||||
workspace_service = getattr(self.ap, 'workspace_service', None)
|
|
||||||
if workspace_service is not None:
|
|
||||||
binding = await workspace_service.get_execution_binding()
|
|
||||||
created_at = binding.workspace_created_at
|
|
||||||
created_ts = (
|
|
||||||
int(created_at.replace(tzinfo=datetime.timezone.utc).timestamp())
|
|
||||||
if created_at.tzinfo is None
|
|
||||||
else int(created_at.timestamp())
|
|
||||||
)
|
|
||||||
token_data = await self.ap.space_service.exchange_oauth_code(
|
|
||||||
code,
|
|
||||||
[binding.workspace_uuid],
|
|
||||||
{binding.workspace_uuid: created_ts},
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Compatibility for early/bootstrap call sites that have not wired
|
|
||||||
# WorkspaceService yet; old Space servers still derive the legacy
|
|
||||||
# Workspace identity from instance_id when the field is omitted.
|
|
||||||
token_data = await self.ap.space_service.exchange_oauth_code(code)
|
|
||||||
access_token = token_data.get('access_token')
|
access_token = token_data.get('access_token')
|
||||||
refresh_token = token_data.get('refresh_token')
|
refresh_token = token_data.get('refresh_token')
|
||||||
expires_in = token_data.get('expires_in', 0)
|
expires_in = token_data.get('expires_in', 0)
|
||||||
|
|||||||
@@ -13,12 +13,11 @@ from typing import Any, Protocol, runtime_checkable
|
|||||||
from ..workspace.policy import CloudWorkspacePolicy, SingleWorkspacePolicy
|
from ..workspace.policy import CloudWorkspacePolicy, SingleWorkspacePolicy
|
||||||
from .directory import DirectoryProjectionProvider, directory_projection_limits_from_config
|
from .directory import DirectoryProjectionProvider, directory_projection_limits_from_config
|
||||||
from .entitlements import EntitlementProvider, OpenSourceEntitlementProvider
|
from .entitlements import EntitlementProvider, OpenSourceEntitlementProvider
|
||||||
from .model_catalog import CloudModelCatalogProvider
|
|
||||||
|
|
||||||
|
|
||||||
CLOUD_BOOTSTRAP_ENTRY_POINT = 'langbot.cloud_bootstrap'
|
CLOUD_BOOTSTRAP_ENTRY_POINT = 'langbot.cloud_bootstrap'
|
||||||
REQUIRED_TENANT_ISOLATION_VERSION = 2
|
REQUIRED_TENANT_ISOLATION_VERSION = 2
|
||||||
SUPPORTED_PGVECTOR_DIMENSIONS = frozenset({384, 512, 768, 1024, 1536, 3072})
|
SUPPORTED_PGVECTOR_DIMENSIONS = frozenset({384, 512, 768, 1024, 1536})
|
||||||
|
|
||||||
|
|
||||||
class CloudBootstrapError(RuntimeError):
|
class CloudBootstrapError(RuntimeError):
|
||||||
@@ -51,7 +50,6 @@ class OpenSourceDeployment:
|
|||||||
)
|
)
|
||||||
directory_provider: None = None
|
directory_provider: None = None
|
||||||
manifest_provider: None = None
|
manifest_provider: None = None
|
||||||
model_catalog_provider: None = None
|
|
||||||
persistence_mode: str = 'oss_compat'
|
persistence_mode: str = 'oss_compat'
|
||||||
required_vector_backend: str | None = None
|
required_vector_backend: str | None = None
|
||||||
|
|
||||||
@@ -82,7 +80,6 @@ class VerifiedCloudDeployment:
|
|||||||
entitlement_provider: EntitlementProvider
|
entitlement_provider: EntitlementProvider
|
||||||
directory_provider: DirectoryProjectionProvider
|
directory_provider: DirectoryProjectionProvider
|
||||||
manifest_provider: CloudManifestProvider
|
manifest_provider: CloudManifestProvider
|
||||||
model_catalog_provider: CloudModelCatalogProvider
|
|
||||||
verification_key_id: str
|
verification_key_id: str
|
||||||
mode: str = dataclasses.field(default='cloud', init=False)
|
mode: str = dataclasses.field(default='cloud', init=False)
|
||||||
workspace_policy: CloudWorkspacePolicy = dataclasses.field(default_factory=CloudWorkspacePolicy, init=False)
|
workspace_policy: CloudWorkspacePolicy = dataclasses.field(default_factory=CloudWorkspacePolicy, init=False)
|
||||||
@@ -113,8 +110,6 @@ class VerifiedCloudDeployment:
|
|||||||
raise CloudBootstrapError('Verified Cloud bootstrap did not provide a directory adapter')
|
raise CloudBootstrapError('Verified Cloud bootstrap did not provide a directory adapter')
|
||||||
if not isinstance(self.manifest_provider, CloudManifestProvider):
|
if not isinstance(self.manifest_provider, CloudManifestProvider):
|
||||||
raise CloudBootstrapError('Verified Cloud bootstrap did not provide a Manifest renewal adapter')
|
raise CloudBootstrapError('Verified Cloud bootstrap did not provide a Manifest renewal adapter')
|
||||||
if not isinstance(self.model_catalog_provider, CloudModelCatalogProvider):
|
|
||||||
raise CloudBootstrapError('Verified Cloud bootstrap did not provide a model catalog adapter')
|
|
||||||
|
|
||||||
def validate_instance_config(self, config: dict[str, Any]) -> None:
|
def validate_instance_config(self, config: dict[str, Any]) -> None:
|
||||||
try:
|
try:
|
||||||
@@ -143,14 +138,8 @@ class VerifiedCloudDeployment:
|
|||||||
if plugin_worker.get('require_hard_limits') is not True:
|
if plugin_worker.get('require_hard_limits') is not True:
|
||||||
raise CloudBootstrapError('Cloud Runtime requires plugin.worker.require_hard_limits=true')
|
raise CloudBootstrapError('Cloud Runtime requires plugin.worker.require_hard_limits=true')
|
||||||
box_config = config.get('box', {})
|
box_config = config.get('box', {})
|
||||||
box_enabled = box_config.get('enabled')
|
if box_config.get('enabled') is not True:
|
||||||
if box_enabled is False:
|
raise CloudBootstrapError('Cloud runtime requires box.enabled=true')
|
||||||
# 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('backend') != 'nsjail':
|
if box_config.get('backend') != 'nsjail':
|
||||||
raise CloudBootstrapError('Cloud runtime requires box.backend=nsjail')
|
raise CloudBootstrapError('Cloud runtime requires box.backend=nsjail')
|
||||||
runtime_endpoint = str(box_config.get('runtime', {}).get('endpoint', '') or '').strip()
|
runtime_endpoint = str(box_config.get('runtime', {}).get('endpoint', '') or '').strip()
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ from ..entity.persistence.cloud_directory import DirectoryProjectionInbox, Direc
|
|||||||
from ..entity.persistence.user import AccountSource, AccountStatus, User
|
from ..entity.persistence.user import AccountSource, AccountStatus, User
|
||||||
from ..entity.persistence.workspace import (
|
from ..entity.persistence.workspace import (
|
||||||
MembershipRole,
|
MembershipRole,
|
||||||
MembershipSource,
|
|
||||||
MembershipStatus,
|
MembershipStatus,
|
||||||
Workspace,
|
Workspace,
|
||||||
WorkspaceExecutionSource,
|
WorkspaceExecutionSource,
|
||||||
@@ -359,7 +358,6 @@ class DirectoryProjectionService:
|
|||||||
|
|
||||||
await self._reconcile_entitlement_snapshot_set(snapshot)
|
await self._reconcile_entitlement_snapshot_set(snapshot)
|
||||||
self._publish_runtime_execution_projection(snapshot.workspaces)
|
self._publish_runtime_execution_projection(snapshot.workspaces)
|
||||||
self._request_model_catalog_sync()
|
|
||||||
self._record_batch_cardinality(
|
self._record_batch_cardinality(
|
||||||
active_workspaces=active_workspace_count,
|
active_workspaces=active_workspace_count,
|
||||||
workspaces=workspace_count,
|
workspaces=workspace_count,
|
||||||
@@ -468,7 +466,6 @@ class DirectoryProjectionService:
|
|||||||
returned.values(),
|
returned.values(),
|
||||||
affected_workspace_uuids=requested,
|
affected_workspace_uuids=requested,
|
||||||
)
|
)
|
||||||
self._request_model_catalog_sync()
|
|
||||||
self._record_batch_cardinality(
|
self._record_batch_cardinality(
|
||||||
active_workspaces=active_workspace_count,
|
active_workspaces=active_workspace_count,
|
||||||
workspaces=workspace_count,
|
workspaces=workspace_count,
|
||||||
@@ -478,14 +475,6 @@ class DirectoryProjectionService:
|
|||||||
self._record_success()
|
self._record_success()
|
||||||
self._consumer_cursor = batch.cursor
|
self._consumer_cursor = batch.cursor
|
||||||
|
|
||||||
def _request_model_catalog_sync(self) -> None:
|
|
||||||
"""Wake model provisioning after a committed directory change."""
|
|
||||||
|
|
||||||
service = getattr(self.ap, 'cloud_model_catalog_service', None)
|
|
||||||
request_sync = getattr(service, 'request_sync', None)
|
|
||||||
if callable(request_sync):
|
|
||||||
request_sync()
|
|
||||||
|
|
||||||
def _publish_runtime_execution_projection(
|
def _publish_runtime_execution_projection(
|
||||||
self,
|
self,
|
||||||
workspaces: Iterable[DirectoryWorkspace],
|
workspaces: Iterable[DirectoryWorkspace],
|
||||||
@@ -887,15 +876,15 @@ class DirectoryProjectionService:
|
|||||||
account_uuid=member.account_uuid,
|
account_uuid=member.account_uuid,
|
||||||
role=role,
|
role=role,
|
||||||
status=status,
|
status=status,
|
||||||
source=MembershipSource.CLOUD_PROJECTION.value,
|
|
||||||
joined_at=joined_at,
|
joined_at=joined_at,
|
||||||
projection_revision=member.projection_revision,
|
projection_revision=member.projection_revision,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
if membership.source != MembershipSource.CLOUD_PROJECTION.value:
|
if membership.projection_revision == 0:
|
||||||
# Core-owned collaboration state is never adopted based on
|
# Revision zero is Core-owned collaboration state. Directory
|
||||||
# account provenance, revision, or matching account identity.
|
# projection seeds memberships, but must not overwrite later
|
||||||
|
# invitation, role, or removal decisions made by Core.
|
||||||
continue
|
continue
|
||||||
if membership.uuid != member.membership_uuid:
|
if membership.uuid != member.membership_uuid:
|
||||||
raise DirectoryProjectionUnavailableError('Directory membership UUID changed for one account')
|
raise DirectoryProjectionUnavailableError('Directory membership UUID changed for one account')
|
||||||
@@ -907,12 +896,11 @@ class DirectoryProjectionService:
|
|||||||
raise DirectoryProjectionUnavailableError('Directory membership revision has conflicting contents')
|
raise DirectoryProjectionUnavailableError('Directory membership revision has conflicting contents')
|
||||||
membership.role = role
|
membership.role = role
|
||||||
membership.status = status
|
membership.status = status
|
||||||
membership.source = MembershipSource.CLOUD_PROJECTION.value
|
|
||||||
membership.joined_at = joined_at
|
membership.joined_at = joined_at
|
||||||
membership.projection_revision = member.projection_revision
|
membership.projection_revision = member.projection_revision
|
||||||
|
|
||||||
for account_uuid, membership in existing.items():
|
for account_uuid, membership in existing.items():
|
||||||
if account_uuid not in included_accounts and membership.source == MembershipSource.CLOUD_PROJECTION.value:
|
if account_uuid not in included_accounts and membership.projection_revision != 0:
|
||||||
membership.status = MembershipStatus.REMOVED.value
|
membership.status = MembershipStatus.REMOVED.value
|
||||||
membership.projection_revision = max(
|
membership.projection_revision = max(
|
||||||
int(membership.projection_revision),
|
int(membership.projection_revision),
|
||||||
|
|||||||
@@ -18,19 +18,11 @@ class EntitlementUnavailableError(RuntimeError):
|
|||||||
|
|
||||||
|
|
||||||
class EntitlementFeatureUnavailableError(EntitlementUnavailableError):
|
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__(
|
def __init__(self, message: str, *, feature: str, entitlement_revision: int | None = None) -> None:
|
||||||
self,
|
super().__init__(message, entitlement_revision=entitlement_revision)
|
||||||
feature: str,
|
|
||||||
*,
|
|
||||||
entitlement_revision: int | None = None,
|
|
||||||
) -> None:
|
|
||||||
self.feature = feature
|
self.feature = feature
|
||||||
super().__init__(
|
|
||||||
f'Workspace entitlement does not grant {feature}',
|
|
||||||
entitlement_revision=entitlement_revision,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class EntitlementSnapshot(pydantic.BaseModel):
|
class EntitlementSnapshot(pydantic.BaseModel):
|
||||||
@@ -99,7 +91,8 @@ class EntitlementSnapshot(pydantic.BaseModel):
|
|||||||
def require_feature(self, feature: str) -> None:
|
def require_feature(self, feature: str) -> None:
|
||||||
if self.features.get(feature) is not True:
|
if self.features.get(feature) is not True:
|
||||||
raise EntitlementFeatureUnavailableError(
|
raise EntitlementFeatureUnavailableError(
|
||||||
feature,
|
f'Workspace entitlement does not grant {feature}',
|
||||||
|
feature=feature,
|
||||||
entitlement_revision=self.entitlement_revision,
|
entitlement_revision=self.entitlement_revision,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -15,15 +15,12 @@ from cryptography.exceptions import InvalidSignature
|
|||||||
from cryptography.hazmat.primitives import serialization
|
from cryptography.hazmat.primitives import serialization
|
||||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||||
|
|
||||||
from .support_admin import SupportAdminReplayError, SupportAdminSessionError, hash_grant_jti
|
|
||||||
|
|
||||||
if typing.TYPE_CHECKING:
|
if typing.TYPE_CHECKING:
|
||||||
from ..core.app import Application
|
from ..core.app import Application
|
||||||
|
|
||||||
|
|
||||||
CONTROL_PLANE_TYP = 'langbot-control-plane+jwt'
|
CONTROL_PLANE_TYP = 'langbot-control-plane+jwt'
|
||||||
LAUNCH_KIND = 'workspace.launch'
|
LAUNCH_KIND = 'workspace.launch'
|
||||||
SUPPORT_ADMIN_LAUNCH_KIND = 'workspace.support_admin_launch'
|
|
||||||
EXPECTED_ISSUER = 'langbot-space'
|
EXPECTED_ISSUER = 'langbot-space'
|
||||||
EXPECTED_AUDIENCE = 'langbot-cloud-runtime'
|
EXPECTED_AUDIENCE = 'langbot-cloud-runtime'
|
||||||
_CONSUMED_JTI_MAX_ENTRIES = 4096
|
_CONSUMED_JTI_MAX_ENTRIES = 4096
|
||||||
@@ -126,60 +123,15 @@ class SpaceLaunchService:
|
|||||||
payload = claims.get('payload')
|
payload = claims.get('payload')
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
raise SpaceLaunchError('Launch assertion payload must be a JSON object')
|
raise SpaceLaunchError('Launch assertion payload must be a JSON object')
|
||||||
kind = _required_string(claims, 'kind')
|
account_uuid = _required_string(payload, 'account_uuid')
|
||||||
workspace_uuid = _required_string(payload, 'workspace_uuid')
|
workspace_uuid = _required_string(payload, 'workspace_uuid')
|
||||||
if expected_workspace_uuid is not None and workspace_uuid != expected_workspace_uuid:
|
if expected_workspace_uuid is not None and workspace_uuid != expected_workspace_uuid:
|
||||||
raise SpaceLaunchError('Launch assertion targets another Workspace')
|
raise SpaceLaunchError('Launch assertion targets another Workspace')
|
||||||
if kind == SUPPORT_ADMIN_LAUNCH_KIND:
|
await self._consume_jti(_required_string(claims, 'jti'), _required_int(claims, 'exp', minimum=1))
|
||||||
if 'account_uuid' in payload:
|
return {
|
||||||
raise SpaceLaunchError('Admin launch assertion must not identify a customer Account')
|
|
||||||
if payload.get('launch_mode') != 'support_admin' or payload.get('principal_type') != 'support_admin':
|
|
||||||
raise SpaceLaunchError('Admin launch principal must be support_admin')
|
|
||||||
actor_account_uuid = _required_string(payload, 'actor_account_uuid')
|
|
||||||
if _required_string(payload, 'effective_role') != 'owner':
|
|
||||||
raise SpaceLaunchError('Admin launch effective role must be owner')
|
|
||||||
issued_at = _required_int(claims, 'iat')
|
|
||||||
expires_at = _required_int(claims, 'exp', minimum=1)
|
|
||||||
if expires_at - issued_at > 90:
|
|
||||||
raise SpaceLaunchError('Admin launch assertion lifetime exceeds 90 seconds')
|
|
||||||
grant_jti_hash = hash_grant_jti(_required_string(claims, 'jti'))
|
|
||||||
result = {
|
|
||||||
'workspace_uuid': workspace_uuid,
|
|
||||||
'launch_mode': 'support_admin',
|
|
||||||
'actor_account_uuid': actor_account_uuid,
|
|
||||||
'effective_role': 'owner',
|
|
||||||
'grant_jti_hash': grant_jti_hash,
|
|
||||||
}
|
|
||||||
support_service = getattr(self.ap, 'support_admin_session_service', None)
|
|
||||||
if support_service is None or not callable(getattr(support_service, 'consume_launch_grant', None)):
|
|
||||||
raise SpaceLaunchError('Durable support admin session service is unavailable')
|
|
||||||
try:
|
|
||||||
support_session = await support_service.consume_launch_grant(
|
|
||||||
grant_jti_hash=grant_jti_hash,
|
|
||||||
workspace_uuid=workspace_uuid,
|
|
||||||
actor_account_uuid=actor_account_uuid,
|
|
||||||
)
|
|
||||||
except SupportAdminReplayError as exc:
|
|
||||||
raise SpaceLaunchError('Launch assertion has already been consumed') from exc
|
|
||||||
except SupportAdminSessionError as exc:
|
|
||||||
raise SpaceLaunchError(str(exc)) from exc
|
|
||||||
result['support_admin_token'] = support_session.token
|
|
||||||
self.ap.logger.info(
|
|
||||||
'cloud_support_admin_launch_consumed actor_account_uuid=%s workspace_uuid=%s',
|
|
||||||
result['actor_account_uuid'],
|
|
||||||
workspace_uuid,
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
if payload.get('launch_mode') is not None:
|
|
||||||
raise SpaceLaunchError('Launch assertion mode is unsupported')
|
|
||||||
account_uuid = _required_string(payload, 'account_uuid')
|
|
||||||
result = {
|
|
||||||
'account_uuid': account_uuid,
|
'account_uuid': account_uuid,
|
||||||
'workspace_uuid': workspace_uuid,
|
'workspace_uuid': workspace_uuid,
|
||||||
}
|
}
|
||||||
await self._consume_jti(_required_string(claims, 'jti'), _required_int(claims, 'exp', minimum=1))
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _verify_assertion(self, token: str) -> dict[str, typing.Any]:
|
def _verify_assertion(self, token: str) -> dict[str, typing.Any]:
|
||||||
if not getattr(getattr(self.ap, 'deployment', None), 'multi_workspace_enabled', False):
|
if not getattr(getattr(self.ap, 'deployment', None), 'multi_workspace_enabled', False):
|
||||||
@@ -217,9 +169,8 @@ class SpaceLaunchService:
|
|||||||
raise SpaceLaunchError('Launch assertion subject targets another instance')
|
raise SpaceLaunchError('Launch assertion subject targets another instance')
|
||||||
if _required_string(claims, 'instance_uuid') != instance_uuid:
|
if _required_string(claims, 'instance_uuid') != instance_uuid:
|
||||||
raise SpaceLaunchError('Launch assertion instance UUID does not match this Core')
|
raise SpaceLaunchError('Launch assertion instance UUID does not match this Core')
|
||||||
kind = _required_string(claims, 'kind')
|
if _required_string(claims, 'kind') != LAUNCH_KIND:
|
||||||
if kind not in {LAUNCH_KIND, SUPPORT_ADMIN_LAUNCH_KIND}:
|
raise SpaceLaunchError('Launch assertion kind is not workspace.launch')
|
||||||
raise SpaceLaunchError('Launch assertion kind is not supported')
|
|
||||||
|
|
||||||
issued_at = _required_int(claims, 'iat')
|
issued_at = _required_int(claims, 'iat')
|
||||||
not_before = _required_int(claims, 'nbf')
|
not_before = _required_int(claims, 'nbf')
|
||||||
|
|||||||
@@ -1,337 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any, Literal, Protocol, runtime_checkable
|
|
||||||
|
|
||||||
import sqlalchemy
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator
|
|
||||||
|
|
||||||
from ..entity.persistence import model as persistence_model
|
|
||||||
|
|
||||||
|
|
||||||
LANGBOT_MODELS_PROVIDER_REQUESTER = 'space-chat-completions'
|
|
||||||
LANGBOT_MODELS_PROVIDER_NAME = 'LangBot Models'
|
|
||||||
_MODEL_RESOURCE_NAMESPACE = uuid.UUID('94c703ca-1df5-4e91-bcd3-74ac65cb7921')
|
|
||||||
_SUPPORTED_CATEGORIES = {'chat', 'embedding', 'rerank'}
|
|
||||||
_MODEL_TABLES = (
|
|
||||||
persistence_model.LLMModel,
|
|
||||||
persistence_model.EmbeddingModel,
|
|
||||||
persistence_model.RerankModel,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CloudModelCatalogItem(BaseModel):
|
|
||||||
model_config = ConfigDict(extra='forbid', frozen=True)
|
|
||||||
|
|
||||||
uuid: str = Field(min_length=1, max_length=255)
|
|
||||||
model_id: str = Field(min_length=1, max_length=255)
|
|
||||||
category: Literal['chat', 'embedding', 'rerank']
|
|
||||||
llm_abilities: tuple[str, ...] = ()
|
|
||||||
is_featured: bool = False
|
|
||||||
featured_order: int = 0
|
|
||||||
|
|
||||||
@field_validator('llm_abilities', mode='before')
|
|
||||||
@classmethod
|
|
||||||
def normalize_missing_abilities(cls, value: Any) -> Any:
|
|
||||||
return () if value is None else value
|
|
||||||
|
|
||||||
@field_validator('llm_abilities')
|
|
||||||
@classmethod
|
|
||||||
def validate_abilities(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
|
||||||
if any(not item.strip() or len(item) > 64 for item in value):
|
|
||||||
raise ValueError('Model abilities must be non-empty strings of at most 64 characters')
|
|
||||||
if len(set(value)) != len(value):
|
|
||||||
raise ValueError('Model abilities must be unique')
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class CloudWorkspaceModelBilling(BaseModel):
|
|
||||||
model_config = ConfigDict(extra='forbid', frozen=True)
|
|
||||||
|
|
||||||
workspace_uuid: str = Field(min_length=36, max_length=36)
|
|
||||||
owner_account_uuid: str | None = Field(default=None, min_length=36, max_length=36)
|
|
||||||
api_key: SecretStr | None = None
|
|
||||||
credits: int | None = None
|
|
||||||
|
|
||||||
@field_validator('workspace_uuid')
|
|
||||||
@classmethod
|
|
||||||
def validate_uuid(cls, value: str) -> str:
|
|
||||||
return str(uuid.UUID(value))
|
|
||||||
|
|
||||||
@field_validator('owner_account_uuid')
|
|
||||||
@classmethod
|
|
||||||
def validate_optional_uuid(cls, value: str | None) -> str | None:
|
|
||||||
return None if value is None else str(uuid.UUID(value))
|
|
||||||
|
|
||||||
|
|
||||||
class CloudModelCatalogSnapshot(BaseModel):
|
|
||||||
model_config = ConfigDict(extra='forbid', frozen=True)
|
|
||||||
|
|
||||||
instance_uuid: str = Field(min_length=1, max_length=255)
|
|
||||||
generated_at: datetime
|
|
||||||
base_url: str = Field(min_length=1, max_length=512)
|
|
||||||
models: tuple[CloudModelCatalogItem, ...]
|
|
||||||
workspaces: tuple[CloudWorkspaceModelBilling, ...]
|
|
||||||
|
|
||||||
@field_validator('base_url')
|
|
||||||
@classmethod
|
|
||||||
def validate_base_url(cls, value: str) -> str:
|
|
||||||
normalized = value.rstrip('/')
|
|
||||||
if not normalized.startswith('https://'):
|
|
||||||
raise ValueError('Cloud model gateway base URL must use HTTPS')
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
@field_validator('models')
|
|
||||||
@classmethod
|
|
||||||
def validate_models(cls, value: tuple[CloudModelCatalogItem, ...]) -> tuple[CloudModelCatalogItem, ...]:
|
|
||||||
if len(value) > 500:
|
|
||||||
raise ValueError('Cloud model catalog exceeds 500 models')
|
|
||||||
identities = {(item.category, item.uuid) for item in value}
|
|
||||||
if len(identities) != len(value):
|
|
||||||
raise ValueError('Cloud model catalog contains duplicate model identities')
|
|
||||||
return value
|
|
||||||
|
|
||||||
@field_validator('workspaces')
|
|
||||||
@classmethod
|
|
||||||
def validate_workspaces(
|
|
||||||
cls, value: tuple[CloudWorkspaceModelBilling, ...]
|
|
||||||
) -> tuple[CloudWorkspaceModelBilling, ...]:
|
|
||||||
if len(value) > 10_000:
|
|
||||||
raise ValueError('Cloud model catalog exceeds 10000 Workspaces')
|
|
||||||
identities = {item.workspace_uuid for item in value}
|
|
||||||
if len(identities) != len(value):
|
|
||||||
raise ValueError('Cloud model catalog contains duplicate Workspaces')
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
|
||||||
class CloudModelCatalogProvider(Protocol):
|
|
||||||
async def fetch_model_catalog(self, instance_uuid: str) -> CloudModelCatalogSnapshot:
|
|
||||||
"""Fetch and verify the complete model catalog and Workspace billing projection."""
|
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
def system_provider_uuid(workspace_uuid: str) -> str:
|
|
||||||
workspace = str(uuid.UUID(workspace_uuid))
|
|
||||||
return str(uuid.uuid5(_MODEL_RESOURCE_NAMESPACE, f'{workspace}:provider:{LANGBOT_MODELS_PROVIDER_REQUESTER}'))
|
|
||||||
|
|
||||||
|
|
||||||
def system_model_uuid(workspace_uuid: str, category: str, upstream_uuid: str) -> str:
|
|
||||||
workspace = str(uuid.UUID(workspace_uuid))
|
|
||||||
if category not in _SUPPORTED_CATEGORIES:
|
|
||||||
raise ValueError(f'Unsupported model category: {category}')
|
|
||||||
if not upstream_uuid:
|
|
||||||
raise ValueError('Upstream model UUID is required')
|
|
||||||
return str(uuid.uuid5(_MODEL_RESOURCE_NAMESPACE, f'{workspace}:model:{category}:{upstream_uuid}'))
|
|
||||||
|
|
||||||
|
|
||||||
class CloudModelCatalogSyncService:
|
|
||||||
"""Reconcile Space-owned model catalog and Owner billing tokens into every Cloud Workspace."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
ap: Any,
|
|
||||||
provider: CloudModelCatalogProvider,
|
|
||||||
instance_uuid: str,
|
|
||||||
*,
|
|
||||||
sync_interval_seconds: float = 3600.0,
|
|
||||||
) -> None:
|
|
||||||
if not isinstance(provider, CloudModelCatalogProvider):
|
|
||||||
raise TypeError('Cloud model catalog sync requires a CloudModelCatalogProvider')
|
|
||||||
if sync_interval_seconds < 10:
|
|
||||||
raise ValueError('Cloud model catalog sync interval must be at least 10 seconds')
|
|
||||||
self.ap = ap
|
|
||||||
self.provider = provider
|
|
||||||
self.instance_uuid = instance_uuid
|
|
||||||
self.sync_interval_seconds = float(sync_interval_seconds)
|
|
||||||
# A tenant UoW commits one Workspace at a time. Keep a durable in-memory
|
|
||||||
# convergence marker so a failed runtime reload is retried even when the
|
|
||||||
# following database reconciliation is a no-op.
|
|
||||||
self._runtime_reload_pending = False
|
|
||||||
self._workspace_credits: dict[str, int | None] = {}
|
|
||||||
self._sync_requested = asyncio.Event()
|
|
||||||
|
|
||||||
def get_workspace_credits(self, workspace_uuid: str) -> int | None:
|
|
||||||
"""Return the latest signed owner-credit projection for a Workspace."""
|
|
||||||
return self._workspace_credits.get(str(uuid.UUID(workspace_uuid)))
|
|
||||||
|
|
||||||
async def initialize(self) -> None:
|
|
||||||
await self.sync_once(reload_runtime=False)
|
|
||||||
|
|
||||||
def request_sync(self) -> None:
|
|
||||||
"""Wake the catalog loop after a directory Workspace change."""
|
|
||||||
|
|
||||||
self._sync_requested.set()
|
|
||||||
|
|
||||||
async def run(self) -> None:
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(self._sync_requested.wait(), timeout=self.sync_interval_seconds)
|
|
||||||
except TimeoutError:
|
|
||||||
pass
|
|
||||||
self._sync_requested.clear()
|
|
||||||
try:
|
|
||||||
await self.sync_once(reload_runtime=True)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
raise
|
|
||||||
except Exception as exc:
|
|
||||||
# Exception messages can contain rendered SQL bound values,
|
|
||||||
# including provider API keys. Log only the exception class.
|
|
||||||
self.ap.logger.warning(f'Cloud model catalog synchronization failed ({type(exc).__name__})')
|
|
||||||
|
|
||||||
async def sync_once(self, *, reload_runtime: bool = True) -> dict[str, int]:
|
|
||||||
summary = {'workspaces': 0, 'created': 0, 'updated': 0, 'deleted': 0}
|
|
||||||
snapshot: CloudModelCatalogSnapshot | None = None
|
|
||||||
sync_error: Exception | None = None
|
|
||||||
reload_error: Exception | None = None
|
|
||||||
try:
|
|
||||||
snapshot = await self.provider.fetch_model_catalog(self.instance_uuid)
|
|
||||||
if snapshot.instance_uuid != self.instance_uuid:
|
|
||||||
raise ValueError('Cloud model catalog targets another LangBot instance')
|
|
||||||
|
|
||||||
bindings = await self.ap.workspace_service.list_active_execution_bindings()
|
|
||||||
billing_by_workspace = {item.workspace_uuid: item for item in snapshot.workspaces}
|
|
||||||
missing = sorted(
|
|
||||||
binding.workspace_uuid for binding in bindings if binding.workspace_uuid not in billing_by_workspace
|
|
||||||
)
|
|
||||||
if missing:
|
|
||||||
raise ValueError(
|
|
||||||
f'Cloud model catalog is missing billing projections for {len(missing)} active Workspaces'
|
|
||||||
)
|
|
||||||
|
|
||||||
for binding in bindings:
|
|
||||||
counts = await self._sync_workspace(
|
|
||||||
binding.workspace_uuid,
|
|
||||||
snapshot,
|
|
||||||
billing_by_workspace[binding.workspace_uuid],
|
|
||||||
)
|
|
||||||
summary['workspaces'] += 1
|
|
||||||
workspace_changed = any(counts[key] > 0 for key in ('created', 'updated', 'deleted'))
|
|
||||||
if workspace_changed:
|
|
||||||
# _sync_workspace returns only after its tenant UoW commits.
|
|
||||||
self._runtime_reload_pending = True
|
|
||||||
for key in ('created', 'updated', 'deleted'):
|
|
||||||
summary[key] += counts[key]
|
|
||||||
self._workspace_credits[binding.workspace_uuid] = billing_by_workspace[binding.workspace_uuid].credits
|
|
||||||
except Exception as exc:
|
|
||||||
sync_error = exc
|
|
||||||
finally:
|
|
||||||
model_mgr = getattr(self.ap, 'model_mgr', None)
|
|
||||||
if reload_runtime and self._runtime_reload_pending and model_mgr is not None:
|
|
||||||
try:
|
|
||||||
await model_mgr.load_models_from_db()
|
|
||||||
except Exception as exc:
|
|
||||||
reload_error = exc
|
|
||||||
else:
|
|
||||||
self._runtime_reload_pending = False
|
|
||||||
|
|
||||||
if sync_error is not None:
|
|
||||||
if reload_error is not None:
|
|
||||||
raise sync_error from reload_error
|
|
||||||
raise sync_error
|
|
||||||
if reload_error is not None:
|
|
||||||
raise reload_error
|
|
||||||
|
|
||||||
changed = any(summary[key] > 0 for key in ('created', 'updated', 'deleted'))
|
|
||||||
if changed and snapshot is not None:
|
|
||||||
self.ap.logger.info(
|
|
||||||
'Cloud model catalog synchronized '
|
|
||||||
f'({summary["workspaces"]} Workspaces, {len(snapshot.models)} models, '
|
|
||||||
f'created={summary["created"]}, updated={summary["updated"]}, deleted={summary["deleted"]})'
|
|
||||||
)
|
|
||||||
return summary
|
|
||||||
|
|
||||||
async def _sync_workspace(
|
|
||||||
self,
|
|
||||||
workspace_uuid: str,
|
|
||||||
snapshot: CloudModelCatalogSnapshot,
|
|
||||||
billing: CloudWorkspaceModelBilling,
|
|
||||||
) -> dict[str, int]:
|
|
||||||
counts = {'created': 0, 'updated': 0, 'deleted': 0}
|
|
||||||
provider_uuid = system_provider_uuid(workspace_uuid)
|
|
||||||
desired_keys = [billing.api_key.get_secret_value()] if billing.api_key is not None else []
|
|
||||||
|
|
||||||
async with self.ap.persistence_mgr.tenant_uow(workspace_uuid) as uow:
|
|
||||||
provider = await uow.session.scalar(
|
|
||||||
sqlalchemy.select(persistence_model.ModelProvider).where(
|
|
||||||
persistence_model.ModelProvider.uuid == provider_uuid
|
|
||||||
)
|
|
||||||
)
|
|
||||||
provider_values = {
|
|
||||||
'workspace_uuid': workspace_uuid,
|
|
||||||
'name': LANGBOT_MODELS_PROVIDER_NAME,
|
|
||||||
'requester': LANGBOT_MODELS_PROVIDER_REQUESTER,
|
|
||||||
'base_url': snapshot.base_url,
|
|
||||||
'api_keys': desired_keys,
|
|
||||||
}
|
|
||||||
if provider is None:
|
|
||||||
provider = persistence_model.ModelProvider(uuid=provider_uuid, **provider_values)
|
|
||||||
uow.session.add(provider)
|
|
||||||
await uow.session.flush()
|
|
||||||
counts['created'] += 1
|
|
||||||
elif self._update_entity(provider, provider_values):
|
|
||||||
counts['updated'] += 1
|
|
||||||
|
|
||||||
existing_by_table: dict[type, dict[str, Any]] = {}
|
|
||||||
for table in _MODEL_TABLES:
|
|
||||||
rows = (
|
|
||||||
await uow.session.scalars(sqlalchemy.select(table).where(table.provider_uuid == provider_uuid))
|
|
||||||
).all()
|
|
||||||
existing_by_table[table] = {row.uuid: row for row in rows}
|
|
||||||
|
|
||||||
desired_ids: dict[type, set[str]] = {table: set() for table in _MODEL_TABLES}
|
|
||||||
for item in snapshot.models:
|
|
||||||
table, values = self._model_values(workspace_uuid, provider_uuid, item)
|
|
||||||
model_uuid = system_model_uuid(workspace_uuid, item.category, item.uuid)
|
|
||||||
desired_ids[table].add(model_uuid)
|
|
||||||
existing = existing_by_table[table].get(model_uuid)
|
|
||||||
if existing is None:
|
|
||||||
uow.session.add(table(uuid=model_uuid, **values))
|
|
||||||
counts['created'] += 1
|
|
||||||
elif self._update_entity(existing, values):
|
|
||||||
counts['updated'] += 1
|
|
||||||
|
|
||||||
for table, entities in existing_by_table.items():
|
|
||||||
for model_uuid, entity in entities.items():
|
|
||||||
if model_uuid not in desired_ids[table]:
|
|
||||||
await uow.session.delete(entity)
|
|
||||||
counts['deleted'] += 1
|
|
||||||
|
|
||||||
return counts
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _update_entity(entity: Any, values: dict[str, Any]) -> bool:
|
|
||||||
changed = False
|
|
||||||
for key, value in values.items():
|
|
||||||
if getattr(entity, key) != value:
|
|
||||||
setattr(entity, key, value)
|
|
||||||
changed = True
|
|
||||||
return changed
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _model_values(
|
|
||||||
workspace_uuid: str,
|
|
||||||
provider_uuid: str,
|
|
||||||
item: CloudModelCatalogItem,
|
|
||||||
) -> tuple[type, dict[str, Any]]:
|
|
||||||
ranking = 100 - item.featured_order if item.is_featured else 0
|
|
||||||
common = {
|
|
||||||
'workspace_uuid': workspace_uuid,
|
|
||||||
'name': item.model_id,
|
|
||||||
'provider_uuid': provider_uuid,
|
|
||||||
'extra_args': {},
|
|
||||||
'prefered_ranking': ranking,
|
|
||||||
}
|
|
||||||
if item.category == 'chat':
|
|
||||||
return persistence_model.LLMModel, {
|
|
||||||
**common,
|
|
||||||
'abilities': list(item.llm_abilities),
|
|
||||||
'context_length': None,
|
|
||||||
}
|
|
||||||
if item.category == 'embedding':
|
|
||||||
return persistence_model.EmbeddingModel, common
|
|
||||||
if item.category == 'rerank':
|
|
||||||
return persistence_model.RerankModel, common
|
|
||||||
raise ValueError(f'Unsupported model category: {item.category}')
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import dataclasses
|
|
||||||
import datetime
|
|
||||||
import hashlib
|
|
||||||
import re
|
|
||||||
import time
|
|
||||||
import typing
|
|
||||||
|
|
||||||
import jwt
|
|
||||||
from sqlalchemy.exc import IntegrityError
|
|
||||||
|
|
||||||
from ..entity.persistence.support_admin import SupportAdminTemporarySession
|
|
||||||
from ..workspace.errors import WorkspaceError
|
|
||||||
|
|
||||||
if typing.TYPE_CHECKING:
|
|
||||||
from ..core.app import Application
|
|
||||||
|
|
||||||
|
|
||||||
SUPPORT_ADMIN_TOKEN_TYP = 'langbot-support-admin+jwt'
|
|
||||||
SUPPORT_ADMIN_TOKEN_KIND = 'support_admin.session'
|
|
||||||
SUPPORT_ADMIN_EFFECTIVE_ROLE = 'owner'
|
|
||||||
SUPPORT_ADMIN_MAX_TOKEN_SECONDS = 300
|
|
||||||
_SHA256_HEX = re.compile(r'^[0-9a-f]{64}$')
|
|
||||||
|
|
||||||
|
|
||||||
class SupportAdminSessionError(ValueError):
|
|
||||||
"""Raised when a support-admin session or token is not admissible."""
|
|
||||||
|
|
||||||
|
|
||||||
class SupportAdminReplayError(SupportAdminSessionError):
|
|
||||||
"""Raised when a launch grant JTI has already been consumed."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass(frozen=True, slots=True)
|
|
||||||
class IssuedSupportAdminSession:
|
|
||||||
token: str
|
|
||||||
grant_jti_hash: str
|
|
||||||
workspace_uuid: str
|
|
||||||
actor_account_uuid: str
|
|
||||||
issued_at: datetime.datetime
|
|
||||||
expires_at: datetime.datetime
|
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass(frozen=True, slots=True)
|
|
||||||
class SupportAdminSessionIdentity:
|
|
||||||
grant_jti_hash: str
|
|
||||||
workspace_uuid: str
|
|
||||||
actor_account_uuid: str
|
|
||||||
instance_uuid: str
|
|
||||||
placement_generation: int
|
|
||||||
|
|
||||||
|
|
||||||
def hash_grant_jti(jti: str) -> str:
|
|
||||||
return hashlib.sha256(jti.encode('utf-8')).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
class SupportAdminSessionService:
|
|
||||||
"""Issue and validate temporary Workspace-scoped support-admin sessions."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
ap: Application,
|
|
||||||
*,
|
|
||||||
wall_time: typing.Callable[[], float] = time.time,
|
|
||||||
) -> None:
|
|
||||||
self.ap = ap
|
|
||||||
self._wall_time = wall_time
|
|
||||||
|
|
||||||
async def consume_launch_grant(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
grant_jti_hash: str,
|
|
||||||
workspace_uuid: str,
|
|
||||||
actor_account_uuid: str,
|
|
||||||
) -> IssuedSupportAdminSession:
|
|
||||||
self._validate_grant_hash(grant_jti_hash)
|
|
||||||
if not workspace_uuid or not actor_account_uuid:
|
|
||||||
raise SupportAdminSessionError('Support admin session requires an actor and Workspace')
|
|
||||||
|
|
||||||
issued_at = self._utcnow()
|
|
||||||
expires_at = issued_at + datetime.timedelta(seconds=SUPPORT_ADMIN_MAX_TOKEN_SECONDS)
|
|
||||||
tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
|
|
||||||
if not callable(tenant_uow):
|
|
||||||
raise SupportAdminSessionError('Support admin sessions require tenant persistence')
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with tenant_uow(workspace_uuid) as uow:
|
|
||||||
await self.ap.workspace_service.get_execution_binding(workspace_uuid, session=uow.session)
|
|
||||||
uow.session.add(
|
|
||||||
SupportAdminTemporarySession(
|
|
||||||
grant_jti_hash=grant_jti_hash,
|
|
||||||
workspace_uuid=workspace_uuid,
|
|
||||||
actor_account_uuid=actor_account_uuid,
|
|
||||||
issued_at=issued_at,
|
|
||||||
expires_at=expires_at,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await uow.session.flush()
|
|
||||||
except IntegrityError as exc:
|
|
||||||
raise SupportAdminReplayError('Launch assertion has already been consumed') from exc
|
|
||||||
except WorkspaceError as exc:
|
|
||||||
raise SupportAdminSessionError('Workspace is unavailable for support access') from exc
|
|
||||||
|
|
||||||
return IssuedSupportAdminSession(
|
|
||||||
token=self._encode_token(
|
|
||||||
grant_jti_hash=grant_jti_hash,
|
|
||||||
workspace_uuid=workspace_uuid,
|
|
||||||
actor_account_uuid=actor_account_uuid,
|
|
||||||
issued_at=issued_at,
|
|
||||||
expires_at=expires_at,
|
|
||||||
),
|
|
||||||
grant_jti_hash=grant_jti_hash,
|
|
||||||
workspace_uuid=workspace_uuid,
|
|
||||||
actor_account_uuid=actor_account_uuid,
|
|
||||||
issued_at=issued_at,
|
|
||||||
expires_at=expires_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
def is_support_admin_token(self, token: str) -> bool:
|
|
||||||
"""Return True only for compact JWTs marked as support-admin tokens."""
|
|
||||||
|
|
||||||
if not isinstance(token, str) or token.count('.') != 2:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
header = jwt.get_unverified_header(token)
|
|
||||||
except jwt.PyJWTError:
|
|
||||||
return False
|
|
||||||
if header.get('typ') == SUPPORT_ADMIN_TOKEN_TYP:
|
|
||||||
return True
|
|
||||||
try:
|
|
||||||
payload = jwt.decode(token, options={'verify_signature': False})
|
|
||||||
except jwt.PyJWTError:
|
|
||||||
return False
|
|
||||||
return payload.get('kind') == SUPPORT_ADMIN_TOKEN_KIND
|
|
||||||
|
|
||||||
async def authenticate_token(
|
|
||||||
self,
|
|
||||||
token: str,
|
|
||||||
*,
|
|
||||||
requested_workspace_uuid: str | None,
|
|
||||||
) -> SupportAdminSessionIdentity:
|
|
||||||
if not self.is_support_admin_token(token):
|
|
||||||
raise SupportAdminSessionError('Not a support admin token')
|
|
||||||
workspace_uuid = (requested_workspace_uuid or '').strip()
|
|
||||||
if not workspace_uuid:
|
|
||||||
raise SupportAdminSessionError('Support admin token requires an explicit Workspace selector')
|
|
||||||
|
|
||||||
jwt_secret = self.ap.instance_config.data['system']['jwt']['secret']
|
|
||||||
try:
|
|
||||||
payload = jwt.decode(
|
|
||||||
token,
|
|
||||||
jwt_secret,
|
|
||||||
algorithms=['HS256'],
|
|
||||||
issuer='langbot-core',
|
|
||||||
audience=self._audience(workspace_uuid),
|
|
||||||
options={'require': ['exp', 'iat', 'nbf', 'iss', 'aud']},
|
|
||||||
)
|
|
||||||
except jwt.PyJWTError as exc:
|
|
||||||
raise SupportAdminSessionError('Invalid support admin token') from exc
|
|
||||||
self._validate_payload(payload, workspace_uuid)
|
|
||||||
grant_jti_hash = payload['grant_jti_hash']
|
|
||||||
actor_account_uuid = payload['actor_account_uuid']
|
|
||||||
|
|
||||||
tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
|
|
||||||
if not callable(tenant_uow):
|
|
||||||
raise SupportAdminSessionError('Support admin sessions require tenant persistence')
|
|
||||||
|
|
||||||
now = self._utcnow()
|
|
||||||
async with tenant_uow(workspace_uuid) as uow:
|
|
||||||
session = await uow.session.get(SupportAdminTemporarySession, grant_jti_hash)
|
|
||||||
if (
|
|
||||||
session is None
|
|
||||||
or session.workspace_uuid != workspace_uuid
|
|
||||||
or session.actor_account_uuid != actor_account_uuid
|
|
||||||
or session.revoked_at is not None
|
|
||||||
or session.expires_at <= now
|
|
||||||
):
|
|
||||||
raise SupportAdminSessionError('Support admin session is inactive')
|
|
||||||
binding = await self.ap.workspace_service.get_execution_binding(workspace_uuid, session=uow.session)
|
|
||||||
session.last_used_at = now
|
|
||||||
await uow.session.flush()
|
|
||||||
|
|
||||||
return SupportAdminSessionIdentity(
|
|
||||||
grant_jti_hash=grant_jti_hash,
|
|
||||||
workspace_uuid=workspace_uuid,
|
|
||||||
actor_account_uuid=actor_account_uuid,
|
|
||||||
instance_uuid=binding.instance_uuid,
|
|
||||||
placement_generation=binding.placement_generation,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def revoke_session(self, grant_jti_hash: str, workspace_uuid: str) -> None:
|
|
||||||
self._validate_grant_hash(grant_jti_hash)
|
|
||||||
now = self._utcnow()
|
|
||||||
async with self.ap.persistence_mgr.tenant_uow(workspace_uuid) as uow:
|
|
||||||
row = await uow.session.get(SupportAdminTemporarySession, grant_jti_hash)
|
|
||||||
if row is not None and row.revoked_at is None:
|
|
||||||
row.revoked_at = now
|
|
||||||
|
|
||||||
def _encode_token(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
grant_jti_hash: str,
|
|
||||||
workspace_uuid: str,
|
|
||||||
actor_account_uuid: str,
|
|
||||||
issued_at: datetime.datetime,
|
|
||||||
expires_at: datetime.datetime,
|
|
||||||
) -> str:
|
|
||||||
jwt_secret = self.ap.instance_config.data['system']['jwt']['secret']
|
|
||||||
payload: dict[str, typing.Any] = {
|
|
||||||
'kind': SUPPORT_ADMIN_TOKEN_KIND,
|
|
||||||
'iss': 'langbot-core',
|
|
||||||
'aud': self._audience(workspace_uuid),
|
|
||||||
'sub': f'support-admin:{actor_account_uuid}',
|
|
||||||
'iat': issued_at,
|
|
||||||
'nbf': issued_at,
|
|
||||||
'exp': expires_at,
|
|
||||||
'actor_account_uuid': actor_account_uuid,
|
|
||||||
'workspace_uuid': workspace_uuid,
|
|
||||||
'effective_role': SUPPORT_ADMIN_EFFECTIVE_ROLE,
|
|
||||||
'grant_jti_hash': grant_jti_hash,
|
|
||||||
}
|
|
||||||
return jwt.encode(payload, jwt_secret, algorithm='HS256', headers={'typ': SUPPORT_ADMIN_TOKEN_TYP})
|
|
||||||
|
|
||||||
def _validate_payload(self, payload: dict[str, typing.Any], workspace_uuid: str) -> None:
|
|
||||||
if payload.get('kind') != SUPPORT_ADMIN_TOKEN_KIND:
|
|
||||||
raise SupportAdminSessionError('Invalid support admin token kind')
|
|
||||||
if payload.get('workspace_uuid') != workspace_uuid:
|
|
||||||
raise SupportAdminSessionError('Support admin session is scoped to another Workspace')
|
|
||||||
if payload.get('effective_role') != SUPPORT_ADMIN_EFFECTIVE_ROLE:
|
|
||||||
raise SupportAdminSessionError('Invalid support admin token role')
|
|
||||||
actor_account_uuid = payload.get('actor_account_uuid')
|
|
||||||
if not isinstance(actor_account_uuid, str) or not actor_account_uuid.strip():
|
|
||||||
raise SupportAdminSessionError('Invalid support admin actor')
|
|
||||||
grant_jti_hash = payload.get('grant_jti_hash')
|
|
||||||
if not isinstance(grant_jti_hash, str) or not _SHA256_HEX.match(grant_jti_hash):
|
|
||||||
raise SupportAdminSessionError('Invalid support admin grant')
|
|
||||||
|
|
||||||
def _audience(self, workspace_uuid: str) -> str:
|
|
||||||
return f'langbot-support-admin:{self.ap.workspace_service.instance_uuid}:{workspace_uuid}'
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _validate_grant_hash(grant_jti_hash: str) -> None:
|
|
||||||
if not _SHA256_HEX.match(grant_jti_hash):
|
|
||||||
raise SupportAdminSessionError('Invalid support admin grant')
|
|
||||||
|
|
||||||
def _utcnow(self) -> datetime.datetime:
|
|
||||||
return datetime.datetime.fromtimestamp(self._wall_time(), datetime.UTC).replace(tzinfo=None)
|
|
||||||
@@ -51,10 +51,8 @@ from ..workspace import collaboration as workspace_collaboration_module
|
|||||||
from ..workspace import invitation_delivery as invitation_delivery_module
|
from ..workspace import invitation_delivery as invitation_delivery_module
|
||||||
from ..cloud import bootstrap as cloud_bootstrap_module
|
from ..cloud import bootstrap as cloud_bootstrap_module
|
||||||
from ..cloud import launch as cloud_launch_module
|
from ..cloud import launch as cloud_launch_module
|
||||||
from ..cloud import support_admin as cloud_support_admin_module
|
|
||||||
from ..cloud import directory_projection as cloud_directory_projection_module
|
from ..cloud import directory_projection as cloud_directory_projection_module
|
||||||
from ..cloud import entitlements as cloud_entitlements_module
|
from ..cloud import entitlements as cloud_entitlements_module
|
||||||
from ..cloud import model_catalog as cloud_model_catalog_module
|
|
||||||
from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType
|
from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType
|
||||||
|
|
||||||
|
|
||||||
@@ -138,17 +136,16 @@ class Application:
|
|||||||
|
|
||||||
space_launch_service: cloud_launch_module.SpaceLaunchService = None
|
space_launch_service: cloud_launch_module.SpaceLaunchService = None
|
||||||
|
|
||||||
support_admin_session_service: cloud_support_admin_module.SupportAdminSessionService = None
|
|
||||||
|
|
||||||
deployment: cloud_bootstrap_module.OpenSourceDeployment | cloud_bootstrap_module.VerifiedCloudDeployment = None
|
deployment: cloud_bootstrap_module.OpenSourceDeployment | cloud_bootstrap_module.VerifiedCloudDeployment = None
|
||||||
|
|
||||||
deployment_admission: cloud_bootstrap_module.DeploymentAdmissionGuard = None
|
deployment_admission: cloud_bootstrap_module.DeploymentAdmissionGuard = None
|
||||||
directory_projection_service: cloud_directory_projection_module.DirectoryProjectionService | None = None
|
|
||||||
cloud_model_catalog_service: cloud_model_catalog_module.CloudModelCatalogSyncService | None = None
|
|
||||||
manifest_refresh_service: cloud_bootstrap_module.CloudManifestRefreshService | None = None
|
manifest_refresh_service: cloud_bootstrap_module.CloudManifestRefreshService | None = None
|
||||||
|
|
||||||
entitlement_resolver: cloud_entitlements_module.EntitlementResolver | None = None
|
entitlement_resolver: cloud_entitlements_module.EntitlementResolver | None = None
|
||||||
|
|
||||||
|
directory_projection_service: cloud_directory_projection_module.DirectoryProjectionService | None = None
|
||||||
|
|
||||||
vector_db_mgr: vectordb_mgr.VectorDBManager = None
|
vector_db_mgr: vectordb_mgr.VectorDBManager = None
|
||||||
|
|
||||||
http_ctrl: http_controller.HTTPController = None
|
http_ctrl: http_controller.HTTPController = None
|
||||||
@@ -306,12 +303,6 @@ class Application:
|
|||||||
name='cloud-directory-projection',
|
name='cloud-directory-projection',
|
||||||
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||||
)
|
)
|
||||||
if self.cloud_model_catalog_service is not None:
|
|
||||||
self.task_mgr.create_task(
|
|
||||||
self.cloud_model_catalog_service.run(),
|
|
||||||
name='cloud-model-catalog-sync',
|
|
||||||
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
|
||||||
)
|
|
||||||
if self.manifest_refresh_service is not None:
|
if self.manifest_refresh_service is not None:
|
||||||
self.task_mgr.create_task(
|
self.task_mgr.create_task(
|
||||||
self.manifest_refresh_service.run(),
|
self.manifest_refresh_service.run(),
|
||||||
|
|||||||
@@ -42,11 +42,9 @@ from ...workspace import collaboration as workspace_collaboration_module
|
|||||||
from ...workspace import invitation_delivery as invitation_delivery_module
|
from ...workspace import invitation_delivery as invitation_delivery_module
|
||||||
from ...cloud import bootstrap as cloud_bootstrap
|
from ...cloud import bootstrap as cloud_bootstrap
|
||||||
from ...cloud import launch as cloud_launch_module
|
from ...cloud import launch as cloud_launch_module
|
||||||
from ...cloud import support_admin as cloud_support_admin_module
|
|
||||||
from ...cloud.directory import directory_projection_limits_from_config
|
from ...cloud.directory import directory_projection_limits_from_config
|
||||||
from ...cloud.directory_projection import DirectoryProjectionService
|
from ...cloud.directory_projection import DirectoryProjectionService
|
||||||
from ...cloud.entitlements import EntitlementResolver
|
from ...cloud.entitlements import EntitlementResolver
|
||||||
from ...cloud.model_catalog import CloudModelCatalogSyncService
|
|
||||||
from ...api.http.context import ExecutionContext, PrincipalContext, PrincipalType
|
from ...api.http.context import ExecutionContext, PrincipalContext, PrincipalType
|
||||||
from ...api.http.authz import WorkspaceRequiredError
|
from ...api.http.authz import WorkspaceRequiredError
|
||||||
|
|
||||||
@@ -177,22 +175,11 @@ class BuildAppStage(stage.BootingStage):
|
|||||||
# of repeating tenant validation for every manager.
|
# of repeating tenant validation for every manager.
|
||||||
await workspace_service_inst.prime_startup_execution_bindings()
|
await workspace_service_inst.prime_startup_execution_bindings()
|
||||||
|
|
||||||
if not isinstance(deployment, cloud_bootstrap.VerifiedCloudDeployment):
|
|
||||||
raise RuntimeError('Multi-Workspace runtime requires a verified Cloud deployment')
|
|
||||||
cloud_model_catalog_service = CloudModelCatalogSyncService(
|
|
||||||
ap,
|
|
||||||
deployment.model_catalog_provider,
|
|
||||||
constants.instance_id,
|
|
||||||
)
|
|
||||||
await cloud_model_catalog_service.initialize()
|
|
||||||
ap.cloud_model_catalog_service = cloud_model_catalog_service
|
|
||||||
|
|
||||||
ap.workspace_collaboration_service = workspace_collaboration_module.WorkspaceCollaborationService(
|
ap.workspace_collaboration_service = workspace_collaboration_module.WorkspaceCollaborationService(
|
||||||
ap,
|
ap,
|
||||||
workspace_service_inst,
|
workspace_service_inst,
|
||||||
)
|
)
|
||||||
ap.invitation_delivery_service = invitation_delivery_module.InvitationDeliveryService(ap)
|
ap.invitation_delivery_service = invitation_delivery_module.InvitationDeliveryService(ap)
|
||||||
ap.support_admin_session_service = cloud_support_admin_module.SupportAdminSessionService(ap)
|
|
||||||
ap.space_launch_service = cloud_launch_module.SpaceLaunchService(ap)
|
ap.space_launch_service = cloud_launch_module.SpaceLaunchService(ap)
|
||||||
|
|
||||||
user_service_inst = user_service.UserService(ap)
|
user_service_inst = user_service.UserService(ap)
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ _RUNTIME_POLICY_DEFAULTS = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
'plugin': {
|
'plugin': {
|
||||||
'connect_timeout_seconds': 180.0,
|
|
||||||
'worker': {
|
'worker': {
|
||||||
'max_cpus': 1.0,
|
'max_cpus': 1.0,
|
||||||
'max_memory_mb': 512,
|
'max_memory_mb': 512,
|
||||||
@@ -57,7 +56,7 @@ _RUNTIME_POLICY_DEFAULTS = {
|
|||||||
'restart_failure_window_seconds': 30.0,
|
'restart_failure_window_seconds': 30.0,
|
||||||
'restart_circuit_open_seconds': 60.0,
|
'restart_circuit_open_seconds': 60.0,
|
||||||
'require_hard_limits': False,
|
'require_hard_limits': False,
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
'mcp': {'stdio': {'enabled': True}},
|
'mcp': {'stdio': {'enabled': True}},
|
||||||
'monitoring': {
|
'monitoring': {
|
||||||
@@ -187,14 +186,9 @@ def _apply_env_overrides_to_config(cfg: dict) -> dict:
|
|||||||
# At the final key
|
# At the final key
|
||||||
if key in current:
|
if key in current:
|
||||||
if isinstance(current[key], list):
|
if isinstance(current[key], list):
|
||||||
# Convert comma-separated values while preserving the
|
# Convert comma-separated string to list
|
||||||
# element type declared by a non-empty config default.
|
# e.g., SYSTEM__DISABLED_ADAPTERS="aiocqhttp,dingtalk"
|
||||||
items = [item.strip() for item in env_value.split(',') if item.strip()]
|
current[key] = [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
|
|
||||||
elif isinstance(current[key], dict):
|
elif isinstance(current[key], dict):
|
||||||
# Skip dict types
|
# Skip dict types
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sqlalchemy
|
|
||||||
|
|
||||||
from .base import Base
|
|
||||||
|
|
||||||
|
|
||||||
class SupportAdminTemporarySession(Base):
|
|
||||||
"""Temporary support-admin Workspace access session."""
|
|
||||||
|
|
||||||
__tablename__ = 'support_admin_temporary_sessions'
|
|
||||||
|
|
||||||
grant_jti_hash = sqlalchemy.Column(sqlalchemy.String(64), primary_key=True)
|
|
||||||
workspace_uuid = sqlalchemy.Column(
|
|
||||||
sqlalchemy.String(36),
|
|
||||||
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
actor_account_uuid = sqlalchemy.Column(sqlalchemy.String(36), nullable=False)
|
|
||||||
issued_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False)
|
|
||||||
expires_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False)
|
|
||||||
revoked_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
|
|
||||||
last_used_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
sqlalchemy.Index(
|
|
||||||
'ix_support_admin_sessions_workspace_expiry',
|
|
||||||
'workspace_uuid',
|
|
||||||
'expires_at',
|
|
||||||
),
|
|
||||||
sqlalchemy.CheckConstraint(
|
|
||||||
'length(grant_jti_hash) = 64',
|
|
||||||
name='ck_support_admin_sessions_grant_jti_hash',
|
|
||||||
),
|
|
||||||
)
|
|
||||||
@@ -40,11 +40,6 @@ class MembershipStatus(enum.StrEnum):
|
|||||||
REMOVED = 'removed'
|
REMOVED = 'removed'
|
||||||
|
|
||||||
|
|
||||||
class MembershipSource(enum.StrEnum):
|
|
||||||
LOCAL = 'local'
|
|
||||||
CLOUD_PROJECTION = 'cloud_projection'
|
|
||||||
|
|
||||||
|
|
||||||
class InvitationStatus(enum.StrEnum):
|
class InvitationStatus(enum.StrEnum):
|
||||||
PENDING = 'pending'
|
PENDING = 'pending'
|
||||||
ACCEPTED = 'accepted'
|
ACCEPTED = 'accepted'
|
||||||
@@ -156,11 +151,6 @@ class WorkspaceMembership(Base):
|
|||||||
nullable=True,
|
nullable=True,
|
||||||
)
|
)
|
||||||
joined_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
|
joined_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
|
||||||
source = sqlalchemy.Column(
|
|
||||||
sqlalchemy.String(32),
|
|
||||||
nullable=False,
|
|
||||||
server_default=MembershipSource.LOCAL.value,
|
|
||||||
)
|
|
||||||
projection_revision = sqlalchemy.Column(sqlalchemy.BigInteger, nullable=False, server_default='0')
|
projection_revision = sqlalchemy.Column(sqlalchemy.BigInteger, nullable=False, server_default='0')
|
||||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||||
updated_at = sqlalchemy.Column(
|
updated_at = sqlalchemy.Column(
|
||||||
@@ -173,13 +163,6 @@ class WorkspaceMembership(Base):
|
|||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
sqlalchemy.UniqueConstraint('workspace_uuid', 'account_uuid', name='uq_workspace_membership_account'),
|
sqlalchemy.UniqueConstraint('workspace_uuid', 'account_uuid', name='uq_workspace_membership_account'),
|
||||||
sqlalchemy.Index('ix_workspace_memberships_account_status', 'account_uuid', 'status'),
|
sqlalchemy.Index('ix_workspace_memberships_account_status', 'account_uuid', 'status'),
|
||||||
sqlalchemy.Index(
|
|
||||||
'uq_workspace_memberships_one_active_owner',
|
|
||||||
'workspace_uuid',
|
|
||||||
unique=True,
|
|
||||||
sqlite_where=sqlalchemy.text("role = 'owner' AND status = 'active'"),
|
|
||||||
postgresql_where=sqlalchemy.text("role = 'owner' AND status = 'active'"),
|
|
||||||
),
|
|
||||||
sqlalchemy.CheckConstraint(
|
sqlalchemy.CheckConstraint(
|
||||||
"role IN ('owner', 'admin', 'developer', 'operator', 'viewer')",
|
"role IN ('owner', 'admin', 'developer', 'operator', 'viewer')",
|
||||||
name='ck_workspace_memberships_role',
|
name='ck_workspace_memberships_role',
|
||||||
@@ -188,10 +171,6 @@ class WorkspaceMembership(Base):
|
|||||||
"status IN ('active', 'disabled', 'removed')",
|
"status IN ('active', 'disabled', 'removed')",
|
||||||
name='ck_workspace_memberships_status',
|
name='ck_workspace_memberships_status',
|
||||||
),
|
),
|
||||||
sqlalchemy.CheckConstraint(
|
|
||||||
"source IN ('local', 'cloud_projection')",
|
|
||||||
name='ck_workspace_memberships_source',
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,17 +18,6 @@ down_revision = '0008_mcp_resource_prefs'
|
|||||||
branch_labels = None
|
branch_labels = None
|
||||||
depends_on = None
|
depends_on = None
|
||||||
|
|
||||||
_WORKSPACE_IDENTITY_NAMESPACE = uuid.UUID('8ea04f29-8528-4cc3-bb28-30a838c89d76')
|
|
||||||
|
|
||||||
|
|
||||||
def _workspace_uuid_from_instance_id(instance_id: str) -> str:
|
|
||||||
value = instance_id.strip()
|
|
||||||
candidate = value[len('instance_') :] if value.startswith('instance_') else value
|
|
||||||
try:
|
|
||||||
return str(uuid.UUID(candidate))
|
|
||||||
except ValueError:
|
|
||||||
return str(uuid.uuid5(_WORKSPACE_IDENTITY_NAMESPACE, value))
|
|
||||||
|
|
||||||
|
|
||||||
def _table_names(conn: sa.Connection) -> set[str]:
|
def _table_names(conn: sa.Connection) -> set[str]:
|
||||||
return set(sa.inspect(conn).get_table_names())
|
return set(sa.inspect(conn).get_table_names())
|
||||||
@@ -414,7 +403,7 @@ def _bootstrap_default_workspace(conn: sa.Connection) -> None:
|
|||||||
.values(created_by_account_uuid=owner_account_uuid)
|
.values(created_by_account_uuid=owner_account_uuid)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
workspace_uuid = _workspace_uuid_from_instance_id(instance_uuid)
|
workspace_uuid = str(uuid.uuid4())
|
||||||
conn.execute(
|
conn.execute(
|
||||||
workspaces.insert().values(
|
workspaces.insert().values(
|
||||||
uuid=workspace_uuid,
|
uuid=workspace_uuid,
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
"""add temporary support-admin sessions
|
|
||||||
|
|
||||||
Revision ID: 0016_support_admin_sessions
|
|
||||||
Revises: 0015_cloud_core_collab
|
|
||||||
Create Date: 2026-07-31
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
|
|
||||||
revision = '0016_support_admin_sessions'
|
|
||||||
down_revision = '0015_cloud_core_collab'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
_TABLE_NAME = 'support_admin_temporary_sessions'
|
|
||||||
_POLICY_NAME = 'langbot_workspace_isolation'
|
|
||||||
_TENANT_SETTING = 'langbot.workspace_uuid'
|
|
||||||
|
|
||||||
|
|
||||||
def _setting(name: str) -> str:
|
|
||||||
return f"NULLIF(current_setting('{name}', true), '')"
|
|
||||||
|
|
||||||
|
|
||||||
def _quote(conn: sa.Connection, identifier: str) -> str:
|
|
||||||
return conn.dialect.identifier_preparer.quote(identifier)
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
existing_tables = set(sa.inspect(conn).get_table_names())
|
|
||||||
if _TABLE_NAME not in existing_tables:
|
|
||||||
op.create_table(
|
|
||||||
_TABLE_NAME,
|
|
||||||
sa.Column('grant_jti_hash', sa.String(64), nullable=False),
|
|
||||||
sa.Column(
|
|
||||||
'workspace_uuid',
|
|
||||||
sa.String(36),
|
|
||||||
sa.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
|
|
||||||
nullable=False,
|
|
||||||
),
|
|
||||||
sa.Column('actor_account_uuid', sa.String(36), nullable=False),
|
|
||||||
sa.Column('issued_at', sa.DateTime(), nullable=False),
|
|
||||||
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
|
||||||
sa.Column('revoked_at', sa.DateTime(), nullable=True),
|
|
||||||
sa.Column('last_used_at', sa.DateTime(), nullable=True),
|
|
||||||
sa.CheckConstraint(
|
|
||||||
'length(grant_jti_hash) = 64',
|
|
||||||
name='ck_support_admin_sessions_grant_jti_hash',
|
|
||||||
),
|
|
||||||
sa.PrimaryKeyConstraint('grant_jti_hash'),
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
'ix_support_admin_sessions_workspace_expiry',
|
|
||||||
_TABLE_NAME,
|
|
||||||
['workspace_uuid', 'expires_at'],
|
|
||||||
unique=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
if conn.dialect.name != 'postgresql':
|
|
||||||
return
|
|
||||||
|
|
||||||
table = _quote(conn, _TABLE_NAME)
|
|
||||||
policy = _quote(conn, _POLICY_NAME)
|
|
||||||
expression = f'workspace_uuid::text = {_setting(_TENANT_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:
|
|
||||||
conn = op.get_bind()
|
|
||||||
if conn.dialect.name == 'postgresql':
|
|
||||||
table = _quote(conn, _TABLE_NAME)
|
|
||||||
policy = _quote(conn, _POLICY_NAME)
|
|
||||||
op.execute(sa.text(f'DROP POLICY IF EXISTS {policy} ON {table}'))
|
|
||||||
op.drop_index('ix_support_admin_sessions_workspace_expiry', table_name=_TABLE_NAME)
|
|
||||||
op.drop_table(_TABLE_NAME)
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
"""align the OSS Workspace UUID with the persisted instance identity
|
|
||||||
|
|
||||||
Revision ID: 0017_oss_workspace_identity
|
|
||||||
Revises: 0016_support_admin_sessions
|
|
||||||
Create Date: 2026-07-31
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
|
|
||||||
revision = '0017_oss_workspace_identity'
|
|
||||||
down_revision = '0016_support_admin_sessions'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
_WORKSPACE_IDENTITY_NAMESPACE = uuid.UUID('8ea04f29-8528-4cc3-bb28-30a838c89d76')
|
|
||||||
_OSS_WORKSPACE_METADATA_KEY = 'oss_workspace_uuid'
|
|
||||||
|
|
||||||
|
|
||||||
def _workspace_uuid_from_instance_id(instance_id: str) -> str:
|
|
||||||
value = instance_id.strip()
|
|
||||||
candidate = value[len('instance_') :] if value.startswith('instance_') else value
|
|
||||||
try:
|
|
||||||
return str(uuid.UUID(candidate))
|
|
||||||
except ValueError:
|
|
||||||
return str(uuid.uuid5(_WORKSPACE_IDENTITY_NAMESPACE, value))
|
|
||||||
|
|
||||||
|
|
||||||
def _quote(conn: sa.Connection, identifier: str) -> str:
|
|
||||||
return conn.dialect.identifier_preparer.quote(identifier)
|
|
||||||
|
|
||||||
|
|
||||||
def _defer_foreign_keys(conn: sa.Connection, inspector: sa.Inspector, table_names: list[str]) -> None:
|
|
||||||
"""Allow the transaction to re-key a connected tenant graph atomically."""
|
|
||||||
|
|
||||||
if conn.dialect.name == 'sqlite':
|
|
||||||
conn.execute(sa.text('PRAGMA defer_foreign_keys = ON'))
|
|
||||||
return
|
|
||||||
if conn.dialect.name != 'postgresql':
|
|
||||||
raise RuntimeError(f'Unsupported Workspace identity migration dialect: {conn.dialect.name}')
|
|
||||||
|
|
||||||
for table_name in table_names:
|
|
||||||
for foreign_key in inspector.get_foreign_keys(table_name):
|
|
||||||
constraint_name = foreign_key.get('name')
|
|
||||||
if not constraint_name:
|
|
||||||
continue
|
|
||||||
conn.execute(
|
|
||||||
sa.text(
|
|
||||||
f'ALTER TABLE {_quote(conn, table_name)} '
|
|
||||||
f'ALTER CONSTRAINT {_quote(conn, constraint_name)} DEFERRABLE INITIALLY DEFERRED'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _suspend_postgres_rls(
|
|
||||||
conn: sa.Connection,
|
|
||||||
table_names: list[str],
|
|
||||||
) -> dict[str, tuple[bool, bool]]:
|
|
||||||
if conn.dialect.name != 'postgresql':
|
|
||||||
return {}
|
|
||||||
|
|
||||||
states: dict[str, tuple[bool, bool]] = {}
|
|
||||||
for table_name in table_names:
|
|
||||||
row = conn.execute(
|
|
||||||
sa.text('SELECT relrowsecurity, relforcerowsecurity FROM pg_class WHERE oid = to_regclass(:table_name)'),
|
|
||||||
{'table_name': table_name},
|
|
||||||
).one()
|
|
||||||
enabled, forced = bool(row.relrowsecurity), bool(row.relforcerowsecurity)
|
|
||||||
states[table_name] = (enabled, forced)
|
|
||||||
table = _quote(conn, table_name)
|
|
||||||
if forced:
|
|
||||||
conn.execute(sa.text(f'ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY'))
|
|
||||||
if enabled:
|
|
||||||
conn.execute(sa.text(f'ALTER TABLE {table} DISABLE ROW LEVEL SECURITY'))
|
|
||||||
return states
|
|
||||||
|
|
||||||
|
|
||||||
def _restore_postgres_rls(conn: sa.Connection, states: dict[str, tuple[bool, bool]]) -> None:
|
|
||||||
for table_name, (enabled, forced) in states.items():
|
|
||||||
table = _quote(conn, table_name)
|
|
||||||
if enabled:
|
|
||||||
conn.execute(sa.text(f'ALTER TABLE {table} ENABLE ROW LEVEL SECURITY'))
|
|
||||||
if forced:
|
|
||||||
conn.execute(sa.text(f'ALTER TABLE {table} FORCE ROW LEVEL SECURITY'))
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
inspector = sa.inspect(conn)
|
|
||||||
table_names = inspector.get_table_names()
|
|
||||||
if 'workspaces' not in table_names:
|
|
||||||
return
|
|
||||||
|
|
||||||
metadata = sa.MetaData()
|
|
||||||
workspaces = sa.Table('workspaces', metadata, autoload_with=conn)
|
|
||||||
local_rows = conn.execute(sa.select(workspaces).where(workspaces.c.source == 'local')).mappings().all()
|
|
||||||
if not local_rows:
|
|
||||||
return
|
|
||||||
if len(local_rows) != 1:
|
|
||||||
raise RuntimeError('Cannot align OSS Workspace identity: expected exactly one local Workspace')
|
|
||||||
|
|
||||||
old_row = dict(local_rows[0])
|
|
||||||
old_uuid = old_row['uuid']
|
|
||||||
canonical_uuid = _workspace_uuid_from_instance_id(old_row['instance_uuid'])
|
|
||||||
if old_uuid == canonical_uuid:
|
|
||||||
return
|
|
||||||
if conn.execute(sa.select(workspaces.c.uuid).where(workspaces.c.uuid == canonical_uuid)).scalar_one_or_none():
|
|
||||||
raise RuntimeError(f'Cannot align OSS Workspace identity: target {canonical_uuid!r} already exists')
|
|
||||||
|
|
||||||
tenant_tables = [
|
|
||||||
table_name
|
|
||||||
for table_name in table_names
|
|
||||||
if table_name == 'workspaces'
|
|
||||||
or 'workspace_uuid' in {column['name'] for column in inspector.get_columns(table_name)}
|
|
||||||
]
|
|
||||||
rls_states = _suspend_postgres_rls(conn, tenant_tables)
|
|
||||||
try:
|
|
||||||
_defer_foreign_keys(conn, inspector, table_names)
|
|
||||||
|
|
||||||
# Release local source/slug uniqueness while the canonical parent exists
|
|
||||||
# alongside the old parent for the duration of this transaction.
|
|
||||||
temporary_slug = f'__workspace_rekey__{old_uuid}'
|
|
||||||
conn.execute(
|
|
||||||
workspaces.update()
|
|
||||||
.where(workspaces.c.uuid == old_uuid)
|
|
||||||
.values(source='cloud_projection', slug=temporary_slug)
|
|
||||||
)
|
|
||||||
new_row = dict(old_row)
|
|
||||||
new_row['uuid'] = canonical_uuid
|
|
||||||
conn.execute(workspaces.insert().values(**new_row))
|
|
||||||
|
|
||||||
for table_name in tenant_tables:
|
|
||||||
if table_name == 'workspaces':
|
|
||||||
continue
|
|
||||||
table = sa.Table(table_name, metadata, autoload_with=conn, extend_existing=True)
|
|
||||||
conn.execute(table.update().where(table.c.workspace_uuid == old_uuid).values(workspace_uuid=canonical_uuid))
|
|
||||||
|
|
||||||
if 'metadata' in table_names:
|
|
||||||
conn.execute(
|
|
||||||
sa.text('UPDATE metadata SET value = :canonical_uuid WHERE key = :key AND value = :old_uuid'),
|
|
||||||
{
|
|
||||||
'canonical_uuid': canonical_uuid,
|
|
||||||
'key': _OSS_WORKSPACE_METADATA_KEY,
|
|
||||||
'old_uuid': old_uuid,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
conn.execute(workspaces.delete().where(workspaces.c.uuid == old_uuid))
|
|
||||||
if conn.dialect.name == 'postgresql':
|
|
||||||
# Fire deferred FK triggers before ALTER TABLE restores RLS; PostgreSQL
|
|
||||||
# rejects ALTER TABLE while a relation has pending trigger events.
|
|
||||||
conn.execute(sa.text('SET CONSTRAINTS ALL IMMEDIATE'))
|
|
||||||
except Exception:
|
|
||||||
# Alembic owns the transaction. Rollback restores the transactional RLS DDL.
|
|
||||||
raise
|
|
||||||
else:
|
|
||||||
_restore_postgres_rls(conn, rls_states)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
# The previous random UUID is intentionally not recoverable. Keeping the
|
|
||||||
# canonical identity preserves every FK and is safe for older application code.
|
|
||||||
pass
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
"""merge the published Space launch replay and main migration branches
|
|
||||||
|
|
||||||
Revision ID: 0018_merge_launch_replay
|
|
||||||
Revises: 0016_space_launch_replay, 0017_oss_workspace_identity
|
|
||||||
Create Date: 2026-08-01
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
revision = '0018_merge_launch_replay'
|
|
||||||
down_revision = ('0016_space_launch_replay', '0017_oss_workspace_identity')
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
pass
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
"""enforce one active owner per Workspace
|
|
||||||
|
|
||||||
Revision ID: 0019_single_workspace_owner
|
|
||||||
Revises: 0018_merge_launch_replay
|
|
||||||
Create Date: 2026-08-02
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
revision = '0019_single_workspace_owner'
|
|
||||||
down_revision = '0018_merge_launch_replay'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
_INDEX_NAME = 'uq_workspace_memberships_one_active_owner'
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
inspector = sa.inspect(conn)
|
|
||||||
if 'workspace_memberships' not in inspector.get_table_names():
|
|
||||||
return
|
|
||||||
|
|
||||||
# Ownership transfer used to promote a second member without demoting the
|
|
||||||
# original owner. Preserve the Workspace creator where possible and demote
|
|
||||||
# every historical extra owner before installing the database invariant.
|
|
||||||
op.execute(
|
|
||||||
sa.text(
|
|
||||||
"""
|
|
||||||
WITH ranked_owners AS (
|
|
||||||
SELECT membership.uuid,
|
|
||||||
ROW_NUMBER() OVER (
|
|
||||||
PARTITION BY membership.workspace_uuid
|
|
||||||
ORDER BY
|
|
||||||
CASE
|
|
||||||
WHEN membership.account_uuid = workspace.created_by_account_uuid THEN 0
|
|
||||||
ELSE 1
|
|
||||||
END,
|
|
||||||
COALESCE(membership.joined_at, membership.created_at),
|
|
||||||
membership.uuid
|
|
||||||
) AS owner_rank
|
|
||||||
FROM workspace_memberships AS membership
|
|
||||||
JOIN workspaces AS workspace
|
|
||||||
ON workspace.uuid = membership.workspace_uuid
|
|
||||||
WHERE membership.role = 'owner'
|
|
||||||
AND membership.status = 'active'
|
|
||||||
)
|
|
||||||
UPDATE workspace_memberships
|
|
||||||
SET role = 'admin'
|
|
||||||
WHERE uuid IN (
|
|
||||||
SELECT uuid
|
|
||||||
FROM ranked_owners
|
|
||||||
WHERE owner_rank > 1
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
)
|
|
||||||
# Fresh installations may already have this index because SQLAlchemy
|
|
||||||
# metadata is created before Alembic advances the revision marker.
|
|
||||||
op.execute(
|
|
||||||
sa.text(
|
|
||||||
'CREATE UNIQUE INDEX IF NOT EXISTS '
|
|
||||||
'uq_workspace_memberships_one_active_owner '
|
|
||||||
'ON workspace_memberships (workspace_uuid) '
|
|
||||||
"WHERE role = 'owner' AND status = 'active'"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
inspector = sa.inspect(conn)
|
|
||||||
if 'workspace_memberships' not in inspector.get_table_names():
|
|
||||||
return
|
|
||||||
index_names = {index['name'] for index in inspector.get_indexes('workspace_memberships')}
|
|
||||||
if _INDEX_NAME in index_names:
|
|
||||||
op.drop_index(_INDEX_NAME, table_name='workspace_memberships')
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
"""enable 3072-dimensional pgvector embeddings
|
|
||||||
|
|
||||||
Revision ID: 001a_pgvector_dimension_3072
|
|
||||||
Revises: 0019_single_workspace_owner
|
|
||||||
Create Date: 2026-08-05
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
revision = '001a_pgvector_dimension_3072'
|
|
||||||
down_revision = '0019_single_workspace_owner'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
_TABLE = 'langbot_vectors'
|
|
||||||
_CHECK = 'ck_langbot_vectors_embedding_dimension_enabled'
|
|
||||||
_INDEX = 'ix_langbot_vectors_hnsw_cosine_3072'
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
if conn.dialect.name != 'postgresql' or _TABLE not in sa.inspect(conn).get_table_names():
|
|
||||||
return
|
|
||||||
op.drop_constraint(_CHECK, _TABLE, type_='check')
|
|
||||||
op.create_check_constraint(_CHECK, _TABLE, 'embedding_dimension IN (384, 512, 768, 1024, 1536, 3072)')
|
|
||||||
op.execute(
|
|
||||||
sa.text(
|
|
||||||
f'CREATE INDEX {_INDEX} ON {_TABLE} USING hnsw ((embedding::halfvec(3072)) halfvec_cosine_ops) WHERE embedding_dimension = 3072'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
if conn.dialect.name != 'postgresql' or _TABLE not in sa.inspect(conn).get_table_names():
|
|
||||||
return
|
|
||||||
count = conn.scalar(sa.text(f'SELECT COUNT(*) FROM {_TABLE} WHERE embedding_dimension = 3072'))
|
|
||||||
if count:
|
|
||||||
raise RuntimeError('Cannot disable 3072-dimensional pgvector while matching embeddings exist')
|
|
||||||
op.drop_index(_INDEX, table_name=_TABLE)
|
|
||||||
op.drop_constraint(_CHECK, _TABLE, type_='check')
|
|
||||||
op.create_check_constraint(_CHECK, _TABLE, 'embedding_dimension IN (384, 512, 768, 1024, 1536)')
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
"""add explicit Workspace membership source
|
|
||||||
|
|
||||||
Revision ID: 0020_membership_source
|
|
||||||
Revises: 001a_pgvector_dimension_3072
|
|
||||||
Create Date: 2026-08-06
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
revision = '0020_membership_source'
|
|
||||||
down_revision = '001a_pgvector_dimension_3072'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
_CONSTRAINT_NAME = 'ck_workspace_memberships_source'
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
inspector = sa.inspect(conn)
|
|
||||||
if 'workspace_memberships' not in inspector.get_table_names():
|
|
||||||
return
|
|
||||||
if 'source' in {column['name'] for column in inspector.get_columns('workspace_memberships')}:
|
|
||||||
return
|
|
||||||
|
|
||||||
# No durable historical field distinguishes Directory-created revision-zero
|
|
||||||
# rows from Core invitations. Protect every existing row; production can
|
|
||||||
# reclassify separately after UUIDs have been verified against Space.
|
|
||||||
with op.batch_alter_table('workspace_memberships') as batch_op:
|
|
||||||
batch_op.add_column(sa.Column('source', sa.String(length=32), nullable=False, server_default='local'))
|
|
||||||
batch_op.create_check_constraint(
|
|
||||||
_CONSTRAINT_NAME,
|
|
||||||
"source IN ('local', 'cloud_projection')",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
inspector = sa.inspect(conn)
|
|
||||||
if 'workspace_memberships' not in inspector.get_table_names():
|
|
||||||
return
|
|
||||||
if 'source' not in {column['name'] for column in inspector.get_columns('workspace_memberships')}:
|
|
||||||
return
|
|
||||||
with op.batch_alter_table('workspace_memberships') as batch_op:
|
|
||||||
batch_op.drop_constraint(_CONSTRAINT_NAME, type_='check')
|
|
||||||
batch_op.drop_column('source')
|
|
||||||
@@ -54,7 +54,6 @@ _ALEMBIC_TENANT_TABLES = {
|
|||||||
'workspace_memberships',
|
'workspace_memberships',
|
||||||
'workspace_invitations',
|
'workspace_invitations',
|
||||||
'workspace_execution_states',
|
'workspace_execution_states',
|
||||||
'support_admin_temporary_sessions',
|
|
||||||
'workspace_metadata',
|
'workspace_metadata',
|
||||||
'api_keys',
|
'api_keys',
|
||||||
'bots',
|
'bots',
|
||||||
@@ -98,7 +97,7 @@ _WORKSPACE_ALEMBIC_REVISION = '0009_workspace_tenancy'
|
|||||||
_RESOURCE_SCOPE_ALEMBIC_REVISION = '0010_scope_resources'
|
_RESOURCE_SCOPE_ALEMBIC_REVISION = '0010_scope_resources'
|
||||||
_OSS_WORKSPACE_METADATA_KEY = 'oss_workspace_uuid'
|
_OSS_WORKSPACE_METADATA_KEY = 'oss_workspace_uuid'
|
||||||
_RELEASE_MIGRATION_ADVISORY_LOCK_ID = 0x4C414E47424F5432
|
_RELEASE_MIGRATION_ADVISORY_LOCK_ID = 0x4C414E47424F5432
|
||||||
_PGVECTOR_ALLOWED_DIMENSIONS = (384, 512, 768, 1024, 1536, 3072)
|
_PGVECTOR_ALLOWED_DIMENSIONS = (384, 512, 768, 1024, 1536)
|
||||||
_RUNTIME_SCHEMA = 'public'
|
_RUNTIME_SCHEMA = 'public'
|
||||||
_ALEMBIC_RUNTIME_TABLE = 'alembic_version'
|
_ALEMBIC_RUNTIME_TABLE = 'alembic_version'
|
||||||
_RUNTIME_TABLE_PRIVILEGES = frozenset({'SELECT', 'INSERT', 'UPDATE', 'DELETE'})
|
_RUNTIME_TABLE_PRIVILEGES = frozenset({'SELECT', 'INSERT', 'UPDATE', 'DELETE'})
|
||||||
@@ -1356,16 +1355,14 @@ class PersistenceManager:
|
|||||||
index = by_index.get(index_name)
|
index = by_index.get(index_name)
|
||||||
index_definition = normalized(None if index is None else index['definition'])
|
index_definition = normalized(None if index is None else index['definition'])
|
||||||
predicate = normalized(None if index is None else index['predicate'])
|
predicate = normalized(None if index is None else index['predicate'])
|
||||||
vector_type = 'halfvec' if dimension > 2000 else 'vector'
|
|
||||||
operator_class = f'{vector_type}_cosine_ops'
|
|
||||||
if (
|
if (
|
||||||
index is None
|
index is None
|
||||||
or index['access_method'] != 'hnsw'
|
or index['access_method'] != 'hnsw'
|
||||||
or index['is_valid'] is not True
|
or index['is_valid'] is not True
|
||||||
or index['is_ready'] is not True
|
or index['is_ready'] is not True
|
||||||
or f'{vector_type}({dimension})' not in index_definition
|
or f'vector({dimension})' not in index_definition
|
||||||
or f'(embedding)::{vector_type}({dimension})' not in index_definition
|
or f'(embedding)::vector({dimension})' not in index_definition
|
||||||
or operator_class not in index_definition
|
or 'vector_cosine_ops' not in index_definition
|
||||||
or predicate.strip('() ') != f'embedding_dimension = {dimension}'
|
or predicate.strip('() ') != f'embedding_dimension = {dimension}'
|
||||||
):
|
):
|
||||||
raise RuntimeError(f'PostgreSQL pgvector ANN index {index_name!r} is invalid')
|
raise RuntimeError(f'PostgreSQL pgvector ANN index {index_name!r} is invalid')
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import typing
|
|||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
import sqlalchemy.ext.asyncio as sqlalchemy_asyncio
|
import sqlalchemy.ext.asyncio as sqlalchemy_asyncio
|
||||||
import sqlalchemy.orm as sqlalchemy_orm
|
import sqlalchemy.orm as sqlalchemy_orm
|
||||||
from pgvector.sqlalchemy import HALFVEC, Vector
|
from pgvector.sqlalchemy import Vector
|
||||||
from sqlalchemy.dialects.postgresql.dml import OnConflictDoNothing as PostgreSQLOnConflictDoNothing
|
from sqlalchemy.dialects.postgresql.dml import OnConflictDoNothing as PostgreSQLOnConflictDoNothing
|
||||||
from sqlalchemy.dialects.postgresql.dml import OnConflictDoUpdate as PostgreSQLOnConflictDoUpdate
|
from sqlalchemy.dialects.postgresql.dml import OnConflictDoUpdate as PostgreSQLOnConflictDoUpdate
|
||||||
from sqlalchemy.dialects.sqlite.dml import OnConflictDoNothing as SQLiteOnConflictDoNothing
|
from sqlalchemy.dialects.sqlite.dml import OnConflictDoNothing as SQLiteOnConflictDoNothing
|
||||||
@@ -43,7 +43,6 @@ TENANT_TABLE_COLUMNS: dict[str, str] = {
|
|||||||
'workspace_memberships': 'workspace_uuid',
|
'workspace_memberships': 'workspace_uuid',
|
||||||
'workspace_invitations': 'workspace_uuid',
|
'workspace_invitations': 'workspace_uuid',
|
||||||
'workspace_execution_states': 'workspace_uuid',
|
'workspace_execution_states': 'workspace_uuid',
|
||||||
'support_admin_temporary_sessions': 'workspace_uuid',
|
|
||||||
'workspace_metadata': 'workspace_uuid',
|
'workspace_metadata': 'workspace_uuid',
|
||||||
'api_keys': 'workspace_uuid',
|
'api_keys': 'workspace_uuid',
|
||||||
'bots': 'workspace_uuid',
|
'bots': 'workspace_uuid',
|
||||||
@@ -209,7 +208,7 @@ _ALLOWED_SCOPED_BUILTIN_FUNCTION_TYPES = {
|
|||||||
'now': sqlalchemy.sql.functions.now,
|
'now': sqlalchemy.sql.functions.now,
|
||||||
'sum': sqlalchemy.sql.functions.sum,
|
'sum': sqlalchemy.sql.functions.sum,
|
||||||
}
|
}
|
||||||
_ALLOWED_SCOPED_GENERIC_FUNCTIONS = frozenset({'date_trunc', 'length', 'nullif'})
|
_ALLOWED_SCOPED_GENERIC_FUNCTIONS = frozenset({'length', 'nullif'})
|
||||||
_ALLOWED_SCOPED_CUSTOM_OPERATORS = frozenset({'<=>'})
|
_ALLOWED_SCOPED_CUSTOM_OPERATORS = frozenset({'<=>'})
|
||||||
_ALLOWED_SCOPED_STATEMENT_TYPES = (
|
_ALLOWED_SCOPED_STATEMENT_TYPES = (
|
||||||
sqlalchemy.sql.dml.UpdateBase,
|
sqlalchemy.sql.dml.UpdateBase,
|
||||||
@@ -281,7 +280,7 @@ def _validate_scoped_sql_type(
|
|||||||
return
|
return
|
||||||
seen.add(identity)
|
seen.add(identity)
|
||||||
|
|
||||||
if type(sql_type) in {Vector, HALFVEC}:
|
if type(sql_type) is Vector:
|
||||||
return
|
return
|
||||||
if not type(sql_type).__module__.startswith('sqlalchemy.'):
|
if not type(sql_type).__module__.startswith('sqlalchemy.'):
|
||||||
raise ScopedSessionTransactionError('TenantUnitOfWork does not allow custom SQL types in public statements')
|
raise ScopedSessionTransactionError('TenantUnitOfWork does not allow custom SQL types in public statements')
|
||||||
@@ -462,7 +461,7 @@ def _validate_scoped_statement_call(args: tuple[typing.Any, ...], kwargs: dict[s
|
|||||||
if isinstance(element, sqlalchemy.sql.elements.BindParameter) and element.literal_execute:
|
if isinstance(element, sqlalchemy.sql.elements.BindParameter) and element.literal_execute:
|
||||||
raise ScopedSessionTransactionError('TenantUnitOfWork does not allow literal-execute SQL parameters')
|
raise ScopedSessionTransactionError('TenantUnitOfWork does not allow literal-execute SQL parameters')
|
||||||
|
|
||||||
if isinstance(element, sqlalchemy.sql.elements.Cast) and type(element.type) not in {Vector, HALFVEC}:
|
if isinstance(element, sqlalchemy.sql.elements.Cast) and type(element.type) is not Vector:
|
||||||
raise ScopedSessionTransactionError(
|
raise ScopedSessionTransactionError(
|
||||||
'TenantUnitOfWork only allows the trusted pgvector cast used by tenant vector search'
|
'TenantUnitOfWork only allows the trusted pgvector cast used by tenant vector search'
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -132,7 +132,9 @@ class Controller:
|
|||||||
|
|
||||||
break
|
break
|
||||||
|
|
||||||
if not selected_query: # 没找到 说明:没有请求 或者 所有query对应的session都已达到并发上限
|
if selected_query: # 找到了
|
||||||
|
queries.remove(selected_query)
|
||||||
|
else: # 没找到 说明:没有请求 或者 所有query对应的session都已达到并发上限
|
||||||
await self.ap.query_pool.condition.wait()
|
await self.ap.query_pool.condition.wait()
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@@ -41,29 +41,6 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
selected_tool_names = {tool for tool in selected_tools if isinstance(tool, str)}
|
selected_tool_names = {tool for tool in selected_tools if isinstance(tool, str)}
|
||||||
return [tool for tool in tools if tool.name in selected_tool_names]
|
return [tool for tool in tools if tool.name in selected_tool_names]
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _append_to_system_prompt(
|
|
||||||
messages: list[provider_message.Message],
|
|
||||||
addition: str,
|
|
||||||
) -> None:
|
|
||||||
"""Append text to the first system message, creating one if none exists.
|
|
||||||
|
|
||||||
Handles both plain-string and content-element (list) message bodies.
|
|
||||||
"""
|
|
||||||
if messages and messages[0].role == 'system':
|
|
||||||
head = messages[0]
|
|
||||||
if isinstance(head.content, str):
|
|
||||||
head.content = head.content + addition
|
|
||||||
elif isinstance(head.content, list):
|
|
||||||
for ce in head.content:
|
|
||||||
if getattr(ce, 'type', None) == 'text':
|
|
||||||
ce.text = (ce.text or '') + addition
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
head.content.append(provider_message.ContentElement(type='text', text=addition))
|
|
||||||
else:
|
|
||||||
messages.insert(0, provider_message.Message(role='system', content=addition.strip()))
|
|
||||||
|
|
||||||
async def process(
|
async def process(
|
||||||
self,
|
self,
|
||||||
query: pipeline_query.Query,
|
query: pipeline_query.Query,
|
||||||
@@ -298,23 +275,6 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
query.prompt.messages = event_ctx.event.default_prompt
|
query.prompt.messages = event_ctx.event.default_prompt
|
||||||
query.messages = event_ctx.event.prompt
|
query.messages = event_ctx.event.prompt
|
||||||
|
|
||||||
# =========== Current date grounding for the local-agent runner ===========
|
|
||||||
# local-agent system prompts are static strings with no template-variable
|
|
||||||
# support, so without an explicit anchor the LLM resolves relative time
|
|
||||||
# references (e.g. "this quarter", "latest", "currently") against whichever
|
|
||||||
# period is best represented in its training data instead of the real date,
|
|
||||||
# and won't reliably know to double check time-sensitive facts with a tool.
|
|
||||||
if selected_runner == 'local-agent':
|
|
||||||
date_addition = (
|
|
||||||
f'\n\nCurrent date: {datetime.datetime.now().strftime("%Y-%m-%d (%A)")}. '
|
|
||||||
'Resolve relative time references (e.g. "today", "this quarter", "latest", '
|
|
||||||
'"currently") based on this date, not your training cutoff. For anything '
|
|
||||||
'time-sensitive that may have changed since training — stock prices, '
|
|
||||||
'financial results, news, current events, exchange rates, or similar — '
|
|
||||||
'verify with a search tool if one is available rather than answering from memory.'
|
|
||||||
)
|
|
||||||
self._append_to_system_prompt(query.prompt.messages, date_addition)
|
|
||||||
|
|
||||||
# =========== Skill awareness for the local-agent runner ===========
|
# =========== Skill awareness for the local-agent runner ===========
|
||||||
# The actual activation goes through the ``activate`` Tool Call so the
|
# The actual activation goes through the ``activate`` Tool Call so the
|
||||||
# LLM doesn't see full SKILL.md instructions until it commits to a
|
# LLM doesn't see full SKILL.md instructions until it commits to a
|
||||||
@@ -350,7 +310,27 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
bound_skills=bound_skills,
|
bound_skills=bound_skills,
|
||||||
)
|
)
|
||||||
if skill_addition:
|
if skill_addition:
|
||||||
self._append_to_system_prompt(query.prompt.messages, skill_addition)
|
# Append to the first system message; create one if the
|
||||||
|
# prompt has none. Handles both plain-string and
|
||||||
|
# content-element (list) message bodies.
|
||||||
|
if query.prompt.messages and query.prompt.messages[0].role == 'system':
|
||||||
|
head = query.prompt.messages[0]
|
||||||
|
if isinstance(head.content, str):
|
||||||
|
head.content = head.content + skill_addition
|
||||||
|
elif isinstance(head.content, list):
|
||||||
|
appended = False
|
||||||
|
for ce in head.content:
|
||||||
|
if getattr(ce, 'type', None) == 'text':
|
||||||
|
ce.text = (ce.text or '') + skill_addition
|
||||||
|
appended = True
|
||||||
|
break
|
||||||
|
if not appended:
|
||||||
|
head.content.append(provider_message.ContentElement(type='text', text=skill_addition))
|
||||||
|
else:
|
||||||
|
query.prompt.messages.insert(
|
||||||
|
0,
|
||||||
|
provider_message.Message(role='system', content=skill_addition.strip()),
|
||||||
|
)
|
||||||
self.ap.logger.debug(
|
self.ap.logger.debug(
|
||||||
f'Skill index injected into system prompt: '
|
f'Skill index injected into system prompt: '
|
||||||
f'pipeline={query.pipeline_uuid} '
|
f'pipeline={query.pipeline_uuid} '
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ from ....provider import runner as runner_module
|
|||||||
import langbot_plugin.api.entities.events as events
|
import langbot_plugin.api.entities.events as events
|
||||||
from ....utils import importutil, constants, runner as runner_utils
|
from ....utils import importutil, constants, runner as runner_utils
|
||||||
from ....telemetry import features as telemetry_features
|
from ....telemetry import features as telemetry_features
|
||||||
from ....telemetry.identity import workspace_identity
|
|
||||||
from ....provider import runners
|
from ....provider import runners
|
||||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||||
@@ -266,8 +265,7 @@ class ChatMessageHandler(handler.MessageHandler):
|
|||||||
'duration_ms': duration_ms,
|
'duration_ms': duration_ms,
|
||||||
'model_name': model_name,
|
'model_name': model_name,
|
||||||
'version': constants.semantic_version,
|
'version': constants.semantic_version,
|
||||||
**workspace_identity(get_query_execution_context(query)),
|
'instance_id': constants.instance_id,
|
||||||
'runtime_instance_id': constants.instance_id,
|
|
||||||
'edition': constants.edition,
|
'edition': constants.edition,
|
||||||
'pipeline_plugins': pipeline_plugins,
|
'pipeline_plugins': pipeline_plugins,
|
||||||
'features': features,
|
'features': features,
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
import sqlalchemy
|
|
||||||
|
|
||||||
from .. import handler
|
from .. import handler
|
||||||
from ... import entities
|
from ... import entities
|
||||||
from ... import plugin_diagnostics
|
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.message as provider_message
|
||||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||||
@@ -26,14 +24,7 @@ class CommandHandler(handler.MessageHandler):
|
|||||||
|
|
||||||
privilege = 1
|
privilege = 1
|
||||||
|
|
||||||
admins = await self.ap.persistence_mgr.execute_async(
|
if f'{query.launcher_type.value}_{query.launcher_id}' in self.ap.instance_config.data['admins']:
|
||||||
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:
|
|
||||||
privilege = 2
|
privilege = 2
|
||||||
|
|
||||||
spt = command_text.split(' ')
|
spt = command_text.split(' ')
|
||||||
|
|||||||
@@ -241,11 +241,7 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert
|
|||||||
async def process_message_data(msg_data, reply_list):
|
async def process_message_data(msg_data, reply_list):
|
||||||
if msg_data['type'] == 'image':
|
if msg_data['type'] == 'image':
|
||||||
image_base64, image_format = await image.qq_image_url_to_base64(msg_data['data']['url'])
|
image_base64, image_format = await image.qq_image_url_to_base64(msg_data['data']['url'])
|
||||||
reply_list.append(
|
reply_list.append(platform_message.Image(base64=f'data:image/{image_format};base64,{image_base64}'))
|
||||||
platform_message.Image(
|
|
||||||
url=msg_data['data']['url'], base64=f'data:image/{image_format};base64,{image_base64}'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
elif msg_data['type'] == 'text':
|
elif msg_data['type'] == 'text':
|
||||||
reply_list.append(platform_message.Plain(text=msg_data['data']['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)
|
image_msg = platform_message.Face(face_id=face_id, face_name=face_name)
|
||||||
else:
|
else:
|
||||||
image_base64, image_format = await image.qq_image_url_to_base64(msg.data['url'])
|
image_base64, image_format = await image.qq_image_url_to_base64(msg.data['url'])
|
||||||
image_msg = platform_message.Image(
|
image_msg = platform_message.Image(base64=f'data:image/{image_format};base64,{image_base64}')
|
||||||
url=msg.data['url'], base64=f'data:image/{image_format};base64,{image_base64}'
|
|
||||||
)
|
|
||||||
yiri_msg_list.append(image_msg)
|
yiri_msg_list.append(image_msg)
|
||||||
elif msg.type == 'forward':
|
elif msg.type == 'forward':
|
||||||
# 暂时不太合理
|
# 暂时不太合理
|
||||||
|
|||||||
@@ -764,9 +764,7 @@ class DiscordMessageConverter(abstract_platform_adapter.AbstractMessageConverter
|
|||||||
)
|
)
|
||||||
image_base64 = (await asyncio.to_thread(base64.b64encode, image_data)).decode('utf-8')
|
image_base64 = (await asyncio.to_thread(base64.b64encode, image_data)).decode('utf-8')
|
||||||
image_format = response.headers['Content-Type']
|
image_format = response.headers['Content-Type']
|
||||||
element_list.append(
|
element_list.append(platform_message.Image(base64=f'data:{image_format};base64,{image_base64}'))
|
||||||
platform_message.Image(url=attachment.url, base64=f'data:{image_format};base64,{image_base64}')
|
|
||||||
)
|
|
||||||
|
|
||||||
return platform_message.MessageChain(element_list)
|
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()))
|
yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now()))
|
||||||
if pic_url is not None:
|
if pic_url is not None:
|
||||||
base64_url = await image.get_qq_official_image_base64(pic_url=pic_url, content_type=content_type)
|
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))
|
yiri_msg_list.append(platform_message.Plain(text=message))
|
||||||
chain = platform_message.MessageChain(yiri_msg_list)
|
chain = platform_message.MessageChain(yiri_msg_list)
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ class SlackMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
|||||||
yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now()))
|
yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now()))
|
||||||
if pic_url is not None:
|
if pic_url is not None:
|
||||||
base64_url = await image.get_slack_image_to_base64(pic_url=pic_url, bot_token=bot.bot_token)
|
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))
|
yiri_msg_list.append(platform_message.Plain(text=message))
|
||||||
chain = platform_message.MessageChain(yiri_msg_list)
|
chain = platform_message.MessageChain(yiri_msg_list)
|
||||||
|
|||||||
@@ -179,15 +179,9 @@ class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverte
|
|||||||
)
|
)
|
||||||
file_format = 'image/jpeg'
|
file_format = 'image/jpeg'
|
||||||
|
|
||||||
# NOTE: Telegram's file.file_path is a full URL of the form
|
|
||||||
# https://api.telegram.org/file/bot<TOKEN>/<path> which embeds the
|
|
||||||
# bot token. Unlike the public CDN URLs used by other adapters, it
|
|
||||||
# cannot be exposed safely, so only base64 is stored here.
|
|
||||||
encoded = await asyncio.to_thread(base64.b64encode, file_bytes)
|
encoded = await asyncio.to_thread(base64.b64encode, file_bytes)
|
||||||
message_components.append(
|
message_components.append(
|
||||||
platform_message.Image(
|
platform_message.Image(base64=f'data:{file_format};base64,{encoded.decode("utf-8")}')
|
||||||
base64=f'data:{file_format};base64,{encoded.decode("utf-8")}',
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if message.voice:
|
if message.voice:
|
||||||
|
|||||||
@@ -707,37 +707,28 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
if len(listener_tasks) >= 100:
|
if len(listener_tasks) >= 100:
|
||||||
await self.logger.warning('WebSocket inbound listener capacity reached; dropping message')
|
await self.logger.warning('WebSocket inbound listener capacity reached; dropping message')
|
||||||
return
|
return
|
||||||
listener = typing.cast(
|
token = _current_pipeline_uuid.set(pipeline_uuid)
|
||||||
typing.Callable[[typing.Any, typing.Any], typing.Awaitable[None]],
|
try:
|
||||||
listeners[event.__class__],
|
task_manager = getattr(self.ap, 'task_mgr', None)
|
||||||
)
|
if task_manager is None or not isinstance(getattr(task_manager, 'tasks', None), list):
|
||||||
|
listener_task = asyncio.create_task(listeners[event.__class__](event, callback_adapter))
|
||||||
async def run_listener():
|
else:
|
||||||
token = _current_pipeline_uuid.set(pipeline_uuid)
|
listener_task = task_manager.create_task(
|
||||||
try:
|
listeners[event.__class__](event, callback_adapter),
|
||||||
await listener(event, callback_adapter)
|
kind='websocket-message',
|
||||||
finally:
|
name=f'websocket-message-{connection.connection_id}',
|
||||||
_current_pipeline_uuid.reset(token)
|
scopes=[
|
||||||
|
core_entities.LifecycleControlScope.APPLICATION,
|
||||||
listener_coro = run_listener()
|
core_entities.LifecycleControlScope.PLATFORM,
|
||||||
task_manager = getattr(self.ap, 'task_mgr', None)
|
],
|
||||||
if task_manager is None or not isinstance(getattr(task_manager, 'tasks', None), list):
|
instance_uuid=connection.instance_uuid,
|
||||||
listener_task = asyncio.create_task(listener_coro)
|
workspace_uuid=connection.workspace_uuid,
|
||||||
else:
|
placement_generation=connection.placement_generation,
|
||||||
listener_task = task_manager.create_task(
|
).task
|
||||||
listener_coro,
|
listener_tasks.add(listener_task)
|
||||||
kind='websocket-message',
|
listener_task.add_done_callback(self._listener_task_done)
|
||||||
name=f'websocket-message-{connection.connection_id}',
|
finally:
|
||||||
scopes=[
|
_current_pipeline_uuid.reset(token)
|
||||||
core_entities.LifecycleControlScope.APPLICATION,
|
|
||||||
core_entities.LifecycleControlScope.PLATFORM,
|
|
||||||
],
|
|
||||||
instance_uuid=connection.instance_uuid,
|
|
||||||
workspace_uuid=connection.workspace_uuid,
|
|
||||||
placement_generation=connection.placement_generation,
|
|
||||||
).task
|
|
||||||
listener_tasks.add(listener_task)
|
|
||||||
listener_task.add_done_callback(self._listener_task_done)
|
|
||||||
|
|
||||||
def get_websocket_messages(
|
def get_websocket_messages(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from datetime import datetime
|
|||||||
|
|
||||||
import pydantic
|
import pydantic
|
||||||
|
|
||||||
from ...api.http.context import ExecutionContext, PrincipalContext
|
from ...api.http.context import ExecutionContext
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
_SESSION_FILTER_UNSET = object()
|
_SESSION_FILTER_UNSET = object()
|
||||||
@@ -95,9 +95,6 @@ class WebSocketConnection(pydantic.BaseModel):
|
|||||||
metadata: dict = pydantic.Field(default_factory=dict)
|
metadata: dict = pydantic.Field(default_factory=dict)
|
||||||
"""连接元数据(可存储额外信息)"""
|
"""连接元数据(可存储额外信息)"""
|
||||||
|
|
||||||
trigger_principal: PrincipalContext | None = None
|
|
||||||
"""Authenticated principal that opened this dashboard connection."""
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def scope(self) -> WebSocketScope:
|
def scope(self) -> WebSocketScope:
|
||||||
return WebSocketScope(
|
return WebSocketScope(
|
||||||
@@ -115,7 +112,6 @@ class WebSocketConnection(pydantic.BaseModel):
|
|||||||
workspace_uuid=self.workspace_uuid,
|
workspace_uuid=self.workspace_uuid,
|
||||||
placement_generation=self.placement_generation,
|
placement_generation=self.placement_generation,
|
||||||
pipeline_uuid=self.pipeline_uuid,
|
pipeline_uuid=self.pipeline_uuid,
|
||||||
trigger_principal=self.trigger_principal,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -142,7 +138,6 @@ class WebSocketConnectionManager:
|
|||||||
pipeline_uuid: str,
|
pipeline_uuid: str,
|
||||||
session_type: str,
|
session_type: str,
|
||||||
metadata: dict | None = None,
|
metadata: dict | None = None,
|
||||||
trigger_principal: PrincipalContext | None = None,
|
|
||||||
session_id: str | None = None,
|
session_id: str | None = None,
|
||||||
send_queue_size: int = _DEFAULT_SEND_QUEUE_SIZE,
|
send_queue_size: int = _DEFAULT_SEND_QUEUE_SIZE,
|
||||||
max_connections: int = 1024,
|
max_connections: int = 1024,
|
||||||
@@ -179,7 +174,6 @@ class WebSocketConnectionManager:
|
|||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
websocket=websocket,
|
websocket=websocket,
|
||||||
metadata=metadata or {},
|
metadata=metadata or {},
|
||||||
trigger_principal=trigger_principal,
|
|
||||||
send_queue=asyncio.Queue(maxsize=send_queue_size),
|
send_queue=asyncio.Queue(maxsize=send_queue_size),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -133,9 +133,7 @@ class WecomMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
|||||||
yiri_msg_list = []
|
yiri_msg_list = []
|
||||||
yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now()))
|
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)
|
image_base64, image_format = await image.get_wecom_image_base64(pic_url=picurl)
|
||||||
yiri_msg_list.append(
|
yiri_msg_list.append(platform_message.Image(base64=f'data:image/{image_format};base64,{image_base64}'))
|
||||||
platform_message.Image(url=picurl, base64=f'data:image/{image_format};base64,{image_base64}')
|
|
||||||
)
|
|
||||||
chain = platform_message.MessageChain(yiri_msg_list)
|
chain = platform_message.MessageChain(yiri_msg_list)
|
||||||
|
|
||||||
return chain
|
return chain
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import contextlib
|
|||||||
import contextvars
|
import contextvars
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import math
|
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -77,7 +76,7 @@ _GITHUB_ASSET_HOSTS = frozenset(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
_HTTP_REDIRECT_STATUSES = frozenset({301, 302, 303, 307, 308})
|
_HTTP_REDIRECT_STATUSES = frozenset({301, 302, 303, 307, 308})
|
||||||
_DEFAULT_CONNECT_TIMEOUT_SECONDS = 180.0
|
_CONNECT_TIMEOUT_SEC = 30.0
|
||||||
_HEARTBEAT_INTERVAL_SEC = 20.0
|
_HEARTBEAT_INTERVAL_SEC = 20.0
|
||||||
_HEARTBEAT_FAILURE_THRESHOLD = 3
|
_HEARTBEAT_FAILURE_THRESHOLD = 3
|
||||||
_RECONNECT_MAX_DELAY_SEC = 60.0
|
_RECONNECT_MAX_DELAY_SEC = 60.0
|
||||||
@@ -207,17 +206,6 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
|
|
||||||
return f'{constants.instance_id}:plugin-runtime'
|
return f'{constants.instance_id}:plugin-runtime'
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _runtime_connect_timeout(plugin_config: dict[str, Any]) -> float:
|
|
||||||
value = plugin_config.get('connect_timeout_seconds', _DEFAULT_CONNECT_TIMEOUT_SECONDS)
|
|
||||||
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value <= 0:
|
|
||||||
raise ValueError('plugin.connect_timeout_seconds must be a positive number')
|
|
||||||
return float(value)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _runtime_connect_timeout_error(timeout_seconds: float) -> str:
|
|
||||||
return f'Plugin runtime did not become ready within {timeout_seconds:g} seconds'
|
|
||||||
|
|
||||||
def _runtime_handler(self) -> handler.RuntimeConnectionHandler:
|
def _runtime_handler(self) -> handler.RuntimeConnectionHandler:
|
||||||
runtime_handler = getattr(self, 'handler', None)
|
runtime_handler = getattr(self, 'handler', None)
|
||||||
if runtime_handler is None:
|
if runtime_handler is None:
|
||||||
@@ -263,8 +251,6 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
def _control_headers(self, *, allow_generate: bool) -> dict[str, str]:
|
def _control_headers(self, *, allow_generate: bool) -> dict[str, str]:
|
||||||
if not self._control_token and allow_generate:
|
if not self._control_token and allow_generate:
|
||||||
self._control_token = secrets.token_urlsafe(48)
|
self._control_token = secrets.token_urlsafe(48)
|
||||||
if not self._control_token:
|
|
||||||
return {}
|
|
||||||
try:
|
try:
|
||||||
self._control_token = validate_runtime_secret(
|
self._control_token = validate_runtime_secret(
|
||||||
self._control_token,
|
self._control_token,
|
||||||
@@ -713,13 +699,10 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
runtime_handler = self._runtime_handler()
|
runtime_handler = self._runtime_handler()
|
||||||
started_at = time.monotonic()
|
|
||||||
async with self._state_lock:
|
async with self._state_lock:
|
||||||
all_states: dict[str, PluginInstallationDesiredState] = {}
|
all_states: dict[str, PluginInstallationDesiredState] = {}
|
||||||
workspace_installations: dict[str, set[str]] = {}
|
workspace_installations: dict[str, set[str]] = {}
|
||||||
workspace_count = 0
|
|
||||||
for context in contexts:
|
for context in contexts:
|
||||||
workspace_count += 1
|
|
||||||
execution_context = await self._validate_execution_context(context)
|
execution_context = await self._validate_execution_context(context)
|
||||||
states = await self._load_workspace_desired_states(execution_context)
|
states = await self._load_workspace_desired_states(execution_context)
|
||||||
installation_ids = {state.binding.installation_uuid for state in states}
|
installation_ids = {state.binding.installation_uuid for state in states}
|
||||||
@@ -739,13 +722,6 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
runtime_handler.unregister_installation_binding(previous.binding)
|
runtime_handler.unregister_installation_binding(previous.binding)
|
||||||
self._known_desired_states = all_states
|
self._known_desired_states = all_states
|
||||||
self._workspace_installations = workspace_installations
|
self._workspace_installations = workspace_installations
|
||||||
self.ap.logger.info(
|
|
||||||
'Shared plugin runtime reconcile completed: workspaces=%d desired_installations=%d '
|
|
||||||
'elapsed_seconds=%.3f',
|
|
||||||
workspace_count,
|
|
||||||
len(all_states),
|
|
||||||
time.monotonic() - started_at,
|
|
||||||
)
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def _validate_execution_context(self, context: TenantContext) -> ExecutionContext:
|
async def _validate_execution_context(self, context: TenantContext) -> ExecutionContext:
|
||||||
@@ -841,8 +817,6 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
runtime_id=self._runtime_id,
|
runtime_id=self._runtime_id,
|
||||||
)
|
)
|
||||||
self.worker_policy = self._load_worker_policy()
|
self.worker_policy = self._load_worker_policy()
|
||||||
plugin_config = self.ap.instance_config.data.get('plugin', {})
|
|
||||||
connect_timeout_seconds = self._runtime_connect_timeout(plugin_config)
|
|
||||||
|
|
||||||
async with self._lifecycle_lock:
|
async with self._lifecycle_lock:
|
||||||
if self._closing:
|
if self._closing:
|
||||||
@@ -984,12 +958,10 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
|
|
||||||
self._transport_task = asyncio.create_task(task_coro)
|
self._transport_task = asyncio.create_task(task_coro)
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(self._connected.wait(), timeout=connect_timeout_seconds)
|
await asyncio.wait_for(self._connected.wait(), timeout=_CONNECT_TIMEOUT_SEC)
|
||||||
except asyncio.TimeoutError as exc:
|
except asyncio.TimeoutError as exc:
|
||||||
await self._stop_transport()
|
await self._stop_transport()
|
||||||
raise PluginRuntimeNotConnectedError(
|
raise PluginRuntimeNotConnectedError('Plugin runtime did not become ready within 30 seconds') from exc
|
||||||
self._runtime_connect_timeout_error(connect_timeout_seconds)
|
|
||||||
) from exc
|
|
||||||
if connect_errors:
|
if connect_errors:
|
||||||
await self._stop_transport()
|
await self._stop_transport()
|
||||||
raise PluginRuntimeNotConnectedError(f'Plugin runtime connection failed: {connect_errors[-1]}')
|
raise PluginRuntimeNotConnectedError(f'Plugin runtime connection failed: {connect_errors[-1]}')
|
||||||
@@ -1996,11 +1968,11 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
with runtime_handler.installation_scope(binding):
|
with runtime_handler.installation_scope(binding):
|
||||||
return await runtime_handler.handle_page_api(plugin_author, plugin_name, page_id, endpoint, method, body)
|
return await runtime_handler.handle_page_api(plugin_author, plugin_name, page_id, endpoint, method, body)
|
||||||
|
|
||||||
async def get_debug_info(self, execution_context: ExecutionContext) -> dict[str, Any]:
|
async def get_debug_info(self) -> dict[str, Any]:
|
||||||
"""Get debug information including debug key and WS URL"""
|
"""Get debug information including debug key and WS URL"""
|
||||||
if not self.is_enable_plugin or not self._runtime_available():
|
if not self.is_enable_plugin or not self._runtime_available():
|
||||||
return {}
|
return {}
|
||||||
return await self._runtime_handler().get_debug_info(execution_context)
|
return await self._runtime_handler().get_debug_info()
|
||||||
|
|
||||||
async def emit_event(
|
async def emit_event(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -1960,14 +1960,14 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def get_debug_info(self, execution_context: ExecutionContext) -> dict[str, Any]:
|
async def get_debug_info(self) -> dict[str, Any]:
|
||||||
"""Get debug information including debug key and WS URL"""
|
"""Get debug information including debug key and WS URL"""
|
||||||
result = await self.call_action(
|
with self.installation_scope(None):
|
||||||
LangBotToRuntimeAction.GET_DEBUG_INFO,
|
result = await self.call_action(
|
||||||
{},
|
LangBotToRuntimeAction.GET_DEBUG_INFO,
|
||||||
timeout=10,
|
{},
|
||||||
action_context=execution_context,
|
timeout=10,
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# ================= RAG Capability Callers (LangBot -> Runtime) =================
|
# ================= RAG Capability Callers (LangBot -> Runtime) =================
|
||||||
|
|||||||
@@ -529,7 +529,6 @@ class ModelManager:
|
|||||||
model['uuid']: model
|
model['uuid']: model
|
||||||
for model in await self.ap.embedding_models_service.get_embedding_models(context, include_secret=True)
|
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
|
created = 0
|
||||||
updated = 0
|
updated = 0
|
||||||
@@ -598,36 +597,6 @@ class ModelManager:
|
|||||||
)
|
)
|
||||||
updated += 1
|
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:
|
if created or updated:
|
||||||
self.ap.logger.info(f'Synced models from LangBot Space: {created} added, {updated} 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:
|
if api_key:
|
||||||
headers['Authorization'] = f'Bearer {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] = {
|
payload: dict[str, typing.Any] = {
|
||||||
'model': model_name,
|
'model': model_name,
|
||||||
'query': query,
|
'query': query,
|
||||||
'documents': documents,
|
'documents': documents,
|
||||||
'top_n': top_n,
|
'top_n': top_n,
|
||||||
}
|
}
|
||||||
if request_args:
|
if extra_args:
|
||||||
payload.update(request_args)
|
payload.update(extra_args)
|
||||||
|
|
||||||
if not rerank_url:
|
rerank_url = f'{base_url}/rerank'
|
||||||
rerank_url = f'{base_url}/{str(rerank_path).strip("/")}'
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
|
|||||||
@@ -27,19 +27,6 @@ if typing.TYPE_CHECKING:
|
|||||||
HEARTBEAT_INTERVAL_SECONDS = 24 * 3600
|
HEARTBEAT_INTERVAL_SECONDS = 24 * 3600
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceResourceSnapshot(typing.TypedDict):
|
|
||||||
workspace_uuid: str
|
|
||||||
bot_count: int
|
|
||||||
pipeline_count: int
|
|
||||||
knowledge_base_count: int
|
|
||||||
plugin_count: int
|
|
||||||
mcp_server_count: int
|
|
||||||
extension_count: int
|
|
||||||
skill_count: int
|
|
||||||
adapters: list[str]
|
|
||||||
execution_generation: int
|
|
||||||
|
|
||||||
|
|
||||||
async def _count(
|
async def _count(
|
||||||
ap: core_app.Application,
|
ap: core_app.Application,
|
||||||
table,
|
table,
|
||||||
@@ -65,65 +52,8 @@ async def _count(
|
|||||||
return -1
|
return -1
|
||||||
|
|
||||||
|
|
||||||
async def _cloud_workspace_resource_counts(ap: core_app.Application, bindings) -> list[WorkspaceResourceSnapshot]:
|
async def build_heartbeat_payload(ap: core_app.Application) -> dict:
|
||||||
"""Summarize already-loaded Cloud registries without per-tenant SQL."""
|
"""Collect the anonymous instance profile snapshot."""
|
||||||
persistence_mgr = ap.persistence_mgr
|
|
||||||
if getattr(getattr(persistence_mgr, 'mode', None), 'value', None) != 'cloud_runtime':
|
|
||||||
return []
|
|
||||||
|
|
||||||
counts: dict[str, WorkspaceResourceSnapshot] = {
|
|
||||||
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,
|
|
||||||
'skill_count': 0,
|
|
||||||
'adapters': [],
|
|
||||||
'execution_generation': binding.placement_generation,
|
|
||||||
}
|
|
||||||
for binding in bindings
|
|
||||||
}
|
|
||||||
|
|
||||||
adapter_sets: dict[str, set[str]] = {workspace_uuid: set() for workspace_uuid in counts}
|
|
||||||
for key, bot in getattr(ap.platform_mgr, '_bots_by_key', {}).items():
|
|
||||||
if len(key) >= 2 and key[1] in counts:
|
|
||||||
counts[key[1]]['bot_count'] += 1
|
|
||||||
adapter = getattr(bot, 'adapter', None)
|
|
||||||
if adapter is not None and getattr(bot, 'enable', False):
|
|
||||||
adapter_sets[key[1]].add(adapter.__class__.__name__)
|
|
||||||
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 key, skills in getattr(ap.skill_mgr, '_skills_by_scope', {}).items():
|
|
||||||
if len(key) >= 2 and key[1] in counts:
|
|
||||||
counts[key[1]]['skill_count'] += len(skills)
|
|
||||||
|
|
||||||
for workspace_uuid, resource in counts.items():
|
|
||||||
resource['extension_count'] = resource['plugin_count'] + resource['mcp_server_count']
|
|
||||||
resource['adapters'] = sorted(adapter_sets[workspace_uuid])
|
|
||||||
return list(counts.values())
|
|
||||||
|
|
||||||
|
|
||||||
async def build_heartbeat_payload(
|
|
||||||
ap: core_app.Application,
|
|
||||||
*,
|
|
||||||
workspace_uuid: str,
|
|
||||||
workspace_create_ts: int = 0,
|
|
||||||
workspace_resource: WorkspaceResourceSnapshot | None = None,
|
|
||||||
) -> dict:
|
|
||||||
"""Collect one anonymous Workspace profile snapshot."""
|
|
||||||
from ..entity.persistence import bot as persistence_bot
|
from ..entity.persistence import bot as persistence_bot
|
||||||
from ..entity.persistence import mcp as persistence_mcp
|
from ..entity.persistence import mcp as persistence_mcp
|
||||||
from ..entity.persistence import pipeline as persistence_pipeline
|
from ..entity.persistence import pipeline as persistence_pipeline
|
||||||
@@ -206,16 +136,11 @@ async def build_heartbeat_payload(
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if workspace_resource is not None:
|
|
||||||
features.update({key: value for key, value in workspace_resource.items() if key != 'workspace_uuid'})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'event_type': 'instance_heartbeat',
|
'event_type': 'instance_heartbeat',
|
||||||
'query_id': '',
|
'query_id': '',
|
||||||
'version': constants.semantic_version,
|
'version': constants.semantic_version,
|
||||||
'instance_id': constants.instance_id,
|
'instance_id': constants.instance_id,
|
||||||
'workspace_uuid': workspace_uuid,
|
|
||||||
'workspace_create_ts': workspace_create_ts,
|
|
||||||
'instance_create_ts': constants.instance_create_ts,
|
'instance_create_ts': constants.instance_create_ts,
|
||||||
'edition': constants.edition,
|
'edition': constants.edition,
|
||||||
'features': features,
|
'features': features,
|
||||||
@@ -223,49 +148,14 @@ async def build_heartbeat_payload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _workspace_created_timestamp(created_at: datetime | None) -> int:
|
|
||||||
if created_at is None:
|
|
||||||
return 0
|
|
||||||
if created_at.tzinfo is None:
|
|
||||||
# SQLAlchemy may return persisted UTC values without tzinfo. Never
|
|
||||||
# reinterpret them in the host's local timezone.
|
|
||||||
created_at = created_at.replace(tzinfo=timezone.utc)
|
|
||||||
return int(created_at.timestamp())
|
|
||||||
|
|
||||||
|
|
||||||
async def build_heartbeat_payloads(ap: core_app.Application) -> list[dict]:
|
|
||||||
"""Build one heartbeat per active Workspace."""
|
|
||||||
bindings = await ap.workspace_service.list_active_execution_bindings()
|
|
||||||
workspace_uuids = sorted({binding.workspace_uuid for binding in bindings})
|
|
||||||
workspace_create_ts = {
|
|
||||||
binding.workspace_uuid: _workspace_created_timestamp(getattr(binding, 'workspace_created_at', None))
|
|
||||||
for binding in bindings
|
|
||||||
}
|
|
||||||
resources = {
|
|
||||||
resource['workspace_uuid']: resource for resource in await _cloud_workspace_resource_counts(ap, bindings)
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
await build_heartbeat_payload(
|
|
||||||
ap,
|
|
||||||
workspace_uuid=workspace_uuid,
|
|
||||||
workspace_create_ts=workspace_create_ts.get(workspace_uuid, 0),
|
|
||||||
workspace_resource=resources.get(workspace_uuid),
|
|
||||||
)
|
|
||||||
for workspace_uuid in workspace_uuids
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def heartbeat_loop(ap: core_app.Application) -> None:
|
async def heartbeat_loop(ap: core_app.Application) -> None:
|
||||||
"""Send one heartbeat shortly after startup, then daily."""
|
"""Send one heartbeat shortly after startup, then daily."""
|
||||||
# Small delay so managers (platform, skills, plugins) finish loading first
|
# Small delay so managers (platform, skills, plugins) finish loading first
|
||||||
await asyncio.sleep(30)
|
await asyncio.sleep(30)
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
for payload in await build_heartbeat_payloads(ap):
|
payload = await build_heartbeat_payload(ap)
|
||||||
# Heartbeats are a daily bounded batch, not best-effort query events.
|
await ap.telemetry.start_send_task(payload)
|
||||||
# Await each send so the TelemetryManager's 8-task queue cannot drop
|
|
||||||
# Workspaces after the first batch.
|
|
||||||
await ap.telemetry.send(payload)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
try:
|
try:
|
||||||
ap.logger.debug(f'Telemetry heartbeat failed: {e}')
|
ap.logger.debug(f'Telemetry heartbeat failed: {e}')
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import typing
|
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceExecutionContext(typing.Protocol):
|
|
||||||
@property
|
|
||||||
def instance_uuid(self) -> str: ...
|
|
||||||
|
|
||||||
@property
|
|
||||||
def workspace_uuid(self) -> str: ...
|
|
||||||
|
|
||||||
|
|
||||||
def workspace_identity(execution_context: WorkspaceExecutionContext) -> dict[str, str]:
|
|
||||||
"""Build both first-class telemetry identities for one execution."""
|
|
||||||
instance_id = execution_context.instance_uuid.strip()
|
|
||||||
workspace_uuid = execution_context.workspace_uuid.strip()
|
|
||||||
if not instance_id:
|
|
||||||
raise ValueError('Telemetry execution instance ID is empty')
|
|
||||||
if not workspace_uuid:
|
|
||||||
raise ValueError('Telemetry execution Workspace UUID is empty')
|
|
||||||
return {'instance_id': instance_id, 'workspace_uuid': workspace_uuid}
|
|
||||||
@@ -2,11 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
import os
|
|
||||||
import typing
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from ..core import app as core_app
|
from ..core import app as core_app
|
||||||
from ..utils import httpclient
|
from ..utils import httpclient
|
||||||
|
|
||||||
@@ -25,7 +21,7 @@ class TelemetryManager:
|
|||||||
def __init__(self, ap: core_app.Application):
|
def __init__(self, ap: core_app.Application):
|
||||||
self.ap = ap
|
self.ap = ap
|
||||||
|
|
||||||
self.telemetry_config: dict[str, typing.Any] = {}
|
self.telemetry_config = {}
|
||||||
self.send_tasks: list[asyncio.Task] = []
|
self.send_tasks: list[asyncio.Task] = []
|
||||||
self._client: httpx.AsyncClient | None = None
|
self._client: httpx.AsyncClient | None = None
|
||||||
|
|
||||||
@@ -135,35 +131,7 @@ class TelemetryManager:
|
|||||||
async with self._client_context() as client:
|
async with self._client_context() as client:
|
||||||
try:
|
try:
|
||||||
# Use asyncio.wait_for to ensure we always bound the total time
|
# Use asyncio.wait_for to ensure we always bound the total time
|
||||||
telemetry_token = os.getenv('LANGBOT_TELEMETRY_INGEST_TOKEN', '').strip()
|
resp = await asyncio.wait_for(client.post(url, json=sanitized), timeout=10 + 1)
|
||||||
headers: dict[str, str] = {}
|
|
||||||
if telemetry_token:
|
|
||||||
headers['X-LangBot-Telemetry-Token'] = telemetry_token
|
|
||||||
else:
|
|
||||||
workspace_uuid = str(sanitized.get('workspace_uuid', '')).strip()
|
|
||||||
user_service = getattr(self.ap, 'user_service', None)
|
|
||||||
if workspace_uuid and user_service is not None:
|
|
||||||
try:
|
|
||||||
owner = await user_service.get_workspace_owner(workspace_uuid)
|
|
||||||
owner_email = str(getattr(owner, 'user', '') or '').strip()
|
|
||||||
space_service = getattr(self.ap, 'space_service', None)
|
|
||||||
access_token = (
|
|
||||||
await space_service.get_valid_access_token(owner_email)
|
|
||||||
if owner_email and space_service is not None
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
access_token = str(access_token or '').strip()
|
|
||||||
if access_token:
|
|
||||||
headers['Authorization'] = f'Bearer {access_token}'
|
|
||||||
except Exception:
|
|
||||||
self.ap.logger.debug(
|
|
||||||
'Could not resolve authenticated telemetry reporter', exc_info=True
|
|
||||||
)
|
|
||||||
if headers:
|
|
||||||
request = client.post(url, json=sanitized, headers=headers)
|
|
||||||
else:
|
|
||||||
request = client.post(url, json=sanitized)
|
|
||||||
resp = await asyncio.wait_for(request, timeout=10 + 1)
|
|
||||||
|
|
||||||
if resp.status_code >= 400:
|
if resp.status_code >= 400:
|
||||||
body = await httpclient.response_text(resp, max_chars=200)
|
body = await httpclient.response_text(resp, max_chars=200)
|
||||||
@@ -175,8 +143,7 @@ class TelemetryManager:
|
|||||||
app_err = False
|
app_err = False
|
||||||
try:
|
try:
|
||||||
j = await httpclient.parse_json_response(resp)
|
j = await httpclient.parse_json_response(resp)
|
||||||
app_code = j.get('code') if isinstance(j, dict) else None
|
if isinstance(j, dict) and j.get('code') is not None and int(j.get('code')) >= 400:
|
||||||
if app_code is not None and int(app_code) >= 400:
|
|
||||||
app_err = True
|
app_err = True
|
||||||
self.ap.logger.warning(
|
self.ap.logger.warning(
|
||||||
f'Telemetry post to {url} returned application error code {j.get("code")} - {j.get("msg")}'
|
f'Telemetry post to {url} returned application error code {j.get("code")} - {j.get("msg")}'
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ class VectorDBManager:
|
|||||||
use_business_database = pgvector_config.get('use_business_database', False)
|
use_business_database = pgvector_config.get('use_business_database', False)
|
||||||
allowed_dimensions = pgvector_config.get(
|
allowed_dimensions = pgvector_config.get(
|
||||||
'allowed_dimensions',
|
'allowed_dimensions',
|
||||||
[384, 512, 768, 1024, 1536, 3072],
|
[384, 512, 768, 1024, 1536],
|
||||||
)
|
)
|
||||||
common_options = {
|
common_options = {
|
||||||
'use_business_database': use_business_database,
|
'use_business_database': use_business_database,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from collections.abc import AsyncIterator
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
from pgvector.sqlalchemy import HALFVEC, Vector
|
from pgvector.sqlalchemy import Vector
|
||||||
from sqlalchemy.dialects.postgresql import insert as postgresql_insert
|
from sqlalchemy.dialects.postgresql import insert as postgresql_insert
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.orm import declarative_base
|
from sqlalchemy.orm import declarative_base
|
||||||
@@ -18,7 +18,7 @@ from langbot.pkg.vector.vdb import VectorDatabase
|
|||||||
|
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
DEFAULT_ALLOWED_DIMENSIONS = (384, 512, 768, 1024, 1536, 3072)
|
DEFAULT_ALLOWED_DIMENSIONS = (384, 512, 768, 1024, 1536)
|
||||||
|
|
||||||
# pgvector schema only stores these metadata fields.
|
# pgvector schema only stores these metadata fields.
|
||||||
_PG_SUPPORTED_FIELDS = {'text', 'file_id', 'chunk_uuid'}
|
_PG_SUPPORTED_FIELDS = {'text', 'file_id', 'chunk_uuid'}
|
||||||
@@ -321,12 +321,7 @@ class PgVectorDatabase(VectorDatabase):
|
|||||||
if len(query_embedding) != scope.embedding_dimension:
|
if len(query_embedding) != scope.embedding_dimension:
|
||||||
raise ValueError(f'Query embedding must have the selected dimension {scope.embedding_dimension}')
|
raise ValueError(f'Query embedding must have the selected dimension {scope.embedding_dimension}')
|
||||||
|
|
||||||
typed_embedding = sqlalchemy.cast(
|
typed_embedding = sqlalchemy.cast(PgVectorEntry.embedding, Vector(scope.embedding_dimension))
|
||||||
PgVectorEntry.embedding,
|
|
||||||
HALFVEC(scope.embedding_dimension)
|
|
||||||
if scope.embedding_dimension > 2000
|
|
||||||
else Vector(scope.embedding_dimension),
|
|
||||||
)
|
|
||||||
distance = typed_embedding.cosine_distance(query_embedding)
|
distance = typed_embedding.cosine_distance(query_embedding)
|
||||||
statement = (
|
statement = (
|
||||||
sqlalchemy.select(
|
sqlalchemy.select(
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ from ..entity.persistence.user import AccountStatus, User
|
|||||||
from ..entity.persistence.workspace import (
|
from ..entity.persistence.workspace import (
|
||||||
InvitationStatus,
|
InvitationStatus,
|
||||||
MembershipRole,
|
MembershipRole,
|
||||||
MembershipSource,
|
|
||||||
MembershipStatus,
|
MembershipStatus,
|
||||||
Workspace,
|
Workspace,
|
||||||
WorkspaceInvitation,
|
WorkspaceInvitation,
|
||||||
@@ -89,7 +88,6 @@ class ResolvedWorkspaceAccess:
|
|||||||
@dataclasses.dataclass(frozen=True, slots=True)
|
@dataclasses.dataclass(frozen=True, slots=True)
|
||||||
class WorkspaceMemberView:
|
class WorkspaceMemberView:
|
||||||
membership: WorkspaceMembership
|
membership: WorkspaceMembership
|
||||||
display_name: str
|
|
||||||
email: str
|
email: str
|
||||||
|
|
||||||
|
|
||||||
@@ -296,7 +294,7 @@ class WorkspaceCollaborationService:
|
|||||||
async def operation(active_session: AsyncSession) -> list[WorkspaceMemberView]:
|
async def operation(active_session: AsyncSession) -> list[WorkspaceMemberView]:
|
||||||
await self._load_actor(active_session, workspace_uuid, actor)
|
await self._load_actor(active_session, workspace_uuid, actor)
|
||||||
statement = (
|
statement = (
|
||||||
sqlalchemy.select(WorkspaceMembership, User.user, User.normalized_email)
|
sqlalchemy.select(WorkspaceMembership, User.user)
|
||||||
.join(User, User.uuid == WorkspaceMembership.account_uuid)
|
.join(User, User.uuid == WorkspaceMembership.account_uuid)
|
||||||
.where(
|
.where(
|
||||||
WorkspaceMembership.workspace_uuid == workspace_uuid,
|
WorkspaceMembership.workspace_uuid == workspace_uuid,
|
||||||
@@ -306,12 +304,8 @@ class WorkspaceCollaborationService:
|
|||||||
.order_by(WorkspaceMembership.created_at, WorkspaceMembership.uuid)
|
.order_by(WorkspaceMembership.created_at, WorkspaceMembership.uuid)
|
||||||
)
|
)
|
||||||
return [
|
return [
|
||||||
WorkspaceMemberView(
|
WorkspaceMemberView(membership=membership, email=email)
|
||||||
membership=membership,
|
for membership, email in (await active_session.execute(statement)).all()
|
||||||
display_name=display_name,
|
|
||||||
email=email,
|
|
||||||
)
|
|
||||||
for membership, display_name, email in (await active_session.execute(statement)).all()
|
|
||||||
]
|
]
|
||||||
|
|
||||||
return await self._run(operation, session=session, read_only=True)
|
return await self._run(operation, session=session, read_only=True)
|
||||||
@@ -484,7 +478,6 @@ class WorkspaceCollaborationService:
|
|||||||
account_uuid=account_uuid,
|
account_uuid=account_uuid,
|
||||||
role=invitation.role,
|
role=invitation.role,
|
||||||
status=MembershipStatus.ACTIVE.value,
|
status=MembershipStatus.ACTIVE.value,
|
||||||
source=MembershipSource.LOCAL.value,
|
|
||||||
invited_by_account_uuid=invitation.created_by_account_uuid,
|
invited_by_account_uuid=invitation.created_by_account_uuid,
|
||||||
joined_at=now,
|
joined_at=now,
|
||||||
projection_revision=0,
|
projection_revision=0,
|
||||||
@@ -493,7 +486,6 @@ class WorkspaceCollaborationService:
|
|||||||
elif membership.status != MembershipStatus.ACTIVE.value:
|
elif membership.status != MembershipStatus.ACTIVE.value:
|
||||||
membership.role = invitation.role
|
membership.role = invitation.role
|
||||||
membership.status = MembershipStatus.ACTIVE.value
|
membership.status = MembershipStatus.ACTIVE.value
|
||||||
membership.source = MembershipSource.LOCAL.value
|
|
||||||
membership.invited_by_account_uuid = invitation.created_by_account_uuid
|
membership.invited_by_account_uuid = invitation.created_by_account_uuid
|
||||||
membership.joined_at = now
|
membership.joined_at = now
|
||||||
|
|
||||||
@@ -507,14 +499,14 @@ class WorkspaceCollaborationService:
|
|||||||
return await self._run(operation, session=session)
|
return await self._run(operation, session=session)
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def _invitation_lock(self, lock_key: str):
|
async def _invitation_lock(self, token_digest: str):
|
||||||
"""Serialize one token within workspace scope while retaining only active lock entries."""
|
"""Serialize one token while retaining only active lock entries."""
|
||||||
|
|
||||||
async with self._invitation_locks_guard:
|
async with self._invitation_locks_guard:
|
||||||
entry = self._invitation_locks.get(lock_key)
|
entry = self._invitation_locks.get(token_digest)
|
||||||
if entry is None:
|
if entry is None:
|
||||||
entry = _InvitationLockEntry(lock=asyncio.Lock())
|
entry = _InvitationLockEntry(lock=asyncio.Lock())
|
||||||
self._invitation_locks[lock_key] = entry
|
self._invitation_locks[token_digest] = entry
|
||||||
entry.users += 1
|
entry.users += 1
|
||||||
|
|
||||||
await entry.lock.acquire()
|
await entry.lock.acquire()
|
||||||
@@ -525,7 +517,7 @@ class WorkspaceCollaborationService:
|
|||||||
async with self._invitation_locks_guard:
|
async with self._invitation_locks_guard:
|
||||||
entry.users -= 1
|
entry.users -= 1
|
||||||
if entry.users == 0:
|
if entry.users == 0:
|
||||||
self._invitation_locks.pop(lock_key, None)
|
self._invitation_locks.pop(token_digest, None)
|
||||||
|
|
||||||
async def revoke_invitation(
|
async def revoke_invitation(
|
||||||
self,
|
self,
|
||||||
@@ -614,8 +606,6 @@ class WorkspaceCollaborationService:
|
|||||||
) -> WorkspaceMembership:
|
) -> WorkspaceMembership:
|
||||||
if role not in {item.value for item in MembershipRole}:
|
if role not in {item.value for item in MembershipRole}:
|
||||||
raise MembershipPermissionError('Unknown Workspace role')
|
raise MembershipPermissionError('Unknown Workspace role')
|
||||||
if role == MembershipRole.OWNER.value:
|
|
||||||
raise MembershipPermissionError('Workspace ownership cannot be transferred')
|
|
||||||
|
|
||||||
async def operation(active_session: AsyncSession) -> WorkspaceMembership:
|
async def operation(active_session: AsyncSession) -> WorkspaceMembership:
|
||||||
await self._require_active_workspace(active_session, workspace_uuid)
|
await self._require_active_workspace(active_session, workspace_uuid)
|
||||||
@@ -627,8 +617,8 @@ class WorkspaceCollaborationService:
|
|||||||
target_account_uuid,
|
target_account_uuid,
|
||||||
)
|
)
|
||||||
self._require_can_manage_target(persisted_actor, target, new_role=role)
|
self._require_can_manage_target(persisted_actor, target, new_role=role)
|
||||||
if target.role == MembershipRole.OWNER.value:
|
if target.role == MembershipRole.OWNER.value and role != MembershipRole.OWNER.value:
|
||||||
raise LastOwnerError('The Workspace owner cannot be removed or demoted')
|
await self._require_another_owner(active_session, workspace_uuid, target.account_uuid)
|
||||||
target.role = role
|
target.role = role
|
||||||
await active_session.flush()
|
await active_session.flush()
|
||||||
return target
|
return target
|
||||||
@@ -654,7 +644,7 @@ class WorkspaceCollaborationService:
|
|||||||
)
|
)
|
||||||
self._require_can_manage_target(persisted_actor, target)
|
self._require_can_manage_target(persisted_actor, target)
|
||||||
if target.role == MembershipRole.OWNER.value:
|
if target.role == MembershipRole.OWNER.value:
|
||||||
raise LastOwnerError('The Workspace owner cannot be removed or demoted')
|
await self._require_another_owner(active_session, workspace_uuid, target.account_uuid)
|
||||||
target.status = MembershipStatus.REMOVED.value
|
target.status = MembershipStatus.REMOVED.value
|
||||||
await active_session.flush()
|
await active_session.flush()
|
||||||
return target
|
return target
|
||||||
@@ -761,6 +751,26 @@ class WorkspaceCollaborationService:
|
|||||||
raise WorkspaceNotFoundError('Workspace not found')
|
raise WorkspaceNotFoundError('Workspace not found')
|
||||||
return persisted_actor
|
return persisted_actor
|
||||||
|
|
||||||
|
async def _require_another_owner(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
workspace_uuid: str,
|
||||||
|
excluded_account_uuid: str,
|
||||||
|
) -> None:
|
||||||
|
owners = (
|
||||||
|
await session.scalars(
|
||||||
|
sqlalchemy.select(WorkspaceMembership)
|
||||||
|
.where(
|
||||||
|
WorkspaceMembership.workspace_uuid == workspace_uuid,
|
||||||
|
WorkspaceMembership.status == MembershipStatus.ACTIVE.value,
|
||||||
|
WorkspaceMembership.role == MembershipRole.OWNER.value,
|
||||||
|
)
|
||||||
|
.with_for_update()
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
if not any(owner.account_uuid != excluded_account_uuid for owner in owners):
|
||||||
|
raise LastOwnerError('The last Workspace owner cannot be removed or demoted')
|
||||||
|
|
||||||
def _require_actor_workspace(self, actor: WorkspaceMembership, workspace_uuid: str) -> None:
|
def _require_actor_workspace(self, actor: WorkspaceMembership, workspace_uuid: str) -> None:
|
||||||
if actor.workspace_uuid != workspace_uuid or actor.status != MembershipStatus.ACTIVE.value:
|
if actor.workspace_uuid != workspace_uuid or actor.status != MembershipStatus.ACTIVE.value:
|
||||||
raise WorkspaceNotFoundError('Workspace not found')
|
raise WorkspaceNotFoundError('Workspace not found')
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
@@ -13,4 +12,3 @@ class WorkspaceExecutionBinding:
|
|||||||
placement_generation: int
|
placement_generation: int
|
||||||
write_fenced: bool
|
write_fenced: bool
|
||||||
state: str
|
state: str
|
||||||
workspace_created_at: datetime.datetime | None = None
|
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
|
|
||||||
_INSTANCE_PREFIX = 'instance_'
|
|
||||||
_WORKSPACE_IDENTITY_NAMESPACE = uuid.UUID('8ea04f29-8528-4cc3-bb28-30a838c89d76')
|
|
||||||
|
|
||||||
|
|
||||||
def workspace_uuid_from_instance_id(instance_id: str) -> str:
|
|
||||||
"""Return the stable OSS Workspace UUID for a persisted instance identity."""
|
|
||||||
value = instance_id.strip()
|
|
||||||
if not value:
|
|
||||||
raise ValueError('LangBot instance identity is empty')
|
|
||||||
|
|
||||||
candidate = value[len(_INSTANCE_PREFIX) :] if value.startswith(_INSTANCE_PREFIX) else value
|
|
||||||
try:
|
|
||||||
return str(uuid.UUID(candidate))
|
|
||||||
except ValueError:
|
|
||||||
return str(uuid.uuid5(_WORKSPACE_IDENTITY_NAMESPACE, value))
|
|
||||||
@@ -11,7 +11,6 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|||||||
|
|
||||||
from ..entity.persistence.workspace import (
|
from ..entity.persistence.workspace import (
|
||||||
MembershipRole,
|
MembershipRole,
|
||||||
MembershipSource,
|
|
||||||
MembershipStatus,
|
MembershipStatus,
|
||||||
Workspace,
|
Workspace,
|
||||||
WorkspaceExecutionSource,
|
WorkspaceExecutionSource,
|
||||||
@@ -31,7 +30,6 @@ from .errors import (
|
|||||||
WorkspaceOwnerAlreadyExistsError,
|
WorkspaceOwnerAlreadyExistsError,
|
||||||
)
|
)
|
||||||
from .entities import WorkspaceExecutionBinding
|
from .entities import WorkspaceExecutionBinding
|
||||||
from .identity import workspace_uuid_from_instance_id
|
|
||||||
from .policy import CloudWorkspacePolicy, SingleWorkspacePolicy
|
from .policy import CloudWorkspacePolicy, SingleWorkspacePolicy
|
||||||
from .repository import WorkspaceRepository
|
from .repository import WorkspaceRepository
|
||||||
|
|
||||||
@@ -284,7 +282,6 @@ class WorkspaceService:
|
|||||||
placement_generation=execution_state.active_generation,
|
placement_generation=execution_state.active_generation,
|
||||||
write_fenced=execution_state.write_fenced,
|
write_fenced=execution_state.write_fenced,
|
||||||
state=execution_state.state,
|
state=execution_state.state,
|
||||||
workspace_created_at=workspace.created_at,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
binding = await self._run(operation, session=session)
|
binding = await self._run(operation, session=session)
|
||||||
@@ -452,7 +449,6 @@ class WorkspaceService:
|
|||||||
account_uuid=account_uuid,
|
account_uuid=account_uuid,
|
||||||
role=MembershipRole.OWNER.value,
|
role=MembershipRole.OWNER.value,
|
||||||
status=MembershipStatus.ACTIVE.value,
|
status=MembershipStatus.ACTIVE.value,
|
||||||
source=MembershipSource.LOCAL.value,
|
|
||||||
joined_at=joined_at,
|
joined_at=joined_at,
|
||||||
projection_revision=0,
|
projection_revision=0,
|
||||||
)
|
)
|
||||||
@@ -460,7 +456,6 @@ class WorkspaceService:
|
|||||||
else:
|
else:
|
||||||
membership.role = MembershipRole.OWNER.value
|
membership.role = MembershipRole.OWNER.value
|
||||||
membership.status = MembershipStatus.ACTIVE.value
|
membership.status = MembershipStatus.ACTIVE.value
|
||||||
membership.source = MembershipSource.LOCAL.value
|
|
||||||
membership.joined_at = membership.joined_at or joined_at
|
membership.joined_at = membership.joined_at or joined_at
|
||||||
|
|
||||||
if workspace.created_by_account_uuid is None:
|
if workspace.created_by_account_uuid is None:
|
||||||
@@ -502,7 +497,7 @@ class WorkspaceService:
|
|||||||
created_by_account_uuid: str | None = None,
|
created_by_account_uuid: str | None = None,
|
||||||
) -> Workspace:
|
) -> Workspace:
|
||||||
return Workspace(
|
return Workspace(
|
||||||
uuid=workspace_uuid_from_instance_id(self.instance_uuid),
|
uuid=str(uuid.uuid4()),
|
||||||
instance_uuid=self.instance_uuid,
|
instance_uuid=self.instance_uuid,
|
||||||
name=name,
|
name=name,
|
||||||
slug=slug,
|
slug=slug,
|
||||||
|
|||||||
@@ -105,7 +105,6 @@ system:
|
|||||||
max_bots: -1
|
max_bots: -1
|
||||||
max_pipelines: -1
|
max_pipelines: -1
|
||||||
max_extensions: -1
|
max_extensions: -1
|
||||||
max_knowledge_bases: -1
|
|
||||||
# When set to a non-empty string, every pipeline is forced to use this
|
# When set to a non-empty string, every pipeline is forced to use this
|
||||||
# Box sandbox-scope template regardless of its own configuration, and
|
# Box sandbox-scope template regardless of its own configuration, and
|
||||||
# the per-pipeline "Sandbox Scope" selector is locked in the web UI.
|
# the per-pipeline "Sandbox Scope" selector is locked in the web UI.
|
||||||
@@ -201,7 +200,7 @@ vdb:
|
|||||||
# keep this false when deliberately using an external pgvector DB.
|
# keep this false when deliberately using an external pgvector DB.
|
||||||
use_business_database: false
|
use_business_database: false
|
||||||
# Release migrations create one partial ANN index per enabled value.
|
# Release migrations create one partial ANN index per enabled value.
|
||||||
allowed_dimensions: [384, 512, 768, 1024, 1536, 3072]
|
allowed_dimensions: [384, 512, 768, 1024, 1536]
|
||||||
host: '127.0.0.1'
|
host: '127.0.0.1'
|
||||||
port: 5433
|
port: 5433
|
||||||
database: 'langbot'
|
database: 'langbot'
|
||||||
@@ -245,8 +244,6 @@ storage:
|
|||||||
max_concurrency: 16
|
max_concurrency: 16
|
||||||
plugin:
|
plugin:
|
||||||
enable: true
|
enable: true
|
||||||
# Maximum time for the Runtime transport, handshake, and desired-state replay.
|
|
||||||
connect_timeout_seconds: 180.0
|
|
||||||
runtime_ws_url: 'ws://langbot_plugin_runtime:5400/control/ws'
|
runtime_ws_url: 'ws://langbot_plugin_runtime:5400/control/ws'
|
||||||
enable_marketplace: true
|
enable_marketplace: true
|
||||||
display_plugin_debug_url: 'ws://localhost:5401/plugin/debug/ws'
|
display_plugin_debug_url: 'ws://localhost:5401/plugin/debug/ws'
|
||||||
|
|||||||
@@ -106,12 +106,7 @@ async def plugin_security_api(plugin_module):
|
|||||||
application.plugin_connector.require_workspace_context = AsyncMock()
|
application.plugin_connector.require_workspace_context = AsyncMock()
|
||||||
application.plugin_connector.list_plugins = AsyncMock(return_value=[raw_plugin])
|
application.plugin_connector.list_plugins = AsyncMock(return_value=[raw_plugin])
|
||||||
application.plugin_connector.get_plugin_info = AsyncMock(return_value=raw_plugin)
|
application.plugin_connector.get_plugin_info = AsyncMock(return_value=raw_plugin)
|
||||||
application.plugin_connector.get_debug_info = AsyncMock(
|
application.plugin_connector.get_debug_info = AsyncMock(return_value={'plugin_debug_key': 'runtime-debug-secret'})
|
||||||
return_value={
|
|
||||||
'plugin_debug_key': 'runtime-debug-secret',
|
|
||||||
'expires_at': '2026-08-04T12:00:00Z',
|
|
||||||
}
|
|
||||||
)
|
|
||||||
application.plugin_connector.get_plugin_logs = AsyncMock(return_value=['private runtime line'])
|
application.plugin_connector.get_plugin_logs = AsyncMock(return_value=['private runtime line'])
|
||||||
application.plugin_connector.set_plugin_config = AsyncMock()
|
application.plugin_connector.set_plugin_config = AsyncMock()
|
||||||
|
|
||||||
@@ -237,9 +232,8 @@ async def test_debug_key_requires_resource_manage_permission(plugin_security_api
|
|||||||
assert (await allowed.get_json())['data'] == {
|
assert (await allowed.get_json())['data'] == {
|
||||||
'debug_url': 'http://localhost:5401',
|
'debug_url': 'http://localhost:5401',
|
||||||
'plugin_debug_key': 'runtime-debug-secret',
|
'plugin_debug_key': 'runtime-debug-secret',
|
||||||
'expires_at': '2026-08-04T12:00:00Z',
|
|
||||||
}
|
}
|
||||||
application.plugin_connector.get_debug_info.assert_awaited_once()
|
application.plugin_connector.get_debug_info.assert_awaited_once_with()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -9,8 +9,6 @@ Run: uv run pytest tests/integration/api/test_smoke.py -q
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import MagicMock, AsyncMock, Mock
|
from unittest.mock import MagicMock, AsyncMock, Mock
|
||||||
|
|
||||||
@@ -306,34 +304,12 @@ class TestUserInitEndpoint:
|
|||||||
data = await response.get_json()
|
data = await response.get_json()
|
||||||
assert data['data'] == {
|
assert data['data'] == {
|
||||||
'initialized': True,
|
'initialized': True,
|
||||||
'authenticated_invitation_acceptance_enabled': False,
|
|
||||||
'password_login_enabled': True,
|
'password_login_enabled': True,
|
||||||
'space_login_enabled': False,
|
'space_login_enabled': False,
|
||||||
}
|
}
|
||||||
fake_api_app.user_service.get_login_capabilities.assert_awaited_once_with()
|
fake_api_app.user_service.get_login_capabilities.assert_awaited_once_with()
|
||||||
fake_api_app.user_service.get_first_user.assert_not_awaited()
|
fake_api_app.user_service.get_first_user.assert_not_awaited()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_account_info_enables_authenticated_invitation_acceptance_in_cloud(
|
|
||||||
self, quart_test_client, fake_api_app
|
|
||||||
):
|
|
||||||
fake_api_app.deployment = SimpleNamespace(mode='cloud')
|
|
||||||
fake_api_app.user_service.is_initialized.return_value = True
|
|
||||||
fake_api_app.user_service.get_login_capabilities = AsyncMock(
|
|
||||||
return_value={'password_login_enabled': True, 'space_login_enabled': True}
|
|
||||||
)
|
|
||||||
|
|
||||||
response = await quart_test_client.get('/api/v1/user/account-info')
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = await response.get_json()
|
|
||||||
assert data['data'] == {
|
|
||||||
'initialized': True,
|
|
||||||
'authenticated_invitation_acceptance_enabled': True,
|
|
||||||
'password_login_enabled': False,
|
|
||||||
'space_login_enabled': True,
|
|
||||||
}
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_recovery_key_resets_any_existing_account(self, quart_test_client, fake_api_app, monkeypatch):
|
async def test_recovery_key_resets_any_existing_account(self, quart_test_client, fake_api_app, monkeypatch):
|
||||||
fake_api_app.user_service.is_initialized.return_value = True
|
fake_api_app.user_service.is_initialized.return_value = True
|
||||||
|
|||||||
@@ -1,452 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import datetime
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
import uuid
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from unittest.mock import AsyncMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import sqlalchemy
|
|
||||||
from cryptography.hazmat.primitives import serialization
|
|
||||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
||||||
from quart import Quart
|
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
||||||
|
|
||||||
from langbot.pkg.api.http.authz import Permission
|
|
||||||
from langbot.pkg.api.http.context import PrincipalType, RequestContext
|
|
||||||
from langbot.pkg.api.http.controller import group
|
|
||||||
from langbot.pkg.api.http.controller.groups.pipelines.websocket_chat import WebSocketChatRouterGroup
|
|
||||||
from langbot.pkg.api.http.controller.groups.user import UserRouterGroup
|
|
||||||
from langbot.pkg.cloud.launch import SpaceLaunchError, SpaceLaunchService
|
|
||||||
from langbot.pkg.cloud.support_admin import SupportAdminSessionService
|
|
||||||
from langbot.pkg.entity.persistence.base import Base
|
|
||||||
from langbot.pkg.entity.persistence.support_admin import SupportAdminTemporarySession
|
|
||||||
from langbot.pkg.entity.persistence.user import User
|
|
||||||
from langbot.pkg.entity.persistence.workspace import (
|
|
||||||
Workspace,
|
|
||||||
WorkspaceExecutionState,
|
|
||||||
WorkspaceMembership,
|
|
||||||
)
|
|
||||||
from langbot.pkg.workspace.service import WorkspaceService
|
|
||||||
|
|
||||||
|
|
||||||
pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
|
|
||||||
|
|
||||||
|
|
||||||
INSTANCE_UUID = 'instance-support-admin'
|
|
||||||
WORKSPACE_UUID = '10000000-0000-4000-8000-000000000001'
|
|
||||||
OTHER_WORKSPACE_UUID = '10000000-0000-4000-8000-000000000002'
|
|
||||||
ACTOR_ACCOUNT_UUID = '20000000-0000-4000-8000-000000000001'
|
|
||||||
KEY_ID = 'support-admin-key-1'
|
|
||||||
|
|
||||||
|
|
||||||
def _base64url(raw: bytes) -> str:
|
|
||||||
return base64.urlsafe_b64encode(raw).rstrip(b'=').decode('ascii')
|
|
||||||
|
|
||||||
|
|
||||||
def _sign(private_key: Ed25519PrivateKey, claims: dict, *, key_id: str = KEY_ID) -> str:
|
|
||||||
header = {'alg': 'EdDSA', 'kid': key_id, 'typ': 'langbot-control-plane+jwt'}
|
|
||||||
encoded_header = _base64url(json.dumps(header, separators=(',', ':')).encode('utf-8'))
|
|
||||||
encoded_claims = _base64url(json.dumps(claims, separators=(',', ':')).encode('utf-8'))
|
|
||||||
signing_input = f'{encoded_header}.{encoded_claims}'
|
|
||||||
return f'{signing_input}.{_base64url(private_key.sign(signing_input.encode("ascii")))}'
|
|
||||||
|
|
||||||
|
|
||||||
def _admin_claims(*, now: int, jti: str | None = None, workspace_uuid: str = WORKSPACE_UUID) -> dict:
|
|
||||||
return {
|
|
||||||
'iss': 'langbot-space',
|
|
||||||
'aud': 'langbot-cloud-runtime',
|
|
||||||
'sub': f'langbot-instance:{INSTANCE_UUID}',
|
|
||||||
'jti': jti or str(uuid.uuid4()),
|
|
||||||
'iat': now,
|
|
||||||
'nbf': now - 5,
|
|
||||||
'exp': now + 90,
|
|
||||||
'instance_uuid': INSTANCE_UUID,
|
|
||||||
'kind': 'workspace.support_admin_launch',
|
|
||||||
'payload': {
|
|
||||||
'workspace_uuid': workspace_uuid,
|
|
||||||
'launch_mode': 'support_admin',
|
|
||||||
'principal_type': 'support_admin',
|
|
||||||
'actor_account_uuid': ACTOR_ACCOUNT_UUID,
|
|
||||||
'effective_role': 'owner',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@group.group_class('support_admin_probe', '/api/v1/support-admin-probe')
|
|
||||||
class SupportAdminProbeGroup(group.RouterGroup):
|
|
||||||
async def initialize(self) -> None:
|
|
||||||
@self.route('/user-token', auth_type=group.AuthType.USER_TOKEN, permission=Permission.WORKSPACE_VIEW)
|
|
||||||
async def _(request_context: RequestContext) -> str:
|
|
||||||
return self.success(data=_context_payload(request_context))
|
|
||||||
|
|
||||||
@self.route(
|
|
||||||
'/member-operation',
|
|
||||||
auth_type=group.AuthType.USER_TOKEN,
|
|
||||||
permission=Permission.MEMBER_VIEW,
|
|
||||||
)
|
|
||||||
async def member_operation(request_context: RequestContext) -> str:
|
|
||||||
return self.success(data=_context_payload(request_context))
|
|
||||||
|
|
||||||
@self.route(
|
|
||||||
'/user-token-or-api-key',
|
|
||||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
|
||||||
permission=Permission.WORKSPACE_VIEW,
|
|
||||||
)
|
|
||||||
async def _(request_context: RequestContext) -> str:
|
|
||||||
return self.success(data=_context_payload(request_context))
|
|
||||||
|
|
||||||
|
|
||||||
def _context_payload(request_context: RequestContext) -> dict:
|
|
||||||
return {
|
|
||||||
'principal_type': request_context.principal.principal_type.value,
|
|
||||||
'actor_account_uuid': request_context.principal.actor_account_uuid,
|
|
||||||
'account_uuid': request_context.principal.account_uuid,
|
|
||||||
'role': request_context.workspace.role,
|
|
||||||
'membership_uuid': request_context.workspace.membership_uuid,
|
|
||||||
'permissions': sorted(request_context.workspace.permissions),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class _TenantUow:
|
|
||||||
def __init__(self, engine):
|
|
||||||
self._engine = engine
|
|
||||||
self.session = None
|
|
||||||
self._transaction = None
|
|
||||||
|
|
||||||
async def __aenter__(self):
|
|
||||||
session_factory = async_sessionmaker(self._engine, expire_on_commit=False)
|
|
||||||
self.session = session_factory()
|
|
||||||
self._transaction = await self.session.begin()
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, exc_type, exc, traceback):
|
|
||||||
try:
|
|
||||||
if exc_type is None:
|
|
||||||
await self._transaction.commit()
|
|
||||||
else:
|
|
||||||
await self._transaction.rollback()
|
|
||||||
finally:
|
|
||||||
await self.session.close()
|
|
||||||
|
|
||||||
|
|
||||||
class _TenantScope:
|
|
||||||
async def __aenter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, exc_type, exc, traceback):
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
class _PersistenceManager:
|
|
||||||
def __init__(self, engine):
|
|
||||||
self._engine = engine
|
|
||||||
self.mode = SimpleNamespace(value='oss_compat')
|
|
||||||
|
|
||||||
def get_db_engine(self):
|
|
||||||
return self._engine
|
|
||||||
|
|
||||||
def tenant_uow(self, workspace_uuid: str):
|
|
||||||
del workspace_uuid
|
|
||||||
return _TenantUow(self._engine)
|
|
||||||
|
|
||||||
def tenant_scope(self, workspace_uuid: str):
|
|
||||||
del workspace_uuid
|
|
||||||
return _TenantScope()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
async def support_admin_api(tmp_path):
|
|
||||||
private_key = Ed25519PrivateKey.generate()
|
|
||||||
public_key = private_key.public_key().public_bytes(
|
|
||||||
encoding=serialization.Encoding.Raw,
|
|
||||||
format=serialization.PublicFormat.Raw,
|
|
||||||
)
|
|
||||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "support-admin.db"}')
|
|
||||||
async with engine.begin() as connection:
|
|
||||||
await connection.run_sync(
|
|
||||||
Base.metadata.create_all,
|
|
||||||
tables=[
|
|
||||||
User.__table__,
|
|
||||||
Workspace.__table__,
|
|
||||||
WorkspaceExecutionState.__table__,
|
|
||||||
WorkspaceMembership.__table__,
|
|
||||||
SupportAdminTemporarySession.__table__,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
for workspace_uuid, slug in (
|
|
||||||
(WORKSPACE_UUID, 'support-admin-a'),
|
|
||||||
(OTHER_WORKSPACE_UUID, 'support-admin-b'),
|
|
||||||
):
|
|
||||||
await connection.execute(
|
|
||||||
sqlalchemy.insert(Workspace).values(
|
|
||||||
uuid=workspace_uuid,
|
|
||||||
instance_uuid=INSTANCE_UUID,
|
|
||||||
name=slug,
|
|
||||||
slug=slug,
|
|
||||||
type='team',
|
|
||||||
status='active',
|
|
||||||
source='cloud_projection',
|
|
||||||
projection_revision=1,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await connection.execute(
|
|
||||||
sqlalchemy.insert(WorkspaceExecutionState).values(
|
|
||||||
workspace_uuid=workspace_uuid,
|
|
||||||
instance_uuid=INSTANCE_UUID,
|
|
||||||
active_generation=1,
|
|
||||||
state='active',
|
|
||||||
write_fenced=False,
|
|
||||||
source='cloud',
|
|
||||||
desired_state_revision=1,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
app = SimpleNamespace()
|
|
||||||
app.persistence_mgr = _PersistenceManager(engine)
|
|
||||||
app.instance_config = SimpleNamespace(
|
|
||||||
data={
|
|
||||||
'system': {
|
|
||||||
'jwt': {'secret': 'support-admin-secret', 'expire': 3600},
|
|
||||||
'websocket_retention': {},
|
|
||||||
},
|
|
||||||
'space': {
|
|
||||||
'launch': {
|
|
||||||
'control_plane_public_key': _base64url(public_key),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'api': {'global_api_key': ''},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
app.logger = logging.getLogger('support-admin-test')
|
|
||||||
app.deployment = SimpleNamespace(mode='cloud', multi_workspace_enabled=True, verification_key_id=KEY_ID)
|
|
||||||
app.directory_projection_service = SimpleNamespace(require_ready=lambda: None)
|
|
||||||
app.workspace_service = WorkspaceService(app, instance_uuid=INSTANCE_UUID)
|
|
||||||
app.entitlement_resolver = SimpleNamespace(
|
|
||||||
instance_uuid=INSTANCE_UUID,
|
|
||||||
resolve=AsyncMock(return_value=SimpleNamespace(entitlement_revision=7)),
|
|
||||||
)
|
|
||||||
app.support_admin_session_service = SupportAdminSessionService(app)
|
|
||||||
app.space_launch_service = SpaceLaunchService(app)
|
|
||||||
app.user_service = SimpleNamespace()
|
|
||||||
app.user_service.get_authenticated_account = AsyncMock(side_effect=AssertionError('normal account auth used'))
|
|
||||||
app.user_service.verify_jwt_token = AsyncMock(side_effect=AssertionError('normal token verification used'))
|
|
||||||
app.user_service.get_user_by_email = AsyncMock(side_effect=AssertionError('user lookup used'))
|
|
||||||
app.apikey_service = SimpleNamespace()
|
|
||||||
app.apikey_service.authenticate_api_key = AsyncMock(
|
|
||||||
return_value=SimpleNamespace(
|
|
||||||
instance_uuid=INSTANCE_UUID,
|
|
||||||
workspace_uuid=OTHER_WORKSPACE_UUID,
|
|
||||||
placement_generation=1,
|
|
||||||
api_key_uuid='api-key',
|
|
||||||
permissions=frozenset(permission.value for permission in Permission),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
quart_app = Quart(__name__)
|
|
||||||
await UserRouterGroup(app, quart_app).initialize()
|
|
||||||
await SupportAdminProbeGroup(app, quart_app).initialize()
|
|
||||||
|
|
||||||
yield app, quart_app.test_client(), engine, private_key
|
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
async def _issue_support_token(app, private_key: Ed25519PrivateKey, *, jti: str | None = None) -> dict[str, str]:
|
|
||||||
launch = await app.space_launch_service.consume_assertion(
|
|
||||||
_sign(private_key, _admin_claims(now=int(time.time()), jti=jti)),
|
|
||||||
expected_workspace_uuid=WORKSPACE_UUID,
|
|
||||||
)
|
|
||||||
return launch
|
|
||||||
|
|
||||||
|
|
||||||
def _auth(token: str, workspace_uuid: str = WORKSPACE_UUID) -> dict[str, str]:
|
|
||||||
return {'Authorization': f'Bearer {token}', 'X-Workspace-Id': workspace_uuid}
|
|
||||||
|
|
||||||
|
|
||||||
async def test_support_admin_membership_only_routes_are_denied(support_admin_api):
|
|
||||||
app, client, _engine, private_key = support_admin_api
|
|
||||||
launch = await _issue_support_token(app, private_key)
|
|
||||||
|
|
||||||
response = await client.get(
|
|
||||||
'/api/v1/support-admin-probe/member-operation',
|
|
||||||
headers=_auth(launch['support_admin_token']),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
assert (await response.get_json())['code'] == 'permission_denied'
|
|
||||||
|
|
||||||
|
|
||||||
async def test_support_admin_check_token_is_rejected(support_admin_api):
|
|
||||||
app, client, _engine, private_key = support_admin_api
|
|
||||||
launch = await _issue_support_token(app, private_key)
|
|
||||||
|
|
||||||
response = await client.get('/api/v1/user/check-token', headers=_auth(launch['support_admin_token']))
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
assert (await response.get_json())['code'] == 'invalid_authentication'
|
|
||||||
|
|
||||||
|
|
||||||
async def test_support_admin_cross_workspace_denied_for_user_token_and_or_api_key(support_admin_api):
|
|
||||||
app, client, _engine, private_key = support_admin_api
|
|
||||||
launch = await _issue_support_token(app, private_key)
|
|
||||||
|
|
||||||
missing_selector = await client.get(
|
|
||||||
'/api/v1/support-admin-probe/user-token',
|
|
||||||
headers={'Authorization': f'Bearer {launch["support_admin_token"]}'},
|
|
||||||
)
|
|
||||||
user_response = await client.get(
|
|
||||||
'/api/v1/support-admin-probe/user-token',
|
|
||||||
headers=_auth(launch['support_admin_token'], OTHER_WORKSPACE_UUID),
|
|
||||||
)
|
|
||||||
either_response = await client.get(
|
|
||||||
'/api/v1/support-admin-probe/user-token-or-api-key',
|
|
||||||
headers={
|
|
||||||
**_auth(launch['support_admin_token'], OTHER_WORKSPACE_UUID),
|
|
||||||
'X-API-Key': 'valid-api-key',
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert missing_selector.status_code == 400
|
|
||||||
assert user_response.status_code == 401
|
|
||||||
assert either_response.status_code == 401
|
|
||||||
app.apikey_service.authenticate_api_key.assert_not_awaited()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_support_admin_request_context_has_actor_owner_and_no_membership(support_admin_api):
|
|
||||||
app, client, engine, private_key = support_admin_api
|
|
||||||
before_count = await _membership_count(engine)
|
|
||||||
launch = await _issue_support_token(app, private_key)
|
|
||||||
|
|
||||||
response = await client.get(
|
|
||||||
'/api/v1/support-admin-probe/user-token',
|
|
||||||
headers=_auth(launch['support_admin_token']),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = (await response.get_json())['data']
|
|
||||||
permissions = set(data.pop('permissions'))
|
|
||||||
assert Permission.WORKSPACE_VIEW.value in permissions
|
|
||||||
assert Permission.RESOURCE_MANAGE.value in permissions
|
|
||||||
assert not permissions.intersection(
|
|
||||||
{
|
|
||||||
Permission.MEMBER_VIEW.value,
|
|
||||||
Permission.MEMBER_INVITE.value,
|
|
||||||
Permission.MEMBER_UPDATE_ROLE.value,
|
|
||||||
Permission.MEMBER_REMOVE.value,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
assert data == {
|
|
||||||
'principal_type': PrincipalType.SUPPORT_ADMIN.value,
|
|
||||||
'actor_account_uuid': ACTOR_ACCOUNT_UUID,
|
|
||||||
'account_uuid': None,
|
|
||||||
'role': 'owner',
|
|
||||||
'membership_uuid': None,
|
|
||||||
}
|
|
||||||
assert await _membership_count(engine) == before_count
|
|
||||||
|
|
||||||
|
|
||||||
async def test_support_admin_missing_workspace_is_controlled_launch_failure(support_admin_api):
|
|
||||||
app, _client, engine, private_key = support_admin_api
|
|
||||||
async with engine.begin() as connection:
|
|
||||||
await connection.execute(
|
|
||||||
sqlalchemy.delete(WorkspaceExecutionState).where(WorkspaceExecutionState.workspace_uuid == WORKSPACE_UUID)
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(SpaceLaunchError, match='unavailable'):
|
|
||||||
await _issue_support_token(app, private_key)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_support_admin_launch_replay_is_durable_across_service_instances(support_admin_api):
|
|
||||||
app, _client, _engine, private_key = support_admin_api
|
|
||||||
jti = str(uuid.uuid4())
|
|
||||||
|
|
||||||
await _issue_support_token(app, private_key, jti=jti)
|
|
||||||
second_service = SpaceLaunchService(app)
|
|
||||||
|
|
||||||
with pytest.raises(SpaceLaunchError, match='already been consumed'):
|
|
||||||
await second_service.consume_assertion(
|
|
||||||
_sign(private_key, _admin_claims(now=int(time.time()), jti=jti)),
|
|
||||||
expected_workspace_uuid=WORKSPACE_UUID,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_support_admin_persisted_expiry_and_revocation_are_enforced(support_admin_api):
|
|
||||||
app, client, engine, private_key = support_admin_api
|
|
||||||
launch = await _issue_support_token(app, private_key)
|
|
||||||
token = launch['support_admin_token']
|
|
||||||
|
|
||||||
async with engine.begin() as connection:
|
|
||||||
await connection.execute(
|
|
||||||
sqlalchemy.update(SupportAdminTemporarySession)
|
|
||||||
.where(SupportAdminTemporarySession.grant_jti_hash == launch['grant_jti_hash'])
|
|
||||||
.values(expires_at=datetime.datetime.now(datetime.UTC).replace(tzinfo=None) - datetime.timedelta(minutes=1))
|
|
||||||
)
|
|
||||||
expired = await client.get('/api/v1/support-admin-probe/user-token', headers=_auth(token))
|
|
||||||
assert expired.status_code == 401
|
|
||||||
|
|
||||||
second = await _issue_support_token(app, private_key)
|
|
||||||
async with engine.begin() as connection:
|
|
||||||
await connection.execute(
|
|
||||||
sqlalchemy.update(SupportAdminTemporarySession)
|
|
||||||
.where(SupportAdminTemporarySession.grant_jti_hash == second['grant_jti_hash'])
|
|
||||||
.values(revoked_at=datetime.datetime.now(datetime.UTC).replace(tzinfo=None))
|
|
||||||
)
|
|
||||||
revoked = await client.get('/api/v1/support-admin-probe/user-token', headers=_auth(second['support_admin_token']))
|
|
||||||
assert revoked.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
async def test_support_admin_websocket_preserves_actor_and_revalidates(support_admin_api):
|
|
||||||
app, _client, _engine, private_key = support_admin_api
|
|
||||||
launch = await _issue_support_token(app, private_key)
|
|
||||||
captured_contexts = []
|
|
||||||
|
|
||||||
class Adapter:
|
|
||||||
async def handle_websocket_message(self, connection, data):
|
|
||||||
del data
|
|
||||||
captured_contexts.append(connection.execution_context)
|
|
||||||
await connection.send_queue.put({'type': 'handled'})
|
|
||||||
connection.is_active = False
|
|
||||||
|
|
||||||
app.pipeline_service = SimpleNamespace(get_pipeline=AsyncMock(return_value=SimpleNamespace(uuid='pipeline-1')))
|
|
||||||
app.platform_mgr = SimpleNamespace(
|
|
||||||
get_websocket_proxy_bot=AsyncMock(return_value=SimpleNamespace(adapter=Adapter()))
|
|
||||||
)
|
|
||||||
|
|
||||||
quart_app = Quart(__name__)
|
|
||||||
await WebSocketChatRouterGroup(app, quart_app).initialize()
|
|
||||||
|
|
||||||
async with quart_app.test_client().websocket('/api/v1/pipelines/pipeline-1/ws/connect') as websocket:
|
|
||||||
await websocket.send(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
'type': 'authenticate',
|
|
||||||
'token': launch['support_admin_token'],
|
|
||||||
'workspace_uuid': WORKSPACE_UUID,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
connected = json.loads(await websocket.receive())
|
|
||||||
assert connected['type'] == 'connected'
|
|
||||||
await websocket.send(json.dumps({'type': 'message', 'message': [{'type': 'text', 'text': 'hi'}]}))
|
|
||||||
handled = json.loads(await websocket.receive())
|
|
||||||
assert handled['type'] == 'handled'
|
|
||||||
|
|
||||||
assert captured_contexts
|
|
||||||
principal = captured_contexts[0].trigger_principal
|
|
||||||
assert principal is not None
|
|
||||||
assert principal.principal_type == PrincipalType.SUPPORT_ADMIN
|
|
||||||
assert principal.actor_account_uuid == ACTOR_ACCOUNT_UUID
|
|
||||||
|
|
||||||
|
|
||||||
async def _membership_count(engine) -> int:
|
|
||||||
async with engine.connect() as connection:
|
|
||||||
return int(
|
|
||||||
await connection.scalar(
|
|
||||||
sqlalchemy.select(sqlalchemy.func.count()).select_from(WorkspaceMembership),
|
|
||||||
)
|
|
||||||
or 0
|
|
||||||
)
|
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime
|
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, Mock
|
from unittest.mock import AsyncMock, Mock
|
||||||
from urllib.parse import parse_qs, urlsplit
|
from urllib.parse import parse_qs, urlsplit
|
||||||
@@ -15,7 +14,6 @@ from langbot.pkg.api.http.controller.groups.user import UserRouterGroup
|
|||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
pytestmark = pytest.mark.integration
|
||||||
WORKSPACE_UUID = '11111111-1111-4111-8111-111111111111'
|
WORKSPACE_UUID = '11111111-1111-4111-8111-111111111111'
|
||||||
WORKSPACE_CREATED_AT = datetime.datetime(2026, 1, 2, 3, 4, 5, tzinfo=datetime.UTC)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -60,12 +58,6 @@ async def space_oauth_api():
|
|||||||
return_value={'account_uuid': 'account-a', 'workspace_uuid': WORKSPACE_UUID}
|
return_value={'account_uuid': 'account-a', 'workspace_uuid': WORKSPACE_UUID}
|
||||||
)
|
)
|
||||||
application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(return_value=access)
|
application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(return_value=access)
|
||||||
application.workspace_service.get_execution_binding = AsyncMock(
|
|
||||||
return_value=SimpleNamespace(
|
|
||||||
workspace_uuid=WORKSPACE_UUID,
|
|
||||||
workspace_created_at=WORKSPACE_CREATED_AT,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
application.space_service.get_oauth_authorize_url = Mock(
|
application.space_service.get_oauth_authorize_url = Mock(
|
||||||
side_effect=lambda redirect_uri, state: f'https://space.example/authorize?state={state}'
|
side_effect=lambda redirect_uri, state: f'https://space.example/authorize?state={state}'
|
||||||
)
|
)
|
||||||
@@ -242,11 +234,7 @@ async def test_login_callback_requires_and_consumes_server_state(space_oauth_api
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert (await response.get_json())['data']['token'] == 'space-login-token'
|
assert (await response.get_json())['data']['token'] == 'space-login-token'
|
||||||
application.user_service.consume_space_oauth_state_details.assert_awaited_once_with('opaque-login-state', 'login')
|
application.user_service.consume_space_oauth_state_details.assert_awaited_once_with('opaque-login-state', 'login')
|
||||||
application.space_service.exchange_oauth_code.assert_awaited_once_with(
|
application.space_service.exchange_oauth_code.assert_awaited_once_with('oauth-code')
|
||||||
'oauth-code',
|
|
||||||
[WORKSPACE_UUID],
|
|
||||||
{WORKSPACE_UUID: int(WORKSPACE_CREATED_AT.timestamp())},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -282,12 +270,11 @@ async def test_space_credits_are_resolved_from_workspace_owner(space_oauth_api):
|
|||||||
|
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
'/api/v1/user/space-credits',
|
'/api/v1/user/space-credits',
|
||||||
headers={'Authorization': 'Bearer account-token', 'X-Workspace-Id': WORKSPACE_UUID},
|
headers={'Authorization': 'Bearer account-token', 'X-Workspace-UUID': WORKSPACE_UUID},
|
||||||
)
|
)
|
||||||
payload = await response.get_json()
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert payload['data'] == {
|
assert (await response.get_json())['data'] == {
|
||||||
'credits': 25000,
|
'credits': 25000,
|
||||||
'owner_space_bound': True,
|
'owner_space_bound': True,
|
||||||
'is_workspace_owner': True,
|
'is_workspace_owner': True,
|
||||||
@@ -295,31 +282,6 @@ async def test_space_credits_are_resolved_from_workspace_owner(space_oauth_api):
|
|||||||
application.space_service.get_credits.assert_awaited_once_with('owner@example.com')
|
application.space_service.get_credits.assert_awaited_once_with('owner@example.com')
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_cloud_workspace_owner_is_always_space_bound_after_login(space_oauth_api):
|
|
||||||
application, client = space_oauth_api
|
|
||||||
application.deployment.mode = 'cloud'
|
|
||||||
application.user_service.get_workspace_owner = AsyncMock(return_value=None)
|
|
||||||
application.space_service.get_credits = AsyncMock()
|
|
||||||
application.cloud_model_catalog_service = SimpleNamespace(
|
|
||||||
get_workspace_credits=lambda workspace_uuid: 25000 if workspace_uuid == WORKSPACE_UUID else None
|
|
||||||
)
|
|
||||||
|
|
||||||
response = await client.get(
|
|
||||||
'/api/v1/user/space-credits',
|
|
||||||
headers={'Authorization': 'Bearer account-token', 'X-Workspace-Id': WORKSPACE_UUID},
|
|
||||||
)
|
|
||||||
payload = await response.get_json()
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert payload['data'] == {
|
|
||||||
'credits': 25000,
|
|
||||||
'owner_space_bound': True,
|
|
||||||
'is_workspace_owner': True,
|
|
||||||
}
|
|
||||||
application.space_service.get_credits.assert_not_awaited()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_bind_callback_uses_opaque_state_and_never_treats_it_as_jwt(space_oauth_api):
|
async def test_bind_callback_uses_opaque_state_and_never_treats_it_as_jwt(space_oauth_api):
|
||||||
application, client = space_oauth_api
|
application, client = space_oauth_api
|
||||||
|
|||||||
@@ -188,7 +188,6 @@ async def test_owner_invites_second_account_and_secret_is_not_persisted(workspac
|
|||||||
workspace_uuid = current['workspace']['uuid']
|
workspace_uuid = current['workspace']['uuid']
|
||||||
assert current['membership']['role'] == 'owner'
|
assert current['membership']['role'] == 'owner'
|
||||||
assert 'member.invite' in current['permissions']
|
assert 'member.invite' in current['permissions']
|
||||||
assert 'owner.transfer' not in current['permissions']
|
|
||||||
|
|
||||||
invite_response = await client.post(
|
invite_response = await client.post(
|
||||||
f'/api/v1/workspaces/{workspace_uuid}/invitations',
|
f'/api/v1/workspaces/{workspace_uuid}/invitations',
|
||||||
@@ -264,14 +263,6 @@ async def test_owner_invites_second_account_and_secret_is_not_persisted(workspac
|
|||||||
assert member_current['membership']['role'] == 'viewer'
|
assert member_current['membership']['role'] == 'viewer'
|
||||||
assert 'member.invite' not in member_current['permissions']
|
assert 'member.invite' not in member_current['permissions']
|
||||||
|
|
||||||
transfer_response = await client.patch(
|
|
||||||
f'/api/v1/workspaces/{workspace_uuid}/members/{member_current["membership"]["account_uuid"]}',
|
|
||||||
headers=_auth(owner_token, workspace_uuid),
|
|
||||||
json={'role': 'owner'},
|
|
||||||
)
|
|
||||||
assert transfer_response.status_code == 403
|
|
||||||
assert (await transfer_response.get_json())['code'] == 'permission_denied'
|
|
||||||
|
|
||||||
forbidden_invite = await client.post(
|
forbidden_invite = await client.post(
|
||||||
f'/api/v1/workspaces/{workspace_uuid}/invitations',
|
f'/api/v1/workspaces/{workspace_uuid}/invitations',
|
||||||
headers=_auth(member_token, workspace_uuid),
|
headers=_auth(member_token, workspace_uuid),
|
||||||
@@ -281,31 +272,6 @@ async def test_owner_invites_second_account_and_secret_is_not_persisted(workspac
|
|||||||
assert (await forbidden_invite.get_json())['code'] == 'permission_denied'
|
assert (await forbidden_invite.get_json())['code'] == 'permission_denied'
|
||||||
|
|
||||||
|
|
||||||
async def test_workspace_member_list_returns_display_name_and_email(workspace_api):
|
|
||||||
_, client, engine, owner_token = workspace_api
|
|
||||||
|
|
||||||
current_response = await client.get('/api/v1/workspaces/current', headers=_auth(owner_token))
|
|
||||||
current = (await current_response.get_json())['data']
|
|
||||||
workspace_uuid = current['workspace']['uuid']
|
|
||||||
owner_uuid = current['membership']['account_uuid']
|
|
||||||
|
|
||||||
async with engine.begin() as connection:
|
|
||||||
await connection.execute(
|
|
||||||
sqlalchemy.update(User).where(User.uuid == owner_uuid).values(user='Owner Display Name')
|
|
||||||
)
|
|
||||||
|
|
||||||
response = await client.get(
|
|
||||||
f'/api/v1/workspaces/{workspace_uuid}/members',
|
|
||||||
headers=_auth(owner_token, workspace_uuid),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
members = (await response.get_json())['data']['members']
|
|
||||||
assert len(members) == 1
|
|
||||||
assert members[0]['display_name'] == 'Owner Display Name'
|
|
||||||
assert members[0]['email'] == 'owner@example.com'
|
|
||||||
|
|
||||||
|
|
||||||
async def test_oss_invitation_accept_requires_logout_before_registration(workspace_api):
|
async def test_oss_invitation_accept_requires_logout_before_registration(workspace_api):
|
||||||
_, client, _, owner_token = workspace_api
|
_, client, _, owner_token = workspace_api
|
||||||
|
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
|
||||||
|
|
||||||
from langbot.pkg.persistence.alembic_runner import run_alembic_stamp, run_alembic_upgrade
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_membership_source_migration_backfills_existing_rows_as_local_and_enforces_constraint(tmp_path):
|
|
||||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "membership-source.db"}')
|
|
||||||
try:
|
|
||||||
async with engine.begin() as connection:
|
|
||||||
await connection.execute(
|
|
||||||
sa.text(
|
|
||||||
"""
|
|
||||||
CREATE TABLE workspace_memberships (
|
|
||||||
uuid VARCHAR(36) PRIMARY KEY,
|
|
||||||
workspace_uuid VARCHAR(36) NOT NULL,
|
|
||||||
account_uuid VARCHAR(36) NOT NULL,
|
|
||||||
role VARCHAR(32) NOT NULL,
|
|
||||||
status VARCHAR(32) NOT NULL,
|
|
||||||
projection_revision BIGINT NOT NULL DEFAULT 0,
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await connection.execute(
|
|
||||||
sa.text(
|
|
||||||
"""
|
|
||||||
INSERT INTO workspace_memberships
|
|
||||||
(uuid, workspace_uuid, account_uuid, role, status, projection_revision)
|
|
||||||
VALUES
|
|
||||||
('00000000-0000-4000-8000-000000000001', 'workspace', 'local-account',
|
|
||||||
'viewer', 'active', 0),
|
|
||||||
('00000000-0000-4000-8000-000000000002', 'workspace', 'cloud-account',
|
|
||||||
'viewer', 'active', 0)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
await run_alembic_stamp(engine, '0019_single_workspace_owner')
|
|
||||||
await run_alembic_upgrade(engine, 'head')
|
|
||||||
|
|
||||||
async with engine.connect() as connection:
|
|
||||||
rows = (
|
|
||||||
await connection.execute(sa.text('SELECT uuid, source FROM workspace_memberships ORDER BY uuid'))
|
|
||||||
).all()
|
|
||||||
columns = await connection.run_sync(
|
|
||||||
lambda sync_connection: {
|
|
||||||
column['name']: column
|
|
||||||
for column in sa.inspect(sync_connection).get_columns('workspace_memberships')
|
|
||||||
}
|
|
||||||
)
|
|
||||||
assert rows == [
|
|
||||||
('00000000-0000-4000-8000-000000000001', 'local'),
|
|
||||||
('00000000-0000-4000-8000-000000000002', 'local'),
|
|
||||||
]
|
|
||||||
assert columns['source']['nullable'] is False
|
|
||||||
|
|
||||||
with pytest.raises(sa.exc.IntegrityError):
|
|
||||||
async with engine.begin() as connection:
|
|
||||||
await connection.execute(
|
|
||||||
sa.text("UPDATE workspace_memberships SET source = 'guessed-from-user-source'")
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
await engine.dispose()
|
|
||||||
@@ -95,18 +95,6 @@ class TestSQLiteMigrationBaseline:
|
|||||||
class TestSQLiteMigrationUpgrade:
|
class TestSQLiteMigrationUpgrade:
|
||||||
"""Tests for upgrade to head workflow."""
|
"""Tests for upgrade to head workflow."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_upgrade_from_published_space_launch_head_to_merged_head(self, sqlite_engine):
|
|
||||||
"""A database released at the production-only 0016 head must remain upgradable."""
|
|
||||||
async with sqlite_engine.begin() as conn:
|
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
|
||||||
|
|
||||||
await run_alembic_stamp(sqlite_engine, '0016_space_launch_replay')
|
|
||||||
await run_alembic_upgrade(sqlite_engine, 'head')
|
|
||||||
|
|
||||||
assert await get_alembic_current(sqlite_engine) == _get_script_head()
|
|
||||||
assert _get_script_head() == '0020_membership_source'
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_upgrade_from_baseline_to_head(self, sqlite_engine):
|
async def test_upgrade_from_baseline_to_head(self, sqlite_engine):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -85,32 +85,6 @@ async def clean_database(postgres_engine: AsyncEngine):
|
|||||||
await clean()
|
await clean()
|
||||||
|
|
||||||
|
|
||||||
async def test_upgrade_adds_3072_dimension_index_and_constraint(
|
|
||||||
postgres_engine: AsyncEngine,
|
|
||||||
clean_database,
|
|
||||||
) -> None:
|
|
||||||
async with postgres_engine.begin() as conn:
|
|
||||||
await conn.execute(text('CREATE EXTENSION IF NOT EXISTS vector'))
|
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
|
||||||
await run_alembic_stamp(postgres_engine, '0010_scope_resources')
|
|
||||||
await run_alembic_upgrade(postgres_engine, 'head')
|
|
||||||
|
|
||||||
async with postgres_engine.connect() as conn:
|
|
||||||
constraint = await conn.scalar(
|
|
||||||
text(
|
|
||||||
'SELECT pg_get_constraintdef(oid) FROM pg_constraint '
|
|
||||||
"WHERE conrelid = 'langbot_vectors'::regclass "
|
|
||||||
"AND conname = 'ck_langbot_vectors_embedding_dimension_enabled'"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
assert '3072' in constraint
|
|
||||||
index_definition = await conn.scalar(
|
|
||||||
text("SELECT indexdef FROM pg_indexes WHERE indexname = 'ix_langbot_vectors_hnsw_cosine_3072'")
|
|
||||||
)
|
|
||||||
assert 'halfvec(3072)' in index_definition
|
|
||||||
assert 'halfvec_cosine_ops' in index_definition
|
|
||||||
|
|
||||||
|
|
||||||
async def test_legacy_upgrade_temporarily_suspends_and_restores_source_rls_for_unprivileged_owner(
|
async def test_legacy_upgrade_temporarily_suspends_and_restores_source_rls_for_unprivileged_owner(
|
||||||
postgres_url: str,
|
postgres_url: str,
|
||||||
postgres_engine: AsyncEngine,
|
postgres_engine: AsyncEngine,
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ def _application(postgres_url: str, *, runtime_role: str = 'langbot_runtime_not_
|
|||||||
'use': 'pgvector',
|
'use': 'pgvector',
|
||||||
'pgvector': {
|
'pgvector': {
|
||||||
'use_business_database': True,
|
'use_business_database': True,
|
||||||
'allowed_dimensions': [384, 512, 768, 1024, 1536, 3072],
|
'allowed_dimensions': [384, 512, 768, 1024, 1536],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
||||||
|
|
||||||
from langbot.pkg.entity.persistence.base import Base
|
|
||||||
from langbot.pkg.entity.persistence.user import User
|
|
||||||
from langbot.pkg.entity.persistence.workspace import Workspace, WorkspaceMembership
|
|
||||||
from langbot.pkg.persistence.alembic_runner import run_alembic_stamp, run_alembic_upgrade
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_single_owner_migration_demotes_historical_extra_owner_and_installs_unique_index(tmp_path):
|
|
||||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "single-owner.db"}')
|
|
||||||
try:
|
|
||||||
async with engine.begin() as connection:
|
|
||||||
await connection.run_sync(Base.metadata.create_all)
|
|
||||||
await connection.execute(sa.text('DROP INDEX uq_workspace_memberships_one_active_owner'))
|
|
||||||
|
|
||||||
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
|
||||||
workspace_uuid = '00000000-0000-4000-8000-000000000001'
|
|
||||||
creator_uuid = '00000000-0000-4000-8000-000000000010'
|
|
||||||
promoted_uuid = '00000000-0000-4000-8000-000000000020'
|
|
||||||
async with session_factory() as session:
|
|
||||||
session.add_all(
|
|
||||||
[
|
|
||||||
User(
|
|
||||||
uuid=creator_uuid,
|
|
||||||
user='creator@example.test',
|
|
||||||
normalized_email='creator@example.test',
|
|
||||||
password='hash',
|
|
||||||
account_type='local',
|
|
||||||
),
|
|
||||||
User(
|
|
||||||
uuid=promoted_uuid,
|
|
||||||
user='promoted@example.test',
|
|
||||||
normalized_email='promoted@example.test',
|
|
||||||
password='hash',
|
|
||||||
account_type='local',
|
|
||||||
),
|
|
||||||
Workspace(
|
|
||||||
uuid=workspace_uuid,
|
|
||||||
instance_uuid='instance-test',
|
|
||||||
name='Workspace',
|
|
||||||
slug='workspace',
|
|
||||||
type='team',
|
|
||||||
status='active',
|
|
||||||
source='local',
|
|
||||||
created_by_account_uuid=creator_uuid,
|
|
||||||
),
|
|
||||||
WorkspaceMembership(
|
|
||||||
uuid='00000000-0000-4000-8000-000000000100',
|
|
||||||
workspace_uuid=workspace_uuid,
|
|
||||||
account_uuid=creator_uuid,
|
|
||||||
role='owner',
|
|
||||||
status='active',
|
|
||||||
),
|
|
||||||
WorkspaceMembership(
|
|
||||||
uuid='00000000-0000-4000-8000-000000000200',
|
|
||||||
workspace_uuid=workspace_uuid,
|
|
||||||
account_uuid=promoted_uuid,
|
|
||||||
role='owner',
|
|
||||||
status='active',
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
await run_alembic_stamp(engine, '0018_merge_launch_replay')
|
|
||||||
await run_alembic_upgrade(engine, 'head')
|
|
||||||
|
|
||||||
async with engine.connect() as connection:
|
|
||||||
roles = dict(
|
|
||||||
(
|
|
||||||
await connection.execute(
|
|
||||||
sa.text(
|
|
||||||
'SELECT account_uuid, role FROM workspace_memberships '
|
|
||||||
'WHERE workspace_uuid = :workspace_uuid ORDER BY account_uuid'
|
|
||||||
),
|
|
||||||
{'workspace_uuid': workspace_uuid},
|
|
||||||
)
|
|
||||||
).all()
|
|
||||||
)
|
|
||||||
assert roles == {creator_uuid: 'owner', promoted_uuid: 'admin'}
|
|
||||||
indexes = await connection.run_sync(
|
|
||||||
lambda sync_connection: {
|
|
||||||
index['name'] for index in sa.inspect(sync_connection).get_indexes('workspace_memberships')
|
|
||||||
}
|
|
||||||
)
|
|
||||||
assert 'uq_workspace_memberships_one_active_owner' in indexes
|
|
||||||
|
|
||||||
with pytest.raises(sa.exc.IntegrityError):
|
|
||||||
async with engine.begin() as connection:
|
|
||||||
await connection.execute(
|
|
||||||
sa.text("UPDATE workspace_memberships SET role = 'owner' WHERE account_uuid = :account_uuid"),
|
|
||||||
{'account_uuid': promoted_uuid},
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
await engine.dispose()
|
|
||||||
@@ -22,7 +22,6 @@ from langbot.pkg.persistence.alembic_runner import (
|
|||||||
from langbot.pkg.utils import constants
|
from langbot.pkg.utils import constants
|
||||||
from langbot.pkg.utils import importutil
|
from langbot.pkg.utils import importutil
|
||||||
from langbot.pkg.workspace.collaboration import normalize_email
|
from langbot.pkg.workspace.collaboration import normalize_email
|
||||||
from langbot.pkg.workspace.identity import workspace_uuid_from_instance_id
|
|
||||||
|
|
||||||
|
|
||||||
pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
|
pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
|
||||||
@@ -110,7 +109,6 @@ async def test_legacy_instance_gets_stable_accounts_and_default_workspace(legacy
|
|||||||
.mappings()
|
.mappings()
|
||||||
.one()
|
.one()
|
||||||
)
|
)
|
||||||
assert workspace['uuid'] == workspace_uuid_from_instance_id('instance_migration_test')
|
|
||||||
assert workspace['instance_uuid'] == 'instance_migration_test'
|
assert workspace['instance_uuid'] == 'instance_migration_test'
|
||||||
assert workspace['slug'] == 'default'
|
assert workspace['slug'] == 'default'
|
||||||
assert workspace['status'] == 'active'
|
assert workspace['status'] == 'active'
|
||||||
@@ -151,53 +149,6 @@ async def test_workspace_upgrade_is_idempotent_and_preserves_identifiers(legacy_
|
|||||||
assert workspace_uuid_after == workspace_uuid_before
|
assert workspace_uuid_after == workspace_uuid_before
|
||||||
|
|
||||||
|
|
||||||
async def test_existing_oss_workspace_is_rekeyed_to_instance_identity(tmp_path):
|
|
||||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-rekey.db"}')
|
|
||||||
instance_id = 'instance_a711d9e4-0953-443f-a0e9-7dd50193a79f'
|
|
||||||
old_workspace_uuid = '11111111-1111-4111-8111-111111111111'
|
|
||||||
canonical_uuid = workspace_uuid_from_instance_id(instance_id)
|
|
||||||
schema = sa.MetaData()
|
|
||||||
sa.Table(
|
|
||||||
'metadata',
|
|
||||||
schema,
|
|
||||||
sa.Column('key', sa.String(255), primary_key=True),
|
|
||||||
sa.Column('value', sa.String(255)),
|
|
||||||
)
|
|
||||||
sa.Table(
|
|
||||||
'workspaces',
|
|
||||||
schema,
|
|
||||||
sa.Column('uuid', sa.String(36), primary_key=True),
|
|
||||||
sa.Column('instance_uuid', sa.String(255), nullable=False),
|
|
||||||
sa.Column('slug', sa.String(255), nullable=False),
|
|
||||||
sa.Column('source', sa.String(32), nullable=False),
|
|
||||||
)
|
|
||||||
sa.Table(
|
|
||||||
'tenant_rows',
|
|
||||||
schema,
|
|
||||||
sa.Column('id', sa.Integer, primary_key=True),
|
|
||||||
sa.Column('workspace_uuid', sa.String(36), sa.ForeignKey('workspaces.uuid'), nullable=False),
|
|
||||||
)
|
|
||||||
async with engine.begin() as conn:
|
|
||||||
await conn.run_sync(schema.create_all)
|
|
||||||
await conn.execute(sa.text("INSERT INTO metadata (key, value) VALUES ('instance_uuid', :value)"), {'value': instance_id})
|
|
||||||
await conn.execute(
|
|
||||||
sa.text("INSERT INTO workspaces (uuid, instance_uuid, slug, source) VALUES (:uuid, :instance, 'default', 'local')"),
|
|
||||||
{'uuid': old_workspace_uuid, 'instance': instance_id},
|
|
||||||
)
|
|
||||||
await conn.execute(
|
|
||||||
sa.text("INSERT INTO tenant_rows (id, workspace_uuid) VALUES (1, :uuid)"),
|
|
||||||
{'uuid': old_workspace_uuid},
|
|
||||||
)
|
|
||||||
await run_alembic_stamp(engine, '0016_support_admin_sessions')
|
|
||||||
|
|
||||||
await run_alembic_upgrade(engine, 'head')
|
|
||||||
|
|
||||||
async with engine.connect() as conn:
|
|
||||||
assert (await conn.execute(sa.text("SELECT uuid FROM workspaces"))).scalar_one() == canonical_uuid
|
|
||||||
assert (await conn.execute(sa.text("SELECT workspace_uuid FROM tenant_rows"))).scalar_one() == canonical_uuid
|
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_workspace_kernel_upgrade_downgrade_upgrade_round_trip(tmp_path):
|
async def test_workspace_kernel_upgrade_downgrade_upgrade_round_trip(tmp_path):
|
||||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-round-trip.db"}')
|
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-round-trip.db"}')
|
||||||
try:
|
try:
|
||||||
@@ -411,47 +362,6 @@ async def test_persistence_startup_defers_workspace_tables_until_account_upgrade
|
|||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
|
|
||||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-rekey.db"}')
|
|
||||||
try:
|
|
||||||
await _create_legacy_schema(engine)
|
|
||||||
await run_alembic_stamp(engine, '0008_mcp_resource_prefs')
|
|
||||||
await run_alembic_upgrade(engine, '0016_support_admin_sessions')
|
|
||||||
|
|
||||||
async with engine.begin() as conn:
|
|
||||||
old_uuid = await conn.scalar(sa.text("SELECT uuid FROM workspaces WHERE source = 'local'"))
|
|
||||||
instance_uuid = await conn.scalar(sa.text("SELECT instance_uuid FROM workspaces WHERE source = 'local'"))
|
|
||||||
assert old_uuid
|
|
||||||
assert instance_uuid
|
|
||||||
await conn.execute(
|
|
||||||
sa.text(
|
|
||||||
"INSERT INTO workspace_metadata (workspace_uuid, key, value) "
|
|
||||||
"VALUES (:workspace_uuid, 'migration_probe', 'present')"
|
|
||||||
),
|
|
||||||
{'workspace_uuid': old_uuid},
|
|
||||||
)
|
|
||||||
await conn.execute(
|
|
||||||
sa.text(
|
|
||||||
"INSERT INTO metadata (key, value) VALUES ('oss_workspace_uuid', :workspace_uuid) "
|
|
||||||
"ON CONFLICT(key) DO UPDATE SET value = excluded.value"
|
|
||||||
),
|
|
||||||
{'workspace_uuid': old_uuid},
|
|
||||||
)
|
|
||||||
|
|
||||||
await run_alembic_upgrade(engine, 'head')
|
|
||||||
expected_uuid = workspace_uuid_from_instance_id(instance_uuid)
|
|
||||||
async with engine.connect() as conn:
|
|
||||||
assert await conn.scalar(sa.text("SELECT uuid FROM workspaces WHERE source = 'local'")) == expected_uuid
|
|
||||||
assert await conn.scalar(
|
|
||||||
sa.text("SELECT workspace_uuid FROM workspace_metadata WHERE key = 'migration_probe'")
|
|
||||||
) == expected_uuid
|
|
||||||
assert await conn.scalar(
|
|
||||||
sa.text("SELECT value FROM metadata WHERE key = 'oss_workspace_uuid'")
|
|
||||||
) == expected_uuid
|
|
||||||
finally:
|
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_persistence_startup_rejects_instance_uuid_drift(tmp_path, monkeypatch):
|
async def test_persistence_startup_rejects_instance_uuid_drift(tmp_path, monkeypatch):
|
||||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "instance-drift.db"}')
|
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "instance-drift.db"}')
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -27,9 +27,10 @@ def test_owner_has_every_fixed_permission():
|
|||||||
assert ctx.workspace.permissions == frozenset(permission.value for permission in authz.Permission)
|
assert ctx.workspace.permissions == frozenset(permission.value for permission in authz.Permission)
|
||||||
|
|
||||||
|
|
||||||
def test_admin_cannot_delete_workspace_or_link_billing():
|
def test_admin_cannot_transfer_owner_delete_workspace_or_link_billing():
|
||||||
ctx = _context(authz.WorkspaceRole.ADMIN)
|
ctx = _context(authz.WorkspaceRole.ADMIN)
|
||||||
|
|
||||||
|
assert not authz.has_permission(ctx, authz.Permission.OWNER_TRANSFER)
|
||||||
assert not authz.has_permission(ctx, authz.Permission.WORKSPACE_DELETE)
|
assert not authz.has_permission(ctx, authz.Permission.WORKSPACE_DELETE)
|
||||||
assert not authz.has_permission(ctx, authz.Permission.BILLING_LINK_MANAGE)
|
assert not authz.has_permission(ctx, authz.Permission.BILLING_LINK_MANAGE)
|
||||||
assert authz.has_permission(ctx, authz.Permission.MEMBER_INVITE)
|
assert authz.has_permission(ctx, authz.Permission.MEMBER_INVITE)
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
"""Cloud Runtime write protection for the managed LangBot Models catalog."""
|
|
||||||
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from unittest.mock import AsyncMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from langbot.pkg.api.http.service import model as model_service_module
|
|
||||||
from langbot.pkg.api.http.service.model import (
|
|
||||||
EmbeddingModelsService,
|
|
||||||
LLMModelsService,
|
|
||||||
RerankModelsService,
|
|
||||||
_assert_cloud_managed_provider_mutable,
|
|
||||||
)
|
|
||||||
from langbot.pkg.cloud.model_catalog import LANGBOT_MODELS_PROVIDER_REQUESTER
|
|
||||||
|
|
||||||
|
|
||||||
WORKSPACE = 'workspace-a'
|
|
||||||
PROVIDER = 'managed-provider'
|
|
||||||
MODEL = 'managed-model'
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_managed_provider_guard_is_cloud_only(monkeypatch) -> None:
|
|
||||||
async def managed_provider(_ap, _context, provider_uuid):
|
|
||||||
assert provider_uuid == PROVIDER
|
|
||||||
return {'uuid': PROVIDER, 'requester': LANGBOT_MODELS_PROVIDER_REQUESTER}
|
|
||||||
|
|
||||||
monkeypatch.setattr(model_service_module, '_require_workspace_provider', managed_provider)
|
|
||||||
application = SimpleNamespace(persistence_mgr=SimpleNamespace(mode=SimpleNamespace(value='cloud_runtime')))
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match='managed by Cloud'):
|
|
||||||
await _assert_cloud_managed_provider_mutable(
|
|
||||||
application,
|
|
||||||
WORKSPACE,
|
|
||||||
PROVIDER,
|
|
||||||
)
|
|
||||||
|
|
||||||
application.persistence_mgr.mode.value = 'normal'
|
|
||||||
await _assert_cloud_managed_provider_mutable(
|
|
||||||
application,
|
|
||||||
WORKSPACE,
|
|
||||||
PROVIDER,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
('service_type', 'create_method', 'model_data'),
|
|
||||||
[
|
|
||||||
(LLMModelsService, 'create_llm_model', {'provider_uuid': PROVIDER, 'name': 'chat', 'abilities': []}),
|
|
||||||
(EmbeddingModelsService, 'create_embedding_model', {'provider_uuid': PROVIDER, 'name': 'embedding'}),
|
|
||||||
(RerankModelsService, 'create_rerank_model', {'provider_uuid': PROVIDER, 'name': 'rerank'}),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_all_model_types_reject_creation_under_managed_provider(
|
|
||||||
monkeypatch,
|
|
||||||
service_type,
|
|
||||||
create_method: str,
|
|
||||||
model_data: dict,
|
|
||||||
) -> None:
|
|
||||||
guard = AsyncMock(side_effect=ValueError('LangBot Models is managed by Cloud and cannot be modified'))
|
|
||||||
monkeypatch.setattr(model_service_module, '_assert_cloud_managed_provider_mutable', guard)
|
|
||||||
application = SimpleNamespace(
|
|
||||||
persistence_mgr=SimpleNamespace(),
|
|
||||||
provider_service=SimpleNamespace(
|
|
||||||
get_provider=AsyncMock(return_value={'uuid': PROVIDER, 'requester': LANGBOT_MODELS_PROVIDER_REQUESTER})
|
|
||||||
),
|
|
||||||
model_mgr=None,
|
|
||||||
)
|
|
||||||
service = service_type(application)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match='managed by Cloud'):
|
|
||||||
await getattr(service, create_method)(WORKSPACE, model_data)
|
|
||||||
|
|
||||||
guard.assert_awaited_once()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
('service_type', 'get_method', 'write_method', 'payload'),
|
|
||||||
[
|
|
||||||
(LLMModelsService, 'get_llm_model', 'update_llm_model', {'name': 'changed'}),
|
|
||||||
(LLMModelsService, 'get_llm_model', 'delete_llm_model', None),
|
|
||||||
(EmbeddingModelsService, 'get_embedding_model', 'update_embedding_model', {'name': 'changed'}),
|
|
||||||
(EmbeddingModelsService, 'get_embedding_model', 'delete_embedding_model', None),
|
|
||||||
(RerankModelsService, 'get_rerank_model', 'update_rerank_model', {'name': 'changed'}),
|
|
||||||
(RerankModelsService, 'get_rerank_model', 'delete_rerank_model', None),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_all_model_types_reject_update_and_delete_for_managed_provider(
|
|
||||||
monkeypatch,
|
|
||||||
service_type,
|
|
||||||
get_method: str,
|
|
||||||
write_method: str,
|
|
||||||
payload: dict | None,
|
|
||||||
) -> None:
|
|
||||||
guard = AsyncMock(side_effect=ValueError('LangBot Models is managed by Cloud and cannot be modified'))
|
|
||||||
monkeypatch.setattr(model_service_module, '_assert_cloud_managed_provider_mutable', guard)
|
|
||||||
application = SimpleNamespace(persistence_mgr=SimpleNamespace(mode=SimpleNamespace(value='cloud_runtime')))
|
|
||||||
service = service_type(application)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
service,
|
|
||||||
get_method,
|
|
||||||
AsyncMock(return_value={'uuid': MODEL, 'provider_uuid': PROVIDER, 'extra_args': {}}),
|
|
||||||
)
|
|
||||||
|
|
||||||
args = (WORKSPACE, MODEL) if payload is None else (WORKSPACE, MODEL, payload)
|
|
||||||
with pytest.raises(ValueError, match='managed by Cloud'):
|
|
||||||
await getattr(service, write_method)(*args)
|
|
||||||
|
|
||||||
guard.assert_awaited_once()
|
|
||||||
@@ -34,7 +34,6 @@ class _Rows:
|
|||||||
def _app():
|
def _app():
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
logger=Mock(),
|
logger=Mock(),
|
||||||
instance_config=SimpleNamespace(data={}),
|
|
||||||
rag_mgr=SimpleNamespace(
|
rag_mgr=SimpleNamespace(
|
||||||
get_all_knowledge_base_details=AsyncMock(return_value=[]),
|
get_all_knowledge_base_details=AsyncMock(return_value=[]),
|
||||||
get_knowledge_base_details=AsyncMock(return_value=None),
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_update_rejects_guessed_uuid_and_scopes_reload():
|
async def test_update_rejects_guessed_uuid_and_scopes_reload():
|
||||||
app = _app()
|
app = _app()
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ from langbot.pkg.workspace.errors import WorkspaceNotFoundError
|
|||||||
pytestmark = pytest.mark.asyncio
|
pytestmark = pytest.mark.asyncio
|
||||||
|
|
||||||
WORKSPACE_UUID = 'workspace-a'
|
WORKSPACE_UUID = 'workspace-a'
|
||||||
SYSTEM_REQUESTER = 'space-chat-completions'
|
|
||||||
|
|
||||||
|
|
||||||
def _create_mock_provider(
|
def _create_mock_provider(
|
||||||
@@ -834,44 +833,6 @@ class TestModelProviderServiceScanProviderModels:
|
|||||||
assert len(result['models']) == 1
|
assert len(result['models']) == 1
|
||||||
assert result['models'][0]['type'] == 'llm'
|
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):
|
async def test_scan_provider_not_implemented_raises_error(self):
|
||||||
"""Raises ValueError when scan not implemented."""
|
"""Raises ValueError when scan not implemented."""
|
||||||
# Setup
|
# Setup
|
||||||
@@ -1006,56 +967,3 @@ class TestProviderSecretRoundtrip:
|
|||||||
)
|
)
|
||||||
|
|
||||||
ap.persistence_mgr.execute_async.assert_not_awaited()
|
ap.persistence_mgr.execute_async.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
class TestCloudManagedProviderProtection:
|
|
||||||
@staticmethod
|
|
||||||
def _service() -> ModelProviderService:
|
|
||||||
ap = SimpleNamespace(
|
|
||||||
persistence_mgr=SimpleNamespace(
|
|
||||||
mode=SimpleNamespace(value='cloud_runtime'),
|
|
||||||
execute_async=AsyncMock(),
|
|
||||||
),
|
|
||||||
model_mgr=SimpleNamespace(),
|
|
||||||
)
|
|
||||||
return ModelProviderService(ap)
|
|
||||||
|
|
||||||
async def test_cloud_rejects_user_created_system_requester(self):
|
|
||||||
service = self._service()
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match='reserved'):
|
|
||||||
await service.create_provider(
|
|
||||||
WORKSPACE_UUID,
|
|
||||||
{
|
|
||||||
'name': 'Fake LangBot Models',
|
|
||||||
'requester': SYSTEM_REQUESTER,
|
|
||||||
'base_url': 'https://example.invalid/v1',
|
|
||||||
'api_keys': ['fake'],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match='reserved'):
|
|
||||||
await service.find_or_create_provider(
|
|
||||||
WORKSPACE_UUID,
|
|
||||||
SYSTEM_REQUESTER,
|
|
||||||
'https://api.langbot.cloud/v1',
|
|
||||||
['fake'],
|
|
||||||
)
|
|
||||||
service.ap.persistence_mgr.execute_async.assert_not_awaited()
|
|
||||||
|
|
||||||
async def test_cloud_rejects_update_and_delete_of_managed_provider(self):
|
|
||||||
service = self._service()
|
|
||||||
service.get_provider = AsyncMock(
|
|
||||||
return_value={'uuid': 'system-provider', 'requester': SYSTEM_REQUESTER}
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match='managed by Cloud'):
|
|
||||||
await service.update_provider(WORKSPACE_UUID, 'system-provider', {'name': 'Renamed'})
|
|
||||||
with pytest.raises(ValueError, match='managed by Cloud'):
|
|
||||||
await service.delete_provider(WORKSPACE_UUID, 'system-provider')
|
|
||||||
service.ap.persistence_mgr.execute_async.assert_not_awaited()
|
|
||||||
|
|
||||||
async def test_oss_does_not_reserve_space_requester(self):
|
|
||||||
ap = SimpleNamespace(persistence_mgr=SimpleNamespace(mode=SimpleNamespace(value='oss_compat')))
|
|
||||||
service = ModelProviderService(ap)
|
|
||||||
assert service._system_requester_is_reserved(SYSTEM_REQUESTER) is False
|
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import time
|
|||||||
|
|
||||||
from langbot.pkg.api.http.service.space import SpaceService
|
from langbot.pkg.api.http.service.space import SpaceService
|
||||||
from langbot.pkg.entity.persistence.user import User
|
from langbot.pkg.entity.persistence.user import User
|
||||||
from langbot.pkg.utils import constants
|
|
||||||
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.asyncio
|
pytestmark = pytest.mark.asyncio
|
||||||
@@ -574,20 +573,10 @@ class TestSpaceServiceExchangeOAuthCode:
|
|||||||
mock_session_obj.post.return_value.__aexit__ = AsyncMock(return_value=None)
|
mock_session_obj.post.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
# Execute
|
# Execute
|
||||||
result = await service.exchange_oauth_code(
|
result = await service.exchange_oauth_code('auth_code')
|
||||||
'auth_code',
|
|
||||||
['workspace-1'],
|
|
||||||
{'workspace-1': 1_700_000_000},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify
|
# Verify
|
||||||
assert result['access_token'] == 'new_access_token'
|
assert result['access_token'] == 'new_access_token'
|
||||||
assert mock_session_obj.post.call_args.kwargs['json'] == {
|
|
||||||
'code': 'auth_code',
|
|
||||||
'instance_id': constants.instance_id,
|
|
||||||
'workspace_uuids': ['workspace-1'],
|
|
||||||
'workspace_created_ats': {'workspace-1': 1_700_000_000},
|
|
||||||
}
|
|
||||||
|
|
||||||
async def test_exchange_oauth_code_api_error(self):
|
async def test_exchange_oauth_code_api_error(self):
|
||||||
"""Raises ValueError on API error."""
|
"""Raises ValueError on API error."""
|
||||||
@@ -767,7 +756,7 @@ class TestSpaceServiceGetModels:
|
|||||||
'uuid': 'uuid-2',
|
'uuid': 'uuid-2',
|
||||||
'model_id': 'model-2',
|
'model_id': 'model-2',
|
||||||
'provider': 'provider-2',
|
'provider': 'provider-2',
|
||||||
'category': 'rerank',
|
'category': 'chat',
|
||||||
'status': 'active',
|
'status': 'active',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -789,7 +778,6 @@ class TestSpaceServiceGetModels:
|
|||||||
|
|
||||||
# Verify
|
# Verify
|
||||||
assert len(result) == 2
|
assert len(result) == 2
|
||||||
assert result[1].category == 'rerank'
|
|
||||||
|
|
||||||
async def test_get_models_api_error(self):
|
async def test_get_models_api_error(self):
|
||||||
"""Raises ValueError on API error."""
|
"""Raises ValueError on API error."""
|
||||||
|
|||||||
@@ -418,7 +418,6 @@ class TestUserServiceGenerateJwtToken:
|
|||||||
assert token is not None
|
assert token is not None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class TestUserServiceVerifyJwtToken:
|
class TestUserServiceVerifyJwtToken:
|
||||||
"""Tests for verify_jwt_token method."""
|
"""Tests for verify_jwt_token method."""
|
||||||
|
|
||||||
|
|||||||
@@ -149,36 +149,6 @@ async def test_session_scope_matches_exact_tenant_placement_and_principal():
|
|||||||
assert sessions == {}
|
assert sessions == {}
|
||||||
|
|
||||||
|
|
||||||
async def test_support_admin_sessions_are_scoped_to_the_persisted_grant():
|
|
||||||
def support_context(grant_jti_hash: str) -> RequestContext:
|
|
||||||
return RequestContext(
|
|
||||||
instance_uuid='instance-test',
|
|
||||||
placement_generation=1,
|
|
||||||
request_id='request-test',
|
|
||||||
auth_type='support-admin',
|
|
||||||
principal=PrincipalContext(
|
|
||||||
principal_type=PrincipalType.SUPPORT_ADMIN,
|
|
||||||
actor_account_uuid='support-actor',
|
|
||||||
support_session_id=grant_jti_hash,
|
|
||||||
),
|
|
||||||
workspace=WorkspaceContext(
|
|
||||||
workspace_uuid='workspace-a',
|
|
||||||
membership_uuid=None,
|
|
||||||
role='owner',
|
|
||||||
permissions=frozenset({'resource.manage'}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
first_context = support_context('a' * 64)
|
|
||||||
second_context = support_context('b' * 64)
|
|
||||||
sessions: dict[str, dict] = {'session-test': {'status': 'waiting'}}
|
|
||||||
_bind_session_scope(sessions['session-test'], first_context)
|
|
||||||
|
|
||||||
assert _get_owned_session(sessions, 'session-test', second_context) is None
|
|
||||||
assert _pop_owned_session(sessions, 'session-test', second_context) is None
|
|
||||||
assert _get_owned_session(sessions, 'session-test', first_context) is sessions['session-test']
|
|
||||||
|
|
||||||
|
|
||||||
async def test_session_capacity_evicts_oldest_session_in_same_workspace():
|
async def test_session_capacity_evicts_oldest_session_in_same_workspace():
|
||||||
owner_context = _request_context()
|
owner_context = _request_context()
|
||||||
sessions: dict[str, dict] = {}
|
sessions: dict[str, dict] = {}
|
||||||
|
|||||||
@@ -46,20 +46,6 @@ def plugin_router_cls():
|
|||||||
yield PluginsRouterGroup
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_public_plugin_asset_route_is_disabled_for_multi_workspace_policy(plugin_router_cls):
|
async def test_public_plugin_asset_route_is_disabled_for_multi_workspace_policy(plugin_router_cls):
|
||||||
connector = SimpleNamespace(
|
connector = SimpleNamespace(
|
||||||
@@ -124,37 +110,24 @@ async def test_background_plugin_operation_refences_captured_generation(plugin_r
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_background_plugin_operation_revalidates_and_runs_inside_tenant_uow(plugin_router_cls):
|
async def test_background_plugin_operation_revalidates_inside_short_tenant_uow(plugin_router_cls):
|
||||||
scopes = []
|
scopes = []
|
||||||
active_scope = None
|
|
||||||
|
|
||||||
transaction_active = False
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def tenant_scope(workspace_uuid):
|
async def tenant_uow(workspace_uuid):
|
||||||
nonlocal active_scope
|
|
||||||
scopes.append(workspace_uuid)
|
scopes.append(workspace_uuid)
|
||||||
active_scope = workspace_uuid
|
yield
|
||||||
try:
|
|
||||||
yield
|
|
||||||
finally:
|
|
||||||
active_scope = None
|
|
||||||
|
|
||||||
connector = SimpleNamespace(
|
connector = SimpleNamespace(
|
||||||
require_workspace_context=AsyncMock(side_effect=lambda context: context),
|
require_workspace_context=AsyncMock(side_effect=lambda context: context),
|
||||||
)
|
)
|
||||||
|
operation = AsyncMock(return_value='done')
|
||||||
async def operation():
|
|
||||||
assert active_scope == CONTEXT.workspace_uuid
|
|
||||||
assert transaction_active is False
|
|
||||||
return 'done'
|
|
||||||
|
|
||||||
router = object.__new__(plugin_router_cls)
|
router = object.__new__(plugin_router_cls)
|
||||||
router.ap = SimpleNamespace(
|
router.ap = SimpleNamespace(
|
||||||
plugin_connector=connector,
|
plugin_connector=connector,
|
||||||
persistence_mgr=SimpleNamespace(
|
persistence_mgr=SimpleNamespace(
|
||||||
mode=SimpleNamespace(value='cloud_runtime'),
|
mode=SimpleNamespace(value='cloud_runtime'),
|
||||||
tenant_scope=tenant_scope,
|
tenant_uow=tenant_uow,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -163,3 +136,4 @@ async def test_background_plugin_operation_revalidates_and_runs_inside_tenant_uo
|
|||||||
assert result == 'done'
|
assert result == 'done'
|
||||||
assert scopes == [CONTEXT.workspace_uuid]
|
assert scopes == [CONTEXT.workspace_uuid]
|
||||||
connector.require_workspace_context.assert_awaited_once_with(CONTEXT)
|
connector.require_workspace_context.assert_awaited_once_with(CONTEXT)
|
||||||
|
operation.assert_awaited_once()
|
||||||
|
|||||||
@@ -66,10 +66,6 @@ class _Provider:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.manifest_provider = _Manifest()
|
self.manifest_provider = _Manifest()
|
||||||
|
|
||||||
async def fetch_model_catalog(self, instance_uuid: str):
|
|
||||||
del instance_uuid
|
|
||||||
raise AssertionError('not used by bootstrap contract tests')
|
|
||||||
|
|
||||||
def bootstrap(self, *, instance_uuid: str, instance_config: dict):
|
def bootstrap(self, *, instance_uuid: str, instance_config: dict):
|
||||||
del instance_config
|
del instance_config
|
||||||
return VerifiedCloudDeployment(
|
return VerifiedCloudDeployment(
|
||||||
@@ -83,7 +79,6 @@ class _Provider:
|
|||||||
entitlement_provider=_Entitlements(),
|
entitlement_provider=_Entitlements(),
|
||||||
directory_provider=_Directory(),
|
directory_provider=_Directory(),
|
||||||
manifest_provider=self.manifest_provider,
|
manifest_provider=self.manifest_provider,
|
||||||
model_catalog_provider=self,
|
|
||||||
verification_key_id='root-2026',
|
verification_key_id='root-2026',
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -108,7 +103,7 @@ def _cloud_config() -> dict:
|
|||||||
'use': 'pgvector',
|
'use': 'pgvector',
|
||||||
'pgvector': {
|
'pgvector': {
|
||||||
'use_business_database': True,
|
'use_business_database': True,
|
||||||
'allowed_dimensions': [384, 768, 1536, 3072],
|
'allowed_dimensions': [384, 768, 1536],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'mcp': {'stdio': {'enabled': False}},
|
'mcp': {'stdio': {'enabled': False}},
|
||||||
@@ -216,6 +211,7 @@ async def test_cloud_directory_capacity_contract_is_fail_closed(directory_config
|
|||||||
[
|
[
|
||||||
({'use_business_database': False, 'allowed_dimensions': [1536]}, 'use_business_database=true'),
|
({'use_business_database': False, 'allowed_dimensions': [1536]}, 'use_business_database=true'),
|
||||||
({'use_business_database': True, 'allowed_dimensions': []}, 'allowed_dimensions'),
|
({'use_business_database': True, 'allowed_dimensions': []}, 'allowed_dimensions'),
|
||||||
|
({'use_business_database': True, 'allowed_dimensions': [3072]}, 'allowed_dimensions'),
|
||||||
({'use_business_database': True, 'allowed_dimensions': [True]}, 'allowed_dimensions'),
|
({'use_business_database': True, 'allowed_dimensions': [True]}, 'allowed_dimensions'),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -232,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(
|
@pytest.mark.parametrize(
|
||||||
('mutate', 'message'),
|
('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'].update(backend='docker'), 'box.backend=nsjail'),
|
||||||
(lambda config: config['box']['runtime'].update(endpoint=''), 'box.runtime.endpoint'),
|
(lambda config: config['box']['runtime'].update(endpoint=''), 'box.runtime.endpoint'),
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -181,39 +181,6 @@ def _delta(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_directory_delta_requests_model_catalog_sync_after_commit(projection_context):
|
|
||||||
application, _session_factory = projection_context
|
|
||||||
request_sync = Mock()
|
|
||||||
application.cloud_model_catalog_service = SimpleNamespace(request_sync=request_sync)
|
|
||||||
event = DirectoryEvent(
|
|
||||||
cursor=2,
|
|
||||||
uuid='20000000-0000-4000-8000-000000000002',
|
|
||||||
aggregate_uuid=WORKSPACE_UUID,
|
|
||||||
event_type='directory.changed',
|
|
||||||
revision=2,
|
|
||||||
payload={'workspace_uuid': WORKSPACE_UUID, 'directory_revision': 2},
|
|
||||||
created_at=datetime.datetime(2026, 7, 24, 12, 30, tzinfo=datetime.UTC),
|
|
||||||
)
|
|
||||||
batch = DirectoryEventBatch(
|
|
||||||
instance_uuid=INSTANCE_UUID,
|
|
||||||
after_cursor=1,
|
|
||||||
cursor=2,
|
|
||||||
high_water_cursor=2,
|
|
||||||
events=[event],
|
|
||||||
)
|
|
||||||
service = DirectoryProjectionService(
|
|
||||||
application,
|
|
||||||
_Provider([_snapshot(1)], [batch], [_delta(workspaces=[_workspace(revision=2)])]),
|
|
||||||
INSTANCE_UUID,
|
|
||||||
)
|
|
||||||
await service.initialize()
|
|
||||||
request_sync.reset_mock()
|
|
||||||
|
|
||||||
await service.sync_once()
|
|
||||||
|
|
||||||
request_sync.assert_called_once_with()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_initial_snapshot_projects_core_owned_rows(projection_context):
|
async def test_initial_snapshot_projects_core_owned_rows(projection_context):
|
||||||
application, session_factory = projection_context
|
application, session_factory = projection_context
|
||||||
reconcile_execution_projection = Mock()
|
reconcile_execution_projection = Mock()
|
||||||
@@ -1056,7 +1023,7 @@ async def test_snapshot_for_another_instance_is_rejected(projection_context):
|
|||||||
await service.initialize()
|
await service.initialize()
|
||||||
|
|
||||||
|
|
||||||
async def test_directory_revision_zero_membership_is_adopted(projection_context):
|
async def test_core_owned_membership_survives_directory_updates_and_omission(projection_context):
|
||||||
application, session_factory = projection_context
|
application, session_factory = projection_context
|
||||||
service = DirectoryProjectionService(application, _Provider([_snapshot(1)]), INSTANCE_UUID)
|
service = DirectoryProjectionService(application, _Provider([_snapshot(1)]), INSTANCE_UUID)
|
||||||
await service.initialize()
|
await service.initialize()
|
||||||
@@ -1067,125 +1034,29 @@ async def test_directory_revision_zero_membership_is_adopted(projection_context)
|
|||||||
membership.role = 'viewer'
|
membership.role = 'viewer'
|
||||||
membership.status = 'active'
|
membership.status = 'active'
|
||||||
membership.projection_revision = 0
|
membership.projection_revision = 0
|
||||||
|
|
||||||
projected_member = _member(revision=2).model_copy(update={'role': 'owner', 'membership_status': 'removed'})
|
|
||||||
projected_workspace = _workspace(revision=2).model_copy(update={'members': (projected_member,)})
|
|
||||||
await service.apply_snapshot(_snapshot(2, workspaces=[projected_workspace]))
|
|
||||||
|
|
||||||
async with session_factory() as session:
|
|
||||||
membership = await session.scalar(sqlalchemy.select(WorkspaceMembership))
|
|
||||||
assert membership.source == 'cloud_projection'
|
|
||||||
assert membership.role == 'owner'
|
|
||||||
assert membership.status == 'removed'
|
|
||||||
assert membership.projection_revision == 2
|
|
||||||
|
|
||||||
|
|
||||||
async def test_directory_revision_zero_membership_omitted_from_snapshot_is_removed(projection_context):
|
|
||||||
application, session_factory = projection_context
|
|
||||||
service = DirectoryProjectionService(application, _Provider([_snapshot(1)]), INSTANCE_UUID)
|
|
||||||
await service.initialize()
|
|
||||||
|
|
||||||
historical_account_uuid = '20000000-0000-0000-0000-000000000099'
|
|
||||||
async with session_factory() as session:
|
|
||||||
async with session.begin():
|
|
||||||
membership = await session.scalar(sqlalchemy.select(WorkspaceMembership))
|
|
||||||
session.add(
|
|
||||||
User(
|
|
||||||
uuid=historical_account_uuid,
|
|
||||||
user='Historical Space Member',
|
|
||||||
normalized_email='historical@example.com',
|
|
||||||
password='',
|
|
||||||
status='active',
|
|
||||||
source='cloud_projection',
|
|
||||||
projection_revision=1,
|
|
||||||
account_type='space',
|
|
||||||
space_account_uuid=historical_account_uuid,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
session.add(
|
session.add(
|
||||||
WorkspaceMembership(
|
WorkspaceMembership(
|
||||||
uuid=SECOND_MEMBERSHIP_UUID,
|
uuid=SECOND_MEMBERSHIP_UUID,
|
||||||
workspace_uuid=WORKSPACE_UUID,
|
workspace_uuid=WORKSPACE_UUID,
|
||||||
account_uuid=historical_account_uuid,
|
account_uuid='20000000-0000-0000-0000-000000000099',
|
||||||
role='viewer',
|
role='viewer',
|
||||||
status='active',
|
status='active',
|
||||||
source='cloud_projection',
|
|
||||||
joined_at=membership.joined_at,
|
joined_at=membership.joined_at,
|
||||||
projection_revision=0,
|
projection_revision=0,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
await service.apply_snapshot(_snapshot(2))
|
|
||||||
|
|
||||||
async with session_factory() as session:
|
|
||||||
historical = await session.get(WorkspaceMembership, SECOND_MEMBERSHIP_UUID)
|
|
||||||
assert historical.status == 'removed'
|
|
||||||
assert historical.projection_revision == 2
|
|
||||||
|
|
||||||
|
|
||||||
async def test_cloud_account_core_invitation_membership_survives_directory_omission(projection_context):
|
|
||||||
application, session_factory = projection_context
|
|
||||||
service = DirectoryProjectionService(application, _Provider([_snapshot(1)]), INSTANCE_UUID)
|
|
||||||
await service.initialize()
|
|
||||||
|
|
||||||
invited_account_uuid = '20000000-0000-0000-0000-000000000098'
|
|
||||||
async with session_factory() as session:
|
|
||||||
async with session.begin():
|
|
||||||
projected_membership = await session.scalar(sqlalchemy.select(WorkspaceMembership))
|
|
||||||
session.add(
|
|
||||||
User(
|
|
||||||
uuid=invited_account_uuid,
|
|
||||||
user='Invited Cloud Account',
|
|
||||||
normalized_email='invited-cloud@example.com',
|
|
||||||
password='',
|
|
||||||
status='active',
|
|
||||||
source='cloud_projection',
|
|
||||||
projection_revision=1,
|
|
||||||
account_type='space',
|
|
||||||
space_account_uuid=invited_account_uuid,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
session.add(
|
|
||||||
WorkspaceMembership(
|
|
||||||
uuid=SECOND_MEMBERSHIP_UUID,
|
|
||||||
workspace_uuid=WORKSPACE_UUID,
|
|
||||||
account_uuid=invited_account_uuid,
|
|
||||||
role='viewer',
|
|
||||||
status='active',
|
|
||||||
source='local',
|
|
||||||
joined_at=projected_membership.joined_at,
|
|
||||||
projection_revision=0,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
await service.apply_snapshot(_snapshot(2))
|
|
||||||
|
|
||||||
async with session_factory() as session:
|
|
||||||
membership = await session.get(WorkspaceMembership, SECOND_MEMBERSHIP_UUID)
|
|
||||||
assert membership.source == 'local'
|
|
||||||
assert membership.status == 'active'
|
|
||||||
assert membership.projection_revision == 0
|
|
||||||
|
|
||||||
|
|
||||||
async def test_directory_does_not_adopt_local_membership_with_different_uuid_for_same_cloud_account(projection_context):
|
|
||||||
application, session_factory = projection_context
|
|
||||||
service = DirectoryProjectionService(application, _Provider([_snapshot(1)]), INSTANCE_UUID)
|
|
||||||
await service.initialize()
|
|
||||||
|
|
||||||
async with session_factory() as session:
|
|
||||||
async with session.begin():
|
|
||||||
membership = await session.scalar(sqlalchemy.select(WorkspaceMembership))
|
|
||||||
membership.uuid = SECOND_MEMBERSHIP_UUID
|
|
||||||
membership.source = 'local'
|
|
||||||
membership.projection_revision = 0
|
|
||||||
|
|
||||||
projected_member = _member(revision=2).model_copy(update={'role': 'owner', 'membership_status': 'removed'})
|
projected_member = _member(revision=2).model_copy(update={'role': 'owner', 'membership_status': 'removed'})
|
||||||
projected_workspace = _workspace(revision=2).model_copy(update={'members': (projected_member,)})
|
projected_workspace = _workspace(revision=2).model_copy(update={'members': (projected_member,)})
|
||||||
await service.apply_snapshot(_snapshot(2, workspaces=[projected_workspace]))
|
await service.apply_snapshot(_snapshot(2, workspaces=[projected_workspace]))
|
||||||
|
|
||||||
async with session_factory() as session:
|
async with session_factory() as session:
|
||||||
membership = await session.get(WorkspaceMembership, SECOND_MEMBERSHIP_UUID)
|
memberships = {
|
||||||
assert membership.source == 'local'
|
membership.uuid: membership
|
||||||
assert membership.role == 'developer'
|
for membership in (await session.scalars(sqlalchemy.select(WorkspaceMembership))).all()
|
||||||
assert membership.status == 'active'
|
}
|
||||||
assert membership.projection_revision == 0
|
assert memberships[MEMBERSHIP_UUID].role == 'viewer'
|
||||||
|
assert memberships[MEMBERSHIP_UUID].status == 'active'
|
||||||
|
assert memberships[MEMBERSHIP_UUID].projection_revision == 0
|
||||||
|
assert memberships[SECOND_MEMBERSHIP_UUID].status == 'active'
|
||||||
|
assert memberships[SECOND_MEMBERSHIP_UUID].projection_revision == 0
|
||||||
|
|||||||
@@ -1,466 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import sqlalchemy
|
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
|
||||||
|
|
||||||
from langbot.pkg.cloud.model_catalog import (
|
|
||||||
CloudModelCatalogSnapshot,
|
|
||||||
CloudModelCatalogSyncService,
|
|
||||||
system_model_uuid,
|
|
||||||
system_provider_uuid,
|
|
||||||
)
|
|
||||||
from langbot.pkg.entity.persistence.base import Base
|
|
||||||
from langbot.pkg.entity.persistence.model import EmbeddingModel, LLMModel, ModelProvider
|
|
||||||
from langbot.pkg.entity.persistence.workspace import Workspace
|
|
||||||
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
|
|
||||||
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.asyncio
|
|
||||||
INSTANCE_UUID = 'instance-model-catalog'
|
|
||||||
WORKSPACE_A = '00000000-0000-4000-8000-000000000001'
|
|
||||||
WORKSPACE_B = '00000000-0000-4000-8000-000000000002'
|
|
||||||
OWNER_A = '10000000-0000-4000-8000-000000000001'
|
|
||||||
OWNER_B = '10000000-0000-4000-8000-000000000002'
|
|
||||||
|
|
||||||
|
|
||||||
class _CatalogProvider:
|
|
||||||
def __init__(self, snapshot: CloudModelCatalogSnapshot) -> None:
|
|
||||||
self.snapshot = snapshot
|
|
||||||
|
|
||||||
async def fetch_model_catalog(self, instance_uuid: str) -> CloudModelCatalogSnapshot:
|
|
||||||
assert instance_uuid == INSTANCE_UUID
|
|
||||||
return self.snapshot
|
|
||||||
|
|
||||||
|
|
||||||
def _snapshot(
|
|
||||||
*,
|
|
||||||
key_a: str | None = 'owner-a-key',
|
|
||||||
model_id: str = 'gpt-test',
|
|
||||||
include_embedding: bool = True,
|
|
||||||
) -> CloudModelCatalogSnapshot:
|
|
||||||
models = [
|
|
||||||
{
|
|
||||||
'uuid': 'upstream-chat',
|
|
||||||
'model_id': model_id,
|
|
||||||
'category': 'chat',
|
|
||||||
'llm_abilities': ['chat', 'vision'],
|
|
||||||
'is_featured': True,
|
|
||||||
'featured_order': 7,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
if include_embedding:
|
|
||||||
models.append(
|
|
||||||
{
|
|
||||||
'uuid': 'upstream-embedding',
|
|
||||||
'model_id': 'embedding-test',
|
|
||||||
'category': 'embedding',
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return CloudModelCatalogSnapshot.model_validate(
|
|
||||||
{
|
|
||||||
'instance_uuid': INSTANCE_UUID,
|
|
||||||
'generated_at': datetime.now(UTC),
|
|
||||||
'base_url': 'https://api.langbot.cloud/v1/',
|
|
||||||
'models': models,
|
|
||||||
'workspaces': [
|
|
||||||
{
|
|
||||||
'workspace_uuid': WORKSPACE_A,
|
|
||||||
'owner_account_uuid': OWNER_A,
|
|
||||||
'api_key': key_a,
|
|
||||||
'credits': 25000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'workspace_uuid': WORKSPACE_B,
|
|
||||||
'owner_account_uuid': OWNER_B,
|
|
||||||
'api_key': 'owner-b-key',
|
|
||||||
'credits': 5000,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_catalog_snapshot_treats_null_model_abilities_as_empty() -> None:
|
|
||||||
payload = _snapshot().model_dump(mode='json')
|
|
||||||
payload['models'][0]['llm_abilities'] = None
|
|
||||||
|
|
||||||
snapshot = CloudModelCatalogSnapshot.model_validate(payload)
|
|
||||||
|
|
||||||
assert snapshot.models[0].llm_abilities == ()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_catalog_reconciles_every_workspace_idempotently_and_tracks_owner_and_downlisting(tmp_path) -> None:
|
|
||||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "model-catalog.db"}')
|
|
||||||
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
|
|
||||||
manager.db = SimpleNamespace(get_engine=lambda: engine)
|
|
||||||
bindings = [
|
|
||||||
SimpleNamespace(instance_uuid=INSTANCE_UUID, workspace_uuid=WORKSPACE_A, placement_generation=1),
|
|
||||||
SimpleNamespace(instance_uuid=INSTANCE_UUID, workspace_uuid=WORKSPACE_B, placement_generation=1),
|
|
||||||
]
|
|
||||||
workspace_service = SimpleNamespace(list_active_execution_bindings=lambda: _async_value(bindings))
|
|
||||||
reload_counter = _AsyncCounter()
|
|
||||||
runtime_reload = SimpleNamespace(load_models_from_db=reload_counter)
|
|
||||||
app = SimpleNamespace(
|
|
||||||
persistence_mgr=manager,
|
|
||||||
workspace_service=workspace_service,
|
|
||||||
model_mgr=runtime_reload,
|
|
||||||
logger=logging.getLogger(__name__),
|
|
||||||
)
|
|
||||||
provider = _CatalogProvider(_snapshot())
|
|
||||||
service = CloudModelCatalogSyncService(app, provider, INSTANCE_UUID)
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with engine.begin() as connection:
|
|
||||||
await connection.run_sync(Base.metadata.create_all)
|
|
||||||
await connection.execute(
|
|
||||||
sqlalchemy.insert(Workspace),
|
|
||||||
[
|
|
||||||
{
|
|
||||||
'uuid': WORKSPACE_A,
|
|
||||||
'instance_uuid': INSTANCE_UUID,
|
|
||||||
'name': 'A',
|
|
||||||
'slug': 'a',
|
|
||||||
'source': 'cloud_projection',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'uuid': WORKSPACE_B,
|
|
||||||
'instance_uuid': INSTANCE_UUID,
|
|
||||||
'name': 'B',
|
|
||||||
'slug': 'b',
|
|
||||||
'source': 'cloud_projection',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
await connection.execute(
|
|
||||||
sqlalchemy.insert(ModelProvider).values(
|
|
||||||
uuid='custom-provider',
|
|
||||||
workspace_uuid=WORKSPACE_A,
|
|
||||||
name='Custom',
|
|
||||||
requester='openai-chat-completions',
|
|
||||||
base_url='https://custom.example/v1',
|
|
||||||
api_keys=['custom-key'],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await connection.execute(
|
|
||||||
sqlalchemy.insert(LLMModel).values(
|
|
||||||
uuid='custom-model',
|
|
||||||
workspace_uuid=WORKSPACE_A,
|
|
||||||
name='custom-model',
|
|
||||||
provider_uuid='custom-provider',
|
|
||||||
abilities=['chat'],
|
|
||||||
extra_args={},
|
|
||||||
prefered_ranking=0,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
first = await service.sync_once()
|
|
||||||
assert first == {'workspaces': 2, 'created': 6, 'updated': 0, 'deleted': 0}
|
|
||||||
assert reload_counter.calls == 1
|
|
||||||
assert service.get_workspace_credits(WORKSPACE_A) == 25000
|
|
||||||
assert service.get_workspace_credits(WORKSPACE_B) == 5000
|
|
||||||
|
|
||||||
async with engine.connect() as connection:
|
|
||||||
providers = (
|
|
||||||
await connection.execute(
|
|
||||||
sqlalchemy.select(
|
|
||||||
ModelProvider.uuid,
|
|
||||||
ModelProvider.workspace_uuid,
|
|
||||||
ModelProvider.api_keys,
|
|
||||||
).where(ModelProvider.requester == 'space-chat-completions')
|
|
||||||
)
|
|
||||||
).all()
|
|
||||||
assert {item.workspace_uuid for item in providers} == {WORKSPACE_A, WORKSPACE_B}
|
|
||||||
assert {item.uuid for item in providers} == {
|
|
||||||
system_provider_uuid(WORKSPACE_A),
|
|
||||||
system_provider_uuid(WORKSPACE_B),
|
|
||||||
}
|
|
||||||
assert {item.workspace_uuid: item.api_keys for item in providers} == {
|
|
||||||
WORKSPACE_A: ['owner-a-key'],
|
|
||||||
WORKSPACE_B: ['owner-b-key'],
|
|
||||||
}
|
|
||||||
assert await connection.scalar(sqlalchemy.select(sqlalchemy.func.count()).select_from(LLMModel)) == 3
|
|
||||||
assert await connection.scalar(sqlalchemy.select(sqlalchemy.func.count()).select_from(EmbeddingModel)) == 2
|
|
||||||
|
|
||||||
second = await service.sync_once()
|
|
||||||
assert second == {'workspaces': 2, 'created': 0, 'updated': 0, 'deleted': 0}
|
|
||||||
assert reload_counter.calls == 1
|
|
||||||
|
|
||||||
provider.snapshot = _snapshot(
|
|
||||||
key_a='new-owner-key',
|
|
||||||
model_id='gpt-renamed',
|
|
||||||
include_embedding=False,
|
|
||||||
)
|
|
||||||
third = await service.sync_once()
|
|
||||||
assert third == {'workspaces': 2, 'created': 0, 'updated': 3, 'deleted': 2}
|
|
||||||
assert reload_counter.calls == 2
|
|
||||||
|
|
||||||
async with engine.connect() as connection:
|
|
||||||
provider_a_keys = await connection.scalar(
|
|
||||||
sqlalchemy.select(ModelProvider.api_keys).where(ModelProvider.uuid == system_provider_uuid(WORKSPACE_A))
|
|
||||||
)
|
|
||||||
assert provider_a_keys == ['new-owner-key']
|
|
||||||
system_model_names = (
|
|
||||||
(
|
|
||||||
await connection.execute(
|
|
||||||
sqlalchemy.select(LLMModel.name).where(
|
|
||||||
LLMModel.provider_uuid.in_(
|
|
||||||
[system_provider_uuid(WORKSPACE_A), system_provider_uuid(WORKSPACE_B)]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.scalars()
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
assert set(system_model_names) == {'gpt-renamed'}
|
|
||||||
assert await connection.scalar(sqlalchemy.select(sqlalchemy.func.count()).select_from(EmbeddingModel)) == 0
|
|
||||||
assert (
|
|
||||||
await connection.scalar(
|
|
||||||
sqlalchemy.select(sqlalchemy.func.count())
|
|
||||||
.select_from(ModelProvider)
|
|
||||||
.where(ModelProvider.uuid == 'custom-provider')
|
|
||||||
)
|
|
||||||
== 1
|
|
||||||
)
|
|
||||||
assert (
|
|
||||||
await connection.scalar(
|
|
||||||
sqlalchemy.select(sqlalchemy.func.count())
|
|
||||||
.select_from(LLMModel)
|
|
||||||
.where(LLMModel.uuid == 'custom-model')
|
|
||||||
)
|
|
||||||
== 1
|
|
||||||
)
|
|
||||||
|
|
||||||
provider.snapshot = _snapshot(key_a=None, model_id='gpt-renamed', include_embedding=False)
|
|
||||||
fourth = await service.sync_once()
|
|
||||||
assert fourth == {'workspaces': 2, 'created': 0, 'updated': 1, 'deleted': 0}
|
|
||||||
assert reload_counter.calls == 3
|
|
||||||
async with engine.connect() as connection:
|
|
||||||
provider_a_keys = await connection.scalar(
|
|
||||||
sqlalchemy.select(ModelProvider.api_keys).where(ModelProvider.uuid == system_provider_uuid(WORKSPACE_A))
|
|
||||||
)
|
|
||||||
assert provider_a_keys == []
|
|
||||||
finally:
|
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
def test_workspace_scoped_ids_are_stable_and_secrets_are_redacted() -> None:
|
|
||||||
assert system_provider_uuid(WORKSPACE_A) == system_provider_uuid(WORKSPACE_A)
|
|
||||||
assert system_provider_uuid(WORKSPACE_A) != system_provider_uuid(WORKSPACE_B)
|
|
||||||
assert system_model_uuid(WORKSPACE_A, 'chat', 'upstream') != system_model_uuid(WORKSPACE_B, 'chat', 'upstream')
|
|
||||||
snapshot = _snapshot()
|
|
||||||
assert 'owner-a-key' not in repr(snapshot)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_snapshot_must_cover_every_active_workspace() -> None:
|
|
||||||
snapshot = _snapshot().model_copy(update={'workspaces': _snapshot().workspaces[:1]})
|
|
||||||
app = SimpleNamespace(
|
|
||||||
workspace_service=SimpleNamespace(
|
|
||||||
list_active_execution_bindings=lambda: _async_value(
|
|
||||||
[SimpleNamespace(workspace_uuid=WORKSPACE_A), SimpleNamespace(workspace_uuid=WORKSPACE_B)]
|
|
||||||
)
|
|
||||||
),
|
|
||||||
logger=logging.getLogger(__name__),
|
|
||||||
)
|
|
||||||
service = CloudModelCatalogSyncService(app, _CatalogProvider(snapshot), INSTANCE_UUID)
|
|
||||||
with pytest.raises(ValueError, match='missing billing projections for 1 active Workspaces'):
|
|
||||||
await service.sync_once()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_periodic_sync_discovers_workspace_created_after_startup_cache_release(tmp_path) -> None:
|
|
||||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "model-catalog-new-workspace.db"}')
|
|
||||||
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
|
|
||||||
manager.db = SimpleNamespace(get_engine=lambda: engine)
|
|
||||||
startup_bindings = [
|
|
||||||
SimpleNamespace(instance_uuid=INSTANCE_UUID, workspace_uuid=WORKSPACE_A, placement_generation=1)
|
|
||||||
]
|
|
||||||
live_bindings = [
|
|
||||||
*startup_bindings,
|
|
||||||
SimpleNamespace(instance_uuid=INSTANCE_UUID, workspace_uuid=WORKSPACE_B, placement_generation=1),
|
|
||||||
]
|
|
||||||
|
|
||||||
class _WorkspaceService:
|
|
||||||
startup_released = False
|
|
||||||
|
|
||||||
async def list_active_execution_bindings(self):
|
|
||||||
return list(live_bindings if self.startup_released else startup_bindings)
|
|
||||||
|
|
||||||
def release_startup_execution_bindings(self):
|
|
||||||
self.startup_released = True
|
|
||||||
|
|
||||||
workspace_service = _WorkspaceService()
|
|
||||||
app = SimpleNamespace(
|
|
||||||
persistence_mgr=manager,
|
|
||||||
workspace_service=workspace_service,
|
|
||||||
model_mgr=SimpleNamespace(load_models_from_db=_AsyncCounter()),
|
|
||||||
logger=logging.getLogger(__name__),
|
|
||||||
)
|
|
||||||
service = CloudModelCatalogSyncService(app, _CatalogProvider(_snapshot()), INSTANCE_UUID)
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with engine.begin() as connection:
|
|
||||||
await connection.run_sync(Base.metadata.create_all)
|
|
||||||
await connection.execute(
|
|
||||||
sqlalchemy.insert(Workspace),
|
|
||||||
[
|
|
||||||
{
|
|
||||||
'uuid': WORKSPACE_A,
|
|
||||||
'instance_uuid': INSTANCE_UUID,
|
|
||||||
'name': 'A',
|
|
||||||
'slug': 'a',
|
|
||||||
'source': 'cloud_projection',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'uuid': WORKSPACE_B,
|
|
||||||
'instance_uuid': INSTANCE_UUID,
|
|
||||||
'name': 'B',
|
|
||||||
'slug': 'b',
|
|
||||||
'source': 'cloud_projection',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
await service.initialize()
|
|
||||||
workspace_service.release_startup_execution_bindings()
|
|
||||||
await service.sync_once()
|
|
||||||
|
|
||||||
async with engine.connect() as connection:
|
|
||||||
provider_b = await connection.scalar(
|
|
||||||
sqlalchemy.select(ModelProvider).where(ModelProvider.uuid == system_provider_uuid(WORKSPACE_B))
|
|
||||||
)
|
|
||||||
assert provider_b is not None
|
|
||||||
finally:
|
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_catalog_run_wakes_immediately_when_directory_changes() -> None:
|
|
||||||
sync_started = asyncio.Event()
|
|
||||||
|
|
||||||
class _WakeService(CloudModelCatalogSyncService):
|
|
||||||
async def sync_once(self, *, reload_runtime: bool = True):
|
|
||||||
del reload_runtime
|
|
||||||
sync_started.set()
|
|
||||||
return {'workspaces': 0, 'created': 0, 'updated': 0, 'deleted': 0}
|
|
||||||
|
|
||||||
app = SimpleNamespace(logger=logging.getLogger(__name__))
|
|
||||||
service = _WakeService(app, _CatalogProvider(_snapshot()), INSTANCE_UUID, sync_interval_seconds=3600)
|
|
||||||
task = asyncio.create_task(service.run())
|
|
||||||
try:
|
|
||||||
await asyncio.sleep(0)
|
|
||||||
service.request_sync()
|
|
||||||
await asyncio.wait_for(sync_started.wait(), timeout=0.2)
|
|
||||||
finally:
|
|
||||||
task.cancel()
|
|
||||||
with pytest.raises(asyncio.CancelledError):
|
|
||||||
await task
|
|
||||||
|
|
||||||
|
|
||||||
async def _async_value(value):
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class _AsyncCounter:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.calls = 0
|
|
||||||
|
|
||||||
async def __call__(self) -> None:
|
|
||||||
self.calls += 1
|
|
||||||
|
|
||||||
|
|
||||||
async def test_partial_workspace_failure_reloads_already_committed_changes() -> None:
|
|
||||||
bindings = [
|
|
||||||
SimpleNamespace(workspace_uuid=WORKSPACE_A),
|
|
||||||
SimpleNamespace(workspace_uuid=WORKSPACE_B),
|
|
||||||
]
|
|
||||||
reload_counter = _AsyncCounter()
|
|
||||||
app = SimpleNamespace(
|
|
||||||
workspace_service=SimpleNamespace(list_active_execution_bindings=lambda: _async_value(bindings)),
|
|
||||||
model_mgr=SimpleNamespace(load_models_from_db=reload_counter),
|
|
||||||
logger=logging.getLogger(__name__),
|
|
||||||
)
|
|
||||||
service = CloudModelCatalogSyncService(app, _CatalogProvider(_snapshot()), INSTANCE_UUID)
|
|
||||||
calls = 0
|
|
||||||
|
|
||||||
async def sync_workspace(*_args):
|
|
||||||
nonlocal calls
|
|
||||||
calls += 1
|
|
||||||
if calls == 1:
|
|
||||||
return {'created': 1, 'updated': 0, 'deleted': 0}
|
|
||||||
raise RuntimeError('second Workspace failed')
|
|
||||||
|
|
||||||
service._sync_workspace = sync_workspace # type: ignore[method-assign]
|
|
||||||
|
|
||||||
with pytest.raises(RuntimeError, match='second Workspace failed'):
|
|
||||||
await service.sync_once()
|
|
||||||
assert service.get_workspace_credits(WORKSPACE_A) == 25000
|
|
||||||
assert service.get_workspace_credits(WORKSPACE_B) is None
|
|
||||||
assert reload_counter.calls == 1
|
|
||||||
|
|
||||||
|
|
||||||
async def test_failed_runtime_reload_is_retried_after_noop_sync() -> None:
|
|
||||||
bindings = [SimpleNamespace(workspace_uuid=WORKSPACE_A)]
|
|
||||||
|
|
||||||
class _FlakyReload:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.calls = 0
|
|
||||||
|
|
||||||
async def __call__(self) -> None:
|
|
||||||
self.calls += 1
|
|
||||||
if self.calls == 1:
|
|
||||||
raise RuntimeError('reload failed')
|
|
||||||
|
|
||||||
runtime_reload = _FlakyReload()
|
|
||||||
app = SimpleNamespace(
|
|
||||||
workspace_service=SimpleNamespace(list_active_execution_bindings=lambda: _async_value(bindings)),
|
|
||||||
model_mgr=SimpleNamespace(load_models_from_db=runtime_reload),
|
|
||||||
logger=logging.getLogger(__name__),
|
|
||||||
)
|
|
||||||
service = CloudModelCatalogSyncService(app, _CatalogProvider(_snapshot()), INSTANCE_UUID)
|
|
||||||
calls = 0
|
|
||||||
|
|
||||||
async def sync_workspace(*_args):
|
|
||||||
nonlocal calls
|
|
||||||
calls += 1
|
|
||||||
if calls == 1:
|
|
||||||
return {'created': 1, 'updated': 0, 'deleted': 0}
|
|
||||||
return {'created': 0, 'updated': 0, 'deleted': 0}
|
|
||||||
|
|
||||||
service._sync_workspace = sync_workspace # type: ignore[method-assign]
|
|
||||||
|
|
||||||
with pytest.raises(RuntimeError, match='reload failed'):
|
|
||||||
await service.sync_once()
|
|
||||||
summary = await service.sync_once()
|
|
||||||
assert summary == {'workspaces': 1, 'created': 0, 'updated': 0, 'deleted': 0}
|
|
||||||
assert runtime_reload.calls == 2
|
|
||||||
|
|
||||||
|
|
||||||
async def test_background_sync_log_redacts_exception_message(caplog) -> None:
|
|
||||||
secret = 'owner-secret-api-key'
|
|
||||||
attempted = asyncio.Event()
|
|
||||||
|
|
||||||
class _FailingProvider:
|
|
||||||
async def fetch_model_catalog(self, instance_uuid: str) -> CloudModelCatalogSnapshot:
|
|
||||||
del instance_uuid
|
|
||||||
attempted.set()
|
|
||||||
raise RuntimeError(f'database parameters include {secret}')
|
|
||||||
|
|
||||||
app = SimpleNamespace(logger=logging.getLogger(__name__))
|
|
||||||
service = CloudModelCatalogSyncService(app, _FailingProvider(), INSTANCE_UUID)
|
|
||||||
service.sync_interval_seconds = 0.001
|
|
||||||
task = asyncio.create_task(service.run())
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(attempted.wait(), timeout=1)
|
|
||||||
await asyncio.sleep(0.01)
|
|
||||||
finally:
|
|
||||||
task.cancel()
|
|
||||||
with pytest.raises(asyncio.CancelledError):
|
|
||||||
await task
|
|
||||||
|
|
||||||
assert secret not in caplog.text
|
|
||||||
assert 'Cloud model catalog synchronization failed (RuntimeError)' in caplog.text
|
|
||||||
@@ -11,7 +11,6 @@ from cryptography.hazmat.primitives import serialization
|
|||||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
|
|
||||||
from langbot.pkg.cloud.launch import SpaceLaunchError, SpaceLaunchService
|
from langbot.pkg.cloud.launch import SpaceLaunchError, SpaceLaunchService
|
||||||
from langbot.pkg.cloud.support_admin import SupportAdminReplayError
|
|
||||||
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.asyncio
|
pytestmark = pytest.mark.asyncio
|
||||||
@@ -58,21 +57,9 @@ def _service(private_key: Ed25519PrivateKey, *, now: int) -> SpaceLaunchService:
|
|||||||
encoding=serialization.Encoding.Raw,
|
encoding=serialization.Encoding.Raw,
|
||||||
format=serialization.PublicFormat.Raw,
|
format=serialization.PublicFormat.Raw,
|
||||||
)
|
)
|
||||||
consumed: set[str] = set()
|
|
||||||
|
|
||||||
class DurableSupportAdminService:
|
|
||||||
async def consume_launch_grant(self, **kwargs):
|
|
||||||
grant_hash = kwargs['grant_jti_hash']
|
|
||||||
if grant_hash in consumed:
|
|
||||||
raise SupportAdminReplayError('already consumed')
|
|
||||||
consumed.add(grant_hash)
|
|
||||||
return SimpleNamespace(token='support-admin-token')
|
|
||||||
|
|
||||||
app = SimpleNamespace(
|
app = SimpleNamespace(
|
||||||
deployment=SimpleNamespace(multi_workspace_enabled=True, verification_key_id=KEY_ID),
|
deployment=SimpleNamespace(multi_workspace_enabled=True, verification_key_id=KEY_ID),
|
||||||
workspace_service=SimpleNamespace(instance_uuid=INSTANCE_UUID),
|
workspace_service=SimpleNamespace(instance_uuid=INSTANCE_UUID),
|
||||||
logger=SimpleNamespace(info=lambda *args, **kwargs: None),
|
|
||||||
support_admin_session_service=DurableSupportAdminService(),
|
|
||||||
instance_config=SimpleNamespace(
|
instance_config=SimpleNamespace(
|
||||||
data={
|
data={
|
||||||
'space': {
|
'space': {
|
||||||
@@ -99,81 +86,6 @@ async def test_consumes_valid_workspace_launch_assertion_once():
|
|||||||
await service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID)
|
await service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def test_consumes_admin_owner_launch_once_and_validates_claims():
|
|
||||||
private_key = Ed25519PrivateKey.generate()
|
|
||||||
now = int(time.time())
|
|
||||||
service = _service(private_key, now=now)
|
|
||||||
claims = _claims(now=now)
|
|
||||||
claims['kind'] = 'workspace.support_admin_launch'
|
|
||||||
claims['payload'].update(
|
|
||||||
{
|
|
||||||
'launch_mode': 'support_admin',
|
|
||||||
'principal_type': 'support_admin',
|
|
||||||
'actor_account_uuid': '33333333-3333-4333-8333-333333333333',
|
|
||||||
'effective_role': 'owner',
|
|
||||||
}
|
|
||||||
)
|
|
||||||
claims['payload'].pop('account_uuid')
|
|
||||||
token = _sign(private_key, claims)
|
|
||||||
|
|
||||||
launch = await service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID)
|
|
||||||
|
|
||||||
assert launch == {
|
|
||||||
'workspace_uuid': WORKSPACE_UUID,
|
|
||||||
'launch_mode': 'support_admin',
|
|
||||||
'actor_account_uuid': '33333333-3333-4333-8333-333333333333',
|
|
||||||
'effective_role': 'owner',
|
|
||||||
'grant_jti_hash': launch['grant_jti_hash'],
|
|
||||||
'support_admin_token': 'support-admin-token',
|
|
||||||
}
|
|
||||||
with pytest.raises(SpaceLaunchError, match='already been consumed'):
|
|
||||||
await service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID)
|
|
||||||
|
|
||||||
invalid = _claims(now=now)
|
|
||||||
invalid['kind'] = 'workspace.support_admin_launch'
|
|
||||||
invalid['payload'].update(
|
|
||||||
{
|
|
||||||
'launch_mode': 'support_admin',
|
|
||||||
'principal_type': 'support_admin',
|
|
||||||
'actor_account_uuid': '33333333-3333-4333-8333-333333333333',
|
|
||||||
'effective_role': 'member',
|
|
||||||
}
|
|
||||||
)
|
|
||||||
invalid['payload'].pop('account_uuid')
|
|
||||||
with pytest.raises(SpaceLaunchError, match='effective role'):
|
|
||||||
await service.consume_assertion(_sign(private_key, invalid), expected_workspace_uuid=WORKSPACE_UUID)
|
|
||||||
|
|
||||||
too_long = _claims(now=now)
|
|
||||||
too_long['kind'] = 'workspace.support_admin_launch'
|
|
||||||
too_long['exp'] = now + 91
|
|
||||||
too_long['payload'].update(
|
|
||||||
{
|
|
||||||
'launch_mode': 'support_admin',
|
|
||||||
'principal_type': 'support_admin',
|
|
||||||
'actor_account_uuid': '33333333-3333-4333-8333-333333333333',
|
|
||||||
'effective_role': 'owner',
|
|
||||||
}
|
|
||||||
)
|
|
||||||
too_long['payload'].pop('account_uuid')
|
|
||||||
with pytest.raises(SpaceLaunchError, match='lifetime exceeds 90 seconds'):
|
|
||||||
await service.consume_assertion(_sign(private_key, too_long), expected_workspace_uuid=WORKSPACE_UUID)
|
|
||||||
|
|
||||||
impersonating = _claims(now=now)
|
|
||||||
impersonating['kind'] = 'workspace.support_admin_launch'
|
|
||||||
impersonating['payload'].update(
|
|
||||||
{
|
|
||||||
'launch_mode': 'support_admin',
|
|
||||||
'principal_type': 'support_admin',
|
|
||||||
'actor_account_uuid': '33333333-3333-4333-8333-333333333333',
|
|
||||||
'effective_role': 'owner',
|
|
||||||
}
|
|
||||||
)
|
|
||||||
with pytest.raises(SpaceLaunchError, match='customer Account'):
|
|
||||||
await service.consume_assertion(_sign(private_key, impersonating), expected_workspace_uuid=WORKSPACE_UUID)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_replay_cache_does_not_scan_all_live_assertions(monkeypatch):
|
async def test_replay_cache_does_not_scan_all_live_assertions(monkeypatch):
|
||||||
private_key = Ed25519PrivateKey.generate()
|
private_key = Ed25519PrivateKey.generate()
|
||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
|
|||||||
@@ -152,19 +152,6 @@ class TestApplyEnvOverridesToConfig:
|
|||||||
|
|
||||||
assert result['system']['disabled_adapters'] == ['aiocqhttp', 'dingtalk', 'telegram']
|
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):
|
def test_override_list_value_empty_items(self):
|
||||||
"""Test that empty items in comma-separated list are filtered."""
|
"""Test that empty items in comma-separated list are filtered."""
|
||||||
load_config = get_load_config_module()
|
load_config = get_load_config_module()
|
||||||
@@ -319,7 +306,6 @@ class TestApplyEnvOverridesToConfig:
|
|||||||
load_config = get_load_config_module()
|
load_config = get_load_config_module()
|
||||||
cfg = {
|
cfg = {
|
||||||
'plugin': {
|
'plugin': {
|
||||||
'connect_timeout_seconds': 30.0,
|
|
||||||
'worker': {
|
'worker': {
|
||||||
'max_cpus': 1.0,
|
'max_cpus': 1.0,
|
||||||
'max_memory_mb': 512,
|
'max_memory_mb': 512,
|
||||||
@@ -330,12 +316,11 @@ class TestApplyEnvOverridesToConfig:
|
|||||||
'restart_failure_threshold': 8,
|
'restart_failure_threshold': 8,
|
||||||
'restart_failure_window_seconds': 30.0,
|
'restart_failure_window_seconds': 30.0,
|
||||||
'restart_circuit_open_seconds': 60.0,
|
'restart_circuit_open_seconds': 60.0,
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
'mcp': {'stdio': {'enabled': True}},
|
'mcp': {'stdio': {'enabled': True}},
|
||||||
}
|
}
|
||||||
env = {
|
env = {
|
||||||
'PLUGIN__CONNECT_TIMEOUT_SECONDS': '180',
|
|
||||||
'PLUGIN__WORKER__MAX_CPUS': '2.5',
|
'PLUGIN__WORKER__MAX_CPUS': '2.5',
|
||||||
'PLUGIN__WORKER__MAX_MEMORY_MB': '1024',
|
'PLUGIN__WORKER__MAX_MEMORY_MB': '1024',
|
||||||
'PLUGIN__WORKER__MAX_PIDS': '64',
|
'PLUGIN__WORKER__MAX_PIDS': '64',
|
||||||
@@ -351,7 +336,6 @@ class TestApplyEnvOverridesToConfig:
|
|||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
result = load_config._apply_env_overrides_to_config(cfg)
|
result = load_config._apply_env_overrides_to_config(cfg)
|
||||||
|
|
||||||
assert result['plugin']['connect_timeout_seconds'] == 180.0
|
|
||||||
assert result['plugin']['worker'] == {
|
assert result['plugin']['worker'] == {
|
||||||
'max_cpus': 2.5,
|
'max_cpus': 2.5,
|
||||||
'max_memory_mb': 1024,
|
'max_memory_mb': 1024,
|
||||||
@@ -396,14 +380,6 @@ class TestApplyEnvOverridesToConfig:
|
|||||||
assert isinstance(result['plugin']['worker']['max_memory_mb'], int)
|
assert isinstance(result['plugin']['worker']['max_memory_mb'], int)
|
||||||
assert result['mcp']['stdio']['enabled'] is False
|
assert result['mcp']['stdio']['enabled'] is False
|
||||||
|
|
||||||
def test_runtime_policy_defaults_add_typed_plugin_connect_timeout(self):
|
|
||||||
load_config = get_load_config_module()
|
|
||||||
|
|
||||||
completed = load_config._complete_runtime_policy_defaults({'plugin': {'enable': True}})
|
|
||||||
|
|
||||||
assert completed['plugin']['connect_timeout_seconds'] == 180.0
|
|
||||||
assert isinstance(completed['plugin']['connect_timeout_seconds'], float)
|
|
||||||
|
|
||||||
def test_webhook_prefix_override(self):
|
def test_webhook_prefix_override(self):
|
||||||
"""Test overriding webhook_prefix via environment variable."""
|
"""Test overriding webhook_prefix via environment variable."""
|
||||||
load_config = get_load_config_module()
|
load_config = get_load_config_module()
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from types import SimpleNamespace
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from pgvector.sqlalchemy import HALFVEC, Vector
|
from pgvector.sqlalchemy import Vector
|
||||||
from sqlalchemy.dialects.postgresql import insert as postgresql_insert
|
from sqlalchemy.dialects.postgresql import insert as postgresql_insert
|
||||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
@@ -961,13 +961,11 @@ async def test_scoped_session_rejects_raw_or_unapproved_sql(
|
|||||||
sa.select(sa.func.coalesce(sa.func.sum(sa.literal(1)), sa.literal(0))),
|
sa.select(sa.func.coalesce(sa.func.sum(sa.literal(1)), sa.literal(0))),
|
||||||
sa.select(
|
sa.select(
|
||||||
sa.func.now(),
|
sa.func.now(),
|
||||||
sa.func.date_trunc('hour', sa.column('timestamp')),
|
|
||||||
sa.func.length(sa.literal('value')),
|
sa.func.length(sa.literal('value')),
|
||||||
sa.func.nullif(sa.literal('value'), sa.literal('')),
|
sa.func.nullif(sa.literal('value'), sa.literal('')),
|
||||||
),
|
),
|
||||||
sa.select(sa.column('embedding').op('<=>')(sa.literal([0.1]))),
|
sa.select(sa.column('embedding').op('<=>')(sa.literal([0.1]))),
|
||||||
sa.select(sa.cast(sa.column('embedding'), Vector(384))),
|
sa.select(sa.cast(sa.column('embedding'), Vector(384))),
|
||||||
sa.select(sa.cast(sa.column('embedding'), HALFVEC(3072))),
|
|
||||||
sa.insert(sa.table('rows', sa.column('id'))).values(id=1),
|
sa.insert(sa.table('rows', sa.column('id'))).values(id=1),
|
||||||
_multi_value_statement(value=1),
|
_multi_value_statement(value=1),
|
||||||
_on_conflict_statement(update_value=sa.func.coalesce(sa.literal(1), sa.literal(0))),
|
_on_conflict_statement(update_value=sa.func.coalesce(sa.literal(1), sa.literal(0))),
|
||||||
|
|||||||
@@ -158,21 +158,17 @@ class TestCommandHandlerReal:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_admin_privilege_check(self, fake_app, mock_event_ctx, mock_execute_factory):
|
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
|
from langbot_plugin.api.entities.builtin.provider.session import LauncherTypes
|
||||||
|
|
||||||
command = get_command_handler()
|
command = get_command_handler()
|
||||||
|
|
||||||
admin_result = Mock()
|
fake_app.instance_config.data = {'admins': ['person_12345']}
|
||||||
admin_result.first.return_value = Mock()
|
|
||||||
fake_app.persistence_mgr.execute_async = AsyncMock(return_value=admin_result)
|
|
||||||
fake_app.instance_config.data = {}
|
|
||||||
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||||
fake_app.cmd_mgr.execute = mock_execute_factory()
|
fake_app.cmd_mgr.execute = mock_execute_factory()
|
||||||
|
|
||||||
handler = command.CommandHandler(fake_app)
|
handler = command.CommandHandler(fake_app)
|
||||||
query = command_query('status')
|
query = command_query('status')
|
||||||
query.bot_uuid = 'bot-1'
|
|
||||||
query.launcher_type = LauncherTypes.PERSON
|
query.launcher_type = LauncherTypes.PERSON
|
||||||
query.launcher_id = 12345
|
query.launcher_id = 12345
|
||||||
|
|
||||||
@@ -180,28 +176,23 @@ class TestCommandHandlerReal:
|
|||||||
async for result in handler.handle(query):
|
async for result in handler.handle(query):
|
||||||
results.append(result)
|
results.append(result)
|
||||||
|
|
||||||
fake_app.persistence_mgr.execute_async.assert_awaited_once()
|
|
||||||
call_args = fake_app.plugin_connector.emit_event.call_args
|
call_args = fake_app.plugin_connector.emit_event.call_args
|
||||||
event = call_args[0][0]
|
event = call_args[0][0]
|
||||||
assert event.is_admin is True
|
assert event.is_admin is True
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_non_admin_privilege_check(self, fake_app, mock_event_ctx, mock_execute_factory):
|
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
|
from langbot_plugin.api.entities.builtin.provider.session import LauncherTypes
|
||||||
|
|
||||||
command = get_command_handler()
|
command = get_command_handler()
|
||||||
|
|
||||||
admin_result = Mock()
|
fake_app.instance_config.data = {'admins': ['person_12345']}
|
||||||
admin_result.first.return_value = None
|
|
||||||
fake_app.persistence_mgr.execute_async = AsyncMock(return_value=admin_result)
|
|
||||||
fake_app.instance_config.data = {}
|
|
||||||
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||||
fake_app.cmd_mgr.execute = mock_execute_factory()
|
fake_app.cmd_mgr.execute = mock_execute_factory()
|
||||||
|
|
||||||
handler = command.CommandHandler(fake_app)
|
handler = command.CommandHandler(fake_app)
|
||||||
query = command_query('status')
|
query = command_query('status')
|
||||||
query.bot_uuid = 'bot-1'
|
|
||||||
query.launcher_type = LauncherTypes.PERSON
|
query.launcher_type = LauncherTypes.PERSON
|
||||||
query.launcher_id = 67890
|
query.launcher_id = 67890
|
||||||
|
|
||||||
@@ -209,7 +200,6 @@ class TestCommandHandlerReal:
|
|||||||
async for result in handler.handle(query):
|
async for result in handler.handle(query):
|
||||||
results.append(result)
|
results.append(result)
|
||||||
|
|
||||||
fake_app.persistence_mgr.execute_async.assert_awaited_once()
|
|
||||||
call_args = fake_app.plugin_connector.emit_event.call_args
|
call_args = fake_app.plugin_connector.emit_event.call_args
|
||||||
event = call_args[0][0]
|
event = call_args[0][0]
|
||||||
assert event.is_admin is False
|
assert event.is_admin is False
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ from sqlalchemy.ext.asyncio import create_async_engine
|
|||||||
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
|
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
|
||||||
from langbot.pkg.persistence.tenant_uow import PersistenceScopeKind
|
from langbot.pkg.persistence.tenant_uow import PersistenceScopeKind
|
||||||
from langbot.pkg.pipeline.controller import Controller
|
from langbot.pkg.pipeline.controller import Controller
|
||||||
from langbot.pkg.pipeline.pool import QueryPool
|
|
||||||
from langbot.pkg.workspace.errors import WorkspaceGenerationMismatchError
|
from langbot.pkg.workspace.errors import WorkspaceGenerationMismatchError
|
||||||
|
|
||||||
|
|
||||||
@@ -144,31 +143,3 @@ async def test_controller_revalidates_generation_before_running_pipeline(
|
|||||||
runtime_pipeline.run.assert_awaited_once_with(sample_query)
|
runtime_pipeline.run.assert_awaited_once_with(sample_query)
|
||||||
query_pool.remove_query.assert_awaited_once_with(sample_query)
|
query_pool.remove_query.assert_awaited_once_with(sample_query)
|
||||||
session._semaphore.release.assert_called_once_with()
|
session._semaphore.release.assert_called_once_with()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_controller_schedules_query_without_removing_it_twice(mock_app, sample_query):
|
|
||||||
query_pool = QueryPool()
|
|
||||||
query_pool.queries.append(sample_query)
|
|
||||||
mock_app.query_pool = query_pool
|
|
||||||
mock_app.sess_mgr.get_session = AsyncMock(return_value=SimpleNamespace(_semaphore=asyncio.Semaphore(1)))
|
|
||||||
|
|
||||||
scheduler_errors: list[str] = []
|
|
||||||
|
|
||||||
def stop_on_scheduler_error(message):
|
|
||||||
scheduler_errors.append(str(message))
|
|
||||||
raise asyncio.CancelledError
|
|
||||||
|
|
||||||
def stop_after_scheduling(process_coro, **_kwargs):
|
|
||||||
process_coro.close()
|
|
||||||
raise asyncio.CancelledError
|
|
||||||
|
|
||||||
mock_app.logger.error.side_effect = stop_on_scheduler_error
|
|
||||||
mock_app.task_mgr.create_task.side_effect = stop_after_scheduling
|
|
||||||
controller = Controller(mock_app)
|
|
||||||
|
|
||||||
with pytest.raises(asyncio.CancelledError):
|
|
||||||
await controller.consumer()
|
|
||||||
|
|
||||||
assert scheduler_errors == []
|
|
||||||
assert query_pool.queries == []
|
|
||||||
|
|||||||
@@ -488,115 +488,3 @@ class TestPreProcessorToolSelection:
|
|||||||
result = await stage.process(query, 'PreProcessor')
|
result = await stage.process(query, 'PreProcessor')
|
||||||
|
|
||||||
assert [tool.name for tool in result.new_query.use_funcs] == ['plugin_tool']
|
assert [tool.name for tool in result.new_query.use_funcs] == ['plugin_tool']
|
||||||
|
|
||||||
|
|
||||||
class TestPreProcessorDateGrounding:
|
|
||||||
"""Tests for current-date injection into the local-agent system prompt."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_local_agent_appends_date_to_existing_system_message(self):
|
|
||||||
"""Date grounding text should be appended to an existing system prompt."""
|
|
||||||
preproc = get_preproc_module()
|
|
||||||
|
|
||||||
app = FakeApp()
|
|
||||||
mock_session = make_session()
|
|
||||||
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
|
|
||||||
|
|
||||||
mock_conversation = Mock()
|
|
||||||
mock_conversation.prompt = Mock(messages=[])
|
|
||||||
mock_conversation.prompt.copy = Mock(return_value=Mock(messages=[]))
|
|
||||||
mock_conversation.messages = []
|
|
||||||
mock_conversation.uuid = None
|
|
||||||
app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation)
|
|
||||||
|
|
||||||
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=None)
|
|
||||||
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
|
|
||||||
|
|
||||||
from langbot_plugin.api.entities.builtin.provider import message as provider_message
|
|
||||||
|
|
||||||
system_message = provider_message.Message(role='system', content='You are a helpful assistant.')
|
|
||||||
mock_event_ctx = Mock()
|
|
||||||
mock_event_ctx.event = Mock(default_prompt=[system_message], prompt=[])
|
|
||||||
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
|
||||||
|
|
||||||
stage = preproc.PreProcessor(app)
|
|
||||||
query = text_query('hello')
|
|
||||||
|
|
||||||
result = await stage.process(query, 'PreProcessor')
|
|
||||||
|
|
||||||
messages = result.new_query.prompt.messages
|
|
||||||
assert len(messages) == 1
|
|
||||||
assert messages[0].role == 'system'
|
|
||||||
assert messages[0].content.startswith('You are a helpful assistant.')
|
|
||||||
assert 'Current date:' in messages[0].content
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_local_agent_creates_system_message_when_none_exists(self):
|
|
||||||
"""A system message should be created when the prompt has none."""
|
|
||||||
preproc = get_preproc_module()
|
|
||||||
|
|
||||||
app = FakeApp()
|
|
||||||
mock_session = make_session()
|
|
||||||
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
|
|
||||||
|
|
||||||
mock_conversation = Mock()
|
|
||||||
mock_conversation.prompt = Mock(messages=[])
|
|
||||||
mock_conversation.prompt.copy = Mock(return_value=Mock(messages=[]))
|
|
||||||
mock_conversation.messages = []
|
|
||||||
mock_conversation.uuid = None
|
|
||||||
app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation)
|
|
||||||
|
|
||||||
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=None)
|
|
||||||
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
|
|
||||||
|
|
||||||
mock_event_ctx = Mock()
|
|
||||||
mock_event_ctx.event = Mock(default_prompt=[], prompt=[])
|
|
||||||
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
|
||||||
|
|
||||||
stage = preproc.PreProcessor(app)
|
|
||||||
query = text_query('hello')
|
|
||||||
|
|
||||||
result = await stage.process(query, 'PreProcessor')
|
|
||||||
|
|
||||||
messages = result.new_query.prompt.messages
|
|
||||||
assert len(messages) == 1
|
|
||||||
assert messages[0].role == 'system'
|
|
||||||
assert 'Current date:' in messages[0].content
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_non_local_agent_runner_skips_date_injection(self):
|
|
||||||
"""Runners other than local-agent should not get the date addition."""
|
|
||||||
preproc = get_preproc_module()
|
|
||||||
|
|
||||||
app = FakeApp()
|
|
||||||
mock_session = make_session()
|
|
||||||
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
|
|
||||||
|
|
||||||
mock_conversation = Mock()
|
|
||||||
mock_conversation.prompt = Mock(messages=[])
|
|
||||||
mock_conversation.prompt.copy = Mock(return_value=Mock(messages=[]))
|
|
||||||
mock_conversation.messages = []
|
|
||||||
mock_conversation.uuid = None
|
|
||||||
app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation)
|
|
||||||
|
|
||||||
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=None)
|
|
||||||
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
|
|
||||||
|
|
||||||
mock_event_ctx = Mock()
|
|
||||||
mock_event_ctx.event = Mock(default_prompt=[], prompt=[])
|
|
||||||
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
|
||||||
|
|
||||||
stage = preproc.PreProcessor(app)
|
|
||||||
query = text_query('hello')
|
|
||||||
query.pipeline_config = {
|
|
||||||
'ai': {
|
|
||||||
'runner': {'runner': 'dify-service-api'},
|
|
||||||
'local-agent': {'model': {'primary': '', 'fallbacks': []}, 'prompt': 'default'},
|
|
||||||
},
|
|
||||||
'output': {'misc': {'at-sender': False}},
|
|
||||||
'trigger': {'misc': {}},
|
|
||||||
}
|
|
||||||
|
|
||||||
result = await stage.process(query, 'PreProcessor')
|
|
||||||
|
|
||||||
assert result.new_query.prompt.messages == []
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
"""Tests for Telegram Dify form callback helpers."""
|
"""Tests for Telegram Dify form callback helpers."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from contextlib import asynccontextmanager
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from telegram import ForceReply
|
from telegram import ForceReply
|
||||||
@@ -10,10 +9,8 @@ from telegram import ForceReply
|
|||||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||||
|
|
||||||
from langbot.pkg.platform.sources.telegram import (
|
from langbot.pkg.platform.sources.telegram import (
|
||||||
TelegramAdapter,
|
TelegramAdapter,
|
||||||
TelegramMessageConverter,
|
|
||||||
_decode_telegram_base64_limited,
|
_decode_telegram_base64_limited,
|
||||||
_telegram_form_action_from_callback,
|
_telegram_form_action_from_callback,
|
||||||
_telegram_select_field_options,
|
_telegram_select_field_options,
|
||||||
@@ -29,70 +26,6 @@ def test_telegram_base64_decode_is_bounded(monkeypatch):
|
|||||||
_decode_telegram_base64_limited('A' * 12)
|
_decode_telegram_base64_limited('A' * 12)
|
||||||
|
|
||||||
|
|
||||||
TELEGRAM_BOT_TOKEN = '123456789:AAExampleBotTokenThatMustNotLeak'
|
|
||||||
TELEGRAM_FILE_URL = f'https://api.telegram.org/file/bot{TELEGRAM_BOT_TOKEN}/photos/file_0.jpg'
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_telegram_photo_does_not_expose_bot_token_in_image_url():
|
|
||||||
"""Regression test for the Telegram bot-token leak.
|
|
||||||
|
|
||||||
telegram.Bot builds file.file_path as
|
|
||||||
https://api.telegram.org/file/bot<TOKEN>/<path>, embedding the bot token.
|
|
||||||
The converter must not copy that URL into Image.url, or the token leaks to
|
|
||||||
the monitoring DB, dashboard and every installed plugin via the message
|
|
||||||
chain. Only base64 (which carries no token) may be stored.
|
|
||||||
"""
|
|
||||||
tg_file = MagicMock()
|
|
||||||
tg_file.file_path = TELEGRAM_FILE_URL
|
|
||||||
|
|
||||||
photo_size = MagicMock()
|
|
||||||
photo_size.get_file = AsyncMock(return_value=tg_file)
|
|
||||||
|
|
||||||
message = MagicMock()
|
|
||||||
message.text = None
|
|
||||||
message.caption = None
|
|
||||||
message.photo = [photo_size]
|
|
||||||
message.voice = None
|
|
||||||
message.document = None
|
|
||||||
|
|
||||||
response = MagicMock()
|
|
||||||
response.headers = {}
|
|
||||||
|
|
||||||
async def iter_chunked(_chunk_size):
|
|
||||||
yield b'\xff\xd8\xff\xe0jpeg-bytes'
|
|
||||||
|
|
||||||
response.content.iter_chunked = iter_chunked
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def fake_get(url):
|
|
||||||
yield response
|
|
||||||
|
|
||||||
fake_session = MagicMock()
|
|
||||||
fake_session.get = fake_get
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
'langbot.pkg.platform.sources.telegram.httpclient.get_session',
|
|
||||||
return_value=fake_session,
|
|
||||||
):
|
|
||||||
chain = await TelegramMessageConverter.target2yiri(message, MagicMock(), 'bot-account')
|
|
||||||
|
|
||||||
images = [c for c in chain if isinstance(c, platform_message.Image)]
|
|
||||||
assert len(images) == 1
|
|
||||||
image = images[0]
|
|
||||||
|
|
||||||
# The token-bearing URL must not be retained anywhere on the component.
|
|
||||||
assert not image.url
|
|
||||||
assert image.base64 is not None
|
|
||||||
assert image.base64.startswith('data:image/jpeg;base64,')
|
|
||||||
|
|
||||||
# Belt-and-suspenders: the token must not appear in the serialized chain
|
|
||||||
# (this is what gets persisted to the monitoring DB and sent to plugins).
|
|
||||||
serialized = json.dumps(chain.model_dump(), ensure_ascii=False)
|
|
||||||
assert TELEGRAM_BOT_TOKEN not in serialized
|
|
||||||
assert 'api.telegram.org/file/bot' not in serialized
|
|
||||||
|
|
||||||
|
|
||||||
def _select_form_data() -> dict:
|
def _select_form_data() -> dict:
|
||||||
return {
|
return {
|
||||||
'_current_input_field': 'choice',
|
'_current_input_field': 'choice',
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user