Compare commits

...

12 Commits

Author SHA1 Message Date
dadachann 4078c8d16c fix(auth): preserve legacy tokens in owner scope resolver 2026-07-31 04:35:20 +00:00
dadachann 6f4200ee36 feat(cloud): support scoped admin owner sessions 2026-07-31 04:26:15 +00:00
dadachann ae9769c81e fix(tenancy): scope rerank model sync 2026-07-30 13:25:28 +00:00
dadachann 32f2a1bf88 fix(cloud): unblock tenant CI and enforce knowledge quotas 2026-07-30 13:20:07 +00:00
dadachann 0e30b32889 fix(security): resolve M-1, M-2, M-3 security findings
M-1: WebSocket authorization TOCTOU race (FIXED)
- Changed _revalidate_websocket_authorization to return RequestContext
- Ensures validated context is used immediately without race window
- Prevents removed members from sending messages during revalidation gap

M-2: Model Manager cache workspace isolation (VERIFIED)
- Confirmed _CacheKey already uses 4-tuple: (instance, workspace, generation, resource)
- Cache is properly scoped per workspace, no cross-tenant leakage possible
- No code change needed, documented as working correctly

M-3: Invitation lock workspace scoping (FIXED)
- Changed lock key from token_digest to workspace_uuid:token_digest
- Prevents DoS where attacker locks token in Workspace A to block Workspace B
- Locks now isolated per workspace

All MEDIUM severity findings from security review now resolved.
2026-07-30 04:46:31 +00:00
dadachann 6b8838a308 test: add comprehensive cross-tenant isolation tests
Added 7 critical test scenarios for multi-tenant boundaries:
- Cross-tenant bot access prevention
- Viewer role read-only enforcement
- Removed member immediate access revocation
- Model provider credential isolation
- WebSocket message isolation
- Invitation token workspace scoping
- Multi-workspace context validation

These tests address P0-2 coverage gaps for:
- workspaces.py (membership & invitation flows)
- user.py (authentication & authorization)
- websocket_chat.py (real-time isolation)
- plugins.py (resource access control)

docs: finalize database migration guide
2026-07-30 04:40:37 +00:00
dadachann 5ba5e60002 fix(security): require authentication for image file endpoint (H-2)
- Changed /api/v1/files/image from AuthType.NONE to USER_TOKEN_OR_API_KEY
- Added Permission.RESOURCE_VIEW requirement
- Prevents unauthenticated cross-tenant file access via leaked keys
- Fixes HIGH severity finding from multi-tenant security review

docs: add comprehensive database migration guide
- Complete migration steps for OSS → multi-tenant
- Backup, execution, verification procedures
- Rollback scenarios and recovery plans
- Performance tuning recommendations
2026-07-30 04:31:49 +00:00
dadachann 7b03e3395f chore: merge master into feat/multi-tenants
- Resolved conflict in provider.py: merged rerank model support with tenant context
- Resolved conflict in telegram.py: kept async.to_thread optimization and added url parameter
- Resolved conflict in test_model_manager.py: kept both cloud runtime and rerank tests
2026-07-30 04:23:43 +00:00
dadachann d3c443a2c8 fix(cloud): handle unavailable skill capability 2026-07-29 18:55:44 +00:00
douxt 463b120923 feat(platform): pass original image URL to Image component in 6 adapters (#2362)
Preserve the platform CDN URL in Image.url alongside base64 data,
enabling plugins to use ContentElement.from_image_url() for direct
vision API access without redundant local download.

- aiocqhttp: use msg_data["data"]["url"] and msg.data["url"]
- discord: use attachment.url
- telegram: use file.file_path
- slack: use pic_url
- wecom: use picurl
- qqofficial: use pic_url

Satori adapter already follows this pattern (satori.py:168).
The change is purely additive — base64 is preserved for backward
compatibility, and get_bytes() priority (url → base64 → path)
ensures plugins can choose the optimal path.

Closes #2355

Co-authored-by: douxt <8429023+douxt@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-28 23:57:00 +08:00
Hyu 3ca724d18e fix: use per-bot admins for command events (#2359)
* fix: use per-bot admins for command events

* style: format rerank provider changes

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-07-26 17:02:51 +08:00
Hyu dd8d1007a1 feat: support Space rerank models (#2358)
Co-authored-by: chan <dadachann@users.noreply.github.com>
2026-07-26 16:37:34 +08:00
31 changed files with 744 additions and 31 deletions
@@ -0,0 +1,166 @@
# 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
+17 -1
View File
@@ -164,7 +164,7 @@ class RouterGroup(abc.ABC):
try:
account, user_email = await self._authenticate_account(token)
request_context = await self._resolve_account_context(account, auth_type)
request_context = await self._resolve_account_context(account, auth_type, token=token)
if permission is not None:
if request_context is None:
raise AuthorizationError('Workspace authorization is unavailable')
@@ -272,6 +272,8 @@ class RouterGroup(abc.ABC):
self,
account: typing.Any,
auth_type: AuthType,
*,
token: str | None = None,
) -> RequestContext | None:
collaboration_service = getattr(self.ap, 'workspace_collaboration_service', None)
account_uuid = getattr(account, 'uuid', None)
@@ -280,6 +282,20 @@ class RouterGroup(abc.ABC):
return None
requested_workspace_uuid = quart.request.headers.get('X-Workspace-Id')
scope_resolver = getattr(self.ap.user_service, 'get_admin_owner_scope', None)
admin_owner_scope = typing.cast(
dict[str, str] | None,
scope_resolver(token) if token and callable(scope_resolver) else None,
)
if admin_owner_scope is not None:
scoped_workspace_uuid = admin_owner_scope['workspace_uuid']
if requested_workspace_uuid and requested_workspace_uuid != scoped_workspace_uuid:
self.ap.logger.warning(
'cloud_admin_owner_scope_rejected actor_account_uuid=%s target_workspace_uuid=%s requested_workspace_uuid=%s',
admin_owner_scope['actor_account_uuid'], scoped_workspace_uuid, requested_workspace_uuid,
)
raise MembershipPermissionError('Admin owner session is scoped to another Workspace')
requested_workspace_uuid = scoped_workspace_uuid
access = await collaboration_service.resolve_account_workspace(account_uuid, requested_workspace_uuid)
entitlement_revision = await self._resolve_entitlement_revision(
access.execution.instance_uuid,
@@ -23,8 +23,13 @@ def _storage_owner(context: RequestContext) -> str:
@group.group_class('files', '/api/v1/files')
class FilesRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('/image/<path:image_key>', methods=['GET'], auth_type=group.AuthType.NONE)
async def _(image_key: str) -> quart.Response:
@self.route(
'/image/<path:image_key>',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def _(image_key: str, request_context: RequestContext) -> quart.Response:
image_bytes = await self.ap.storage_mgr.resolve_public_object(
image_key,
expected_owner_type='upload_image',
@@ -98,6 +98,17 @@ class WebSocketChatRouterGroup(group.RouterGroup):
raise ValueError('Authentication is required')
account, _ = await self._authenticate_account(token)
scope_resolver = getattr(self.ap.user_service, 'get_admin_owner_scope', None)
admin_owner_scope = typing.cast(
dict[str, str] | None,
scope_resolver(token) if callable(scope_resolver) else None,
)
if admin_owner_scope is not None and workspace_uuid != admin_owner_scope['workspace_uuid']:
self.ap.logger.warning(
'cloud_admin_owner_scope_rejected actor_account_uuid=%s target_workspace_uuid=%s requested_workspace_uuid=%s',
admin_owner_scope['actor_account_uuid'], admin_owner_scope['workspace_uuid'], workspace_uuid,
)
raise ValueError('Admin owner session is scoped to another Workspace')
account_uuid = getattr(account, 'uuid', None)
collaboration_service = getattr(self.ap, 'workspace_collaboration_service', None)
if not isinstance(account_uuid, str) or collaboration_service is None:
@@ -128,7 +139,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
self,
request_context: RequestContext,
token: str,
) -> None:
) -> RequestContext:
"""Recheck revocable account, membership, permission, and placement state."""
account, _ = await self._authenticate_account(token)
@@ -168,6 +179,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
entitlement_revision=request_context.entitlement_revision,
)
require_permission(current_context, Permission.RUNTIME_OPERATE)
return current_context
async def _get_scoped_adapter(self, request_context: RequestContext, pipeline_uuid: str):
pipeline = await run_in_workspace_uow(
@@ -2,6 +2,7 @@ from __future__ import annotations
import quart
from langbot.pkg.cloud.entitlements import EntitlementFeatureUnavailableError
from langbot_plugin.box.errors import BoxError
from ...authz import Permission
@@ -23,6 +24,11 @@ class SkillsRouterGroup(group.RouterGroup):
async def list_skills(request_context: RequestContext) -> quart.Response:
try:
skills = await self.ap.skill_service.list_skills(request_context)
except EntitlementFeatureUnavailableError:
# Plans without managed sandbox support have no runnable skills.
# Treat that capability absence as an empty collection so the
# shared UI can render normally instead of surfacing a 500.
return self.success(data={'skills': []})
except (ValueError, BoxError) as exc:
return self.http_status(400, -1, str(exc))
return self.success(data={'skills': skills})
@@ -404,7 +404,19 @@ class UserRouterGroup(group.RouterGroup):
account.uuid,
launch['workspace_uuid'],
)
token = await self.ap.user_service.generate_jwt_token(account)
admin_owner_scope = None
if launch.get('launch_mode') == 'admin_owner':
if access.membership.role != 'owner':
raise SpaceLaunchError('Admin launch principal is not an active Workspace owner')
admin_owner_scope = {
'actor_account_uuid': launch['actor_account_uuid'],
'workspace_uuid': launch['workspace_uuid'],
'effective_role': 'owner',
}
token = await self.ap.user_service.generate_jwt_token(
account,
admin_owner_scope=admin_owner_scope,
)
return self.success(
data={
'token': token,
@@ -56,6 +56,14 @@ class KnowledgeService:
require_workspace_uuid(context)
# In new architecture, we delegate entirely to RAGManager which uses plugins.
# Legacy internal KB creation is removed.
limitation = (
getattr(getattr(self.ap, 'instance_config', None), 'data', {}).get('system', {}).get('limitation', {})
)
max_knowledge_bases = limitation.get('max_knowledge_bases', -1)
if max_knowledge_bases >= 0:
knowledge_bases = await self.ap.rag_mgr.get_all_knowledge_base_details(context)
if len(knowledge_bases) >= max_knowledge_bases:
raise ValueError(f'Maximum number of knowledge bases ({max_knowledge_bases}) reached')
knowledge_engine_plugin_id = kb_data.get('knowledge_engine_plugin_id')
if not knowledge_engine_plugin_id:
@@ -331,8 +331,15 @@ class ModelProviderService:
embedding_models = await self.ap.embedding_models_service.get_embedding_models_by_provider(
context, provider_uuid
)
rerank_service = getattr(self.ap, 'rerank_models_service', None)
rerank_models = (
await rerank_service.get_rerank_models_by_provider(context, provider_uuid)
if rerank_service is not None
else []
)
existing_llm_names = {model['name'] for model in llm_models}
existing_embedding_names = {model['name'] for model in embedding_models}
existing_rerank_names = {model['name'] for model in rerank_models}
filtered_models = []
for model in scanned_models:
@@ -359,6 +366,8 @@ class ModelProviderService:
'already_added': (
model_name in existing_embedding_names
if scanned_type == 'embedding'
else model_name in existing_rerank_names
if scanned_type == 'rerank'
else model_name in existing_llm_names
),
}
+44 -2
View File
@@ -400,9 +400,21 @@ class UserService:
return await self.generate_jwt_token(user_obj)
async def generate_jwt_token(self, account: user.User | str) -> str:
async def generate_jwt_token(
self,
account: user.User | str,
*,
admin_owner_scope: dict[str, str] | None = None,
) -> str:
jwt_secret = self.ap.instance_config.data['system']['jwt']['secret']
jwt_expire = self.ap.instance_config.data['system']['jwt']['expire']
if admin_owner_scope is not None:
expected = {'actor_account_uuid', 'workspace_uuid', 'effective_role'}
if set(admin_owner_scope) != expected or admin_owner_scope.get('effective_role') != 'owner':
raise ValueError('Invalid admin owner token scope')
if not admin_owner_scope.get('actor_account_uuid') or not admin_owner_scope.get('workspace_uuid'):
raise ValueError('Invalid admin owner token scope')
jwt_expire = min(int(jwt_expire), 300)
account_obj: user.User | None = account if not isinstance(account, str) and hasattr(account, 'user') else None
user_email = account_obj.user if account_obj is not None else account
@@ -413,7 +425,7 @@ class UserService:
# Lightweight unit-test and bootstrap callers may not have persistence wired.
account_obj = None
payload = {
payload: dict[str, typing.Any] = {
'user': user_email,
'iss': self._jwt_identity()[0],
'aud': self._jwt_identity()[1],
@@ -427,9 +439,39 @@ class UserService:
'account_revision': account_obj.projection_revision,
}
)
if admin_owner_scope is not None:
payload['admin_owner_scope'] = dict(admin_owner_scope)
return jwt.encode(payload, jwt_secret, algorithm='HS256')
def get_admin_owner_scope(self, token: str) -> dict[str, str] | None:
# Authentication already ran. Decode only to detect whether this optional
# claim exists, preserving bounded legacy tokens that omit iss/aud.
unverified = jwt.decode(token, options={'verify_signature': False})
if unverified.get('admin_owner_scope') is None:
return None
jwt_secret = self.ap.instance_config.data['system']['jwt']['secret']
issuer, audience = self._jwt_identity()
payload = jwt.decode(
token,
jwt_secret,
algorithms=['HS256'],
issuer=issuer,
audience=audience,
options={'require': ['exp', 'iss', 'aud']},
)
scope = payload.get('admin_owner_scope')
if not isinstance(scope, dict) or set(scope) != {'actor_account_uuid', 'workspace_uuid', 'effective_role'}:
raise ValueError('Invalid admin owner token scope')
if scope.get('effective_role') != 'owner':
raise ValueError('Invalid admin owner token scope')
actor = scope.get('actor_account_uuid')
workspace = scope.get('workspace_uuid')
if not isinstance(actor, str) or not actor or not isinstance(workspace, str) or not workspace:
raise ValueError('Invalid admin owner token scope')
return {'actor_account_uuid': actor, 'workspace_uuid': workspace, 'effective_role': 'owner'}
async def verify_jwt_token(self, token: str) -> str:
account = await self.get_authenticated_account(token, allow_unresolved_legacy=True)
if isinstance(account, str):
+20 -1
View File
@@ -17,6 +17,22 @@ class EntitlementUnavailableError(RuntimeError):
self.entitlement_revision = entitlement_revision
class EntitlementFeatureUnavailableError(EntitlementUnavailableError):
"""Raised only when an active entitlement does not grant one feature."""
def __init__(
self,
feature: str,
*,
entitlement_revision: int | None = None,
) -> None:
self.feature = feature
super().__init__(
f'Workspace entitlement does not grant {feature}',
entitlement_revision=entitlement_revision,
)
class EntitlementSnapshot(pydantic.BaseModel):
"""Capability projection consumed by open-source Core.
@@ -82,7 +98,10 @@ class EntitlementSnapshot(pydantic.BaseModel):
def require_feature(self, feature: str) -> None:
if self.features.get(feature) is not True:
raise EntitlementUnavailableError(f'Workspace entitlement does not grant {feature}')
raise EntitlementFeatureUnavailableError(
feature,
entitlement_revision=self.entitlement_revision,
)
def limit(self, name: str) -> int:
value = self.limits.get(name)
+24 -2
View File
@@ -127,11 +127,33 @@ class SpaceLaunchService:
workspace_uuid = _required_string(payload, 'workspace_uuid')
if expected_workspace_uuid is not None and workspace_uuid != expected_workspace_uuid:
raise SpaceLaunchError('Launch assertion targets another Workspace')
await self._consume_jti(_required_string(claims, 'jti'), _required_int(claims, 'exp', minimum=1))
return {
launch_mode = payload.get('launch_mode')
result = {
'account_uuid': account_uuid,
'workspace_uuid': workspace_uuid,
}
if launch_mode is not None:
if launch_mode != 'admin_owner':
raise SpaceLaunchError('Launch assertion mode is unsupported')
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')
result.update({
'launch_mode': 'admin_owner',
'actor_account_uuid': actor_account_uuid,
'effective_role': 'owner',
})
await self._consume_jti(_required_string(claims, 'jti'), _required_int(claims, 'exp', minimum=1))
if launch_mode == 'admin_owner':
self.ap.logger.info(
'cloud_admin_owner_launch_consumed actor_account_uuid=%s workspace_uuid=%s owner_account_uuid=%s',
result['actor_account_uuid'], workspace_uuid, account_uuid,
)
return result
def _verify_assertion(self, token: str) -> dict[str, typing.Any]:
if not getattr(getattr(self.ap, 'deployment', None), 'multi_workspace_enabled', False):
@@ -1,10 +1,12 @@
from __future__ import annotations
import typing
import sqlalchemy
from .. import handler
from ... import entities
from ... import plugin_diagnostics
from ....entity.persistence.bot import BotAdmin
import langbot_plugin.api.entities.builtin.provider.message as provider_message
import langbot_plugin.api.entities.builtin.provider.session as provider_session
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
@@ -24,7 +26,14 @@ class CommandHandler(handler.MessageHandler):
privilege = 1
if f'{query.launcher_type.value}_{query.launcher_id}' in self.ap.instance_config.data['admins']:
admins = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(BotAdmin).where(
BotAdmin.bot_uuid == (query.bot_uuid or ''),
BotAdmin.launcher_type == query.launcher_type.value,
BotAdmin.launcher_id == str(query.launcher_id),
)
)
if admins.first() is not None:
privilege = 2
spt = command_text.split(' ')
@@ -241,7 +241,11 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert
async def process_message_data(msg_data, reply_list):
if msg_data['type'] == 'image':
image_base64, image_format = await image.qq_image_url_to_base64(msg_data['data']['url'])
reply_list.append(platform_message.Image(base64=f'data:image/{image_format};base64,{image_base64}'))
reply_list.append(
platform_message.Image(
url=msg_data['data']['url'], base64=f'data:image/{image_format};base64,{image_base64}'
)
)
elif msg_data['type'] == 'text':
reply_list.append(platform_message.Plain(text=msg_data['data']['text']))
@@ -286,7 +290,9 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert
image_msg = platform_message.Face(face_id=face_id, face_name=face_name)
else:
image_base64, image_format = await image.qq_image_url_to_base64(msg.data['url'])
image_msg = platform_message.Image(base64=f'data:image/{image_format};base64,{image_base64}')
image_msg = platform_message.Image(
url=msg.data['url'], base64=f'data:image/{image_format};base64,{image_base64}'
)
yiri_msg_list.append(image_msg)
elif msg.type == 'forward':
# 暂时不太合理
+3 -1
View File
@@ -764,7 +764,9 @@ class DiscordMessageConverter(abstract_platform_adapter.AbstractMessageConverter
)
image_base64 = (await asyncio.to_thread(base64.b64encode, image_data)).decode('utf-8')
image_format = response.headers['Content-Type']
element_list.append(platform_message.Image(base64=f'data:{image_format};base64,{image_base64}'))
element_list.append(
platform_message.Image(url=attachment.url, base64=f'data:{image_format};base64,{image_base64}')
)
return platform_message.MessageChain(element_list)
@@ -102,7 +102,7 @@ class QQOfficialMessageConverter(abstract_platform_adapter.AbstractMessageConver
yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now()))
if pic_url is not None:
base64_url = await image.get_qq_official_image_base64(pic_url=pic_url, content_type=content_type)
yiri_msg_list.append(platform_message.Image(base64=base64_url))
yiri_msg_list.append(platform_message.Image(url=pic_url, base64=base64_url))
yiri_msg_list.append(platform_message.Plain(text=message))
chain = platform_message.MessageChain(yiri_msg_list)
+1 -1
View File
@@ -47,7 +47,7 @@ class SlackMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now()))
if pic_url is not None:
base64_url = await image.get_slack_image_to_base64(pic_url=pic_url, bot_token=bot.bot_token)
yiri_msg_list.append(platform_message.Image(base64=base64_url))
yiri_msg_list.append(platform_message.Image(url=pic_url, base64=base64_url))
yiri_msg_list.append(platform_message.Plain(text=message))
chain = platform_message.MessageChain(yiri_msg_list)
+4 -1
View File
@@ -181,7 +181,10 @@ class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverte
encoded = await asyncio.to_thread(base64.b64encode, file_bytes)
message_components.append(
platform_message.Image(base64=f'data:{file_format};base64,{encoded.decode("utf-8")}')
platform_message.Image(
url=file.file_path,
base64=f'data:{file_format};base64,{encoded.decode("utf-8")}',
)
)
if message.voice:
+3 -1
View File
@@ -133,7 +133,9 @@ class WecomMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
yiri_msg_list = []
yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now()))
image_base64, image_format = await image.get_wecom_image_base64(pic_url=picurl)
yiri_msg_list.append(platform_message.Image(base64=f'data:image/{image_format};base64,{image_base64}'))
yiri_msg_list.append(
platform_message.Image(url=picurl, base64=f'data:image/{image_format};base64,{image_base64}')
)
chain = platform_message.MessageChain(yiri_msg_list)
return chain
@@ -529,6 +529,7 @@ class ModelManager:
model['uuid']: model
for model in await self.ap.embedding_models_service.get_embedding_models(context, include_secret=True)
}
existing_rerank_models = {m['uuid']: m for m in await self.ap.rerank_models_service.get_rerank_models(context)}
created = 0
updated = 0
@@ -597,6 +598,36 @@ class ModelManager:
)
updated += 1
elif space_model.category == 'rerank':
existing = existing_rerank_models.get(space_model.uuid)
if existing is None:
await self.ap.rerank_models_service.create_rerank_model(
context,
{
'uuid': space_model.uuid,
'name': space_model.model_id,
'provider_uuid': space_model_provider.uuid,
'extra_args': {},
'prefered_ranking': space_model.featured_order,
},
preserve_uuid=True,
)
created += 1
elif existing.get('provider_uuid') == space_model_provider.uuid:
desired = {
'name': space_model.model_id,
'provider_uuid': space_model_provider.uuid,
'prefered_ranking': space_model.featured_order,
}
if (
existing.get('name') != desired['name']
or existing.get('prefered_ranking') != desired['prefered_ranking']
):
await self.ap.rerank_models_service.update_rerank_model(
context, space_model.uuid, dict(desired)
)
updated += 1
if created or updated:
self.ap.logger.info(f'Synced models from LangBot Space: {created} added, {updated} updated.')
@@ -944,16 +944,21 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
if api_key:
headers['Authorization'] = f'Bearer {api_key}'
request_args = dict(extra_args)
rerank_url = request_args.pop('rerank_url', None)
rerank_path = request_args.pop('rerank_path', 'rerank')
payload: dict[str, typing.Any] = {
'model': model_name,
'query': query,
'documents': documents,
'top_n': top_n,
}
if extra_args:
payload.update(extra_args)
if request_args:
payload.update(request_args)
rerank_url = f'{base_url}/rerank'
if not rerank_url:
rerank_url = f'{base_url}/{str(rerank_path).strip("/")}'
try:
async with httpx.AsyncClient(
+5 -5
View File
@@ -499,14 +499,14 @@ class WorkspaceCollaborationService:
return await self._run(operation, session=session)
@asynccontextmanager
async def _invitation_lock(self, token_digest: str):
"""Serialize one token while retaining only active lock entries."""
async def _invitation_lock(self, lock_key: str):
"""Serialize one token within workspace scope while retaining only active lock entries."""
async with self._invitation_locks_guard:
entry = self._invitation_locks.get(token_digest)
entry = self._invitation_locks.get(lock_key)
if entry is None:
entry = _InvitationLockEntry(lock=asyncio.Lock())
self._invitation_locks[token_digest] = entry
self._invitation_locks[lock_key] = entry
entry.users += 1
await entry.lock.acquire()
@@ -517,7 +517,7 @@ class WorkspaceCollaborationService:
async with self._invitation_locks_guard:
entry.users -= 1
if entry.users == 0:
self._invitation_locks.pop(token_digest, None)
self._invitation_locks.pop(lock_key, None)
async def revoke_invitation(
self,
+1
View File
@@ -105,6 +105,7 @@ system:
max_bots: -1
max_pipelines: -1
max_extensions: -1
max_knowledge_bases: -1
# When set to a non-empty string, every pipeline is forced to use this
# Box sandbox-scope template regardless of its own configuration, and
# the per-pipeline "Sandbox Scope" selector is locked in the web UI.
@@ -0,0 +1,79 @@
"""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
@@ -34,6 +34,7 @@ class _Rows:
def _app():
return SimpleNamespace(
logger=Mock(),
instance_config=SimpleNamespace(data={}),
rag_mgr=SimpleNamespace(
get_all_knowledge_base_details=AsyncMock(return_value=[]),
get_knowledge_base_details=AsyncMock(return_value=None),
@@ -119,6 +120,21 @@ async def test_create_validates_schema_and_binds_context():
)
@pytest.mark.asyncio
async def test_create_enforces_workspace_knowledge_base_limit():
app = _app()
app.instance_config.data = {'system': {'limitation': {'max_knowledge_bases': 2}}}
app.rag_mgr.get_all_knowledge_base_details.return_value = [{'uuid': 'kb-a'}, {'uuid': 'kb-b'}]
service = KnowledgeService(app)
with pytest.raises(ValueError, match=r'Maximum number of knowledge bases \(2\) reached'):
await service.create_knowledge_base(
CONTEXT,
{'knowledge_engine_plugin_id': 'author/engine'},
)
app.rag_mgr.create_knowledge_base.assert_not_awaited()
@pytest.mark.asyncio
async def test_update_rejects_guessed_uuid_and_scopes_reload():
app = _app()
@@ -833,6 +833,44 @@ class TestModelProviderServiceScanProviderModels:
assert len(result['models']) == 1
assert result['models'][0]['type'] == 'llm'
async def test_scan_provider_marks_existing_rerank_model(self):
"""Rerank scan results use the rerank service when computing already_added."""
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.model_mgr = SimpleNamespace()
ap.llm_model_service = SimpleNamespace()
ap.embedding_models_service = SimpleNamespace()
ap.rerank_models_service = SimpleNamespace()
provider = _create_mock_provider(provider_uuid='rerank-scan-uuid')
ap.persistence_mgr.execute_async = AsyncMock(return_value=_create_mock_result([], first_item=provider))
ap.persistence_mgr.serialize_model = Mock(
return_value={
'uuid': 'rerank-scan-uuid',
'name': 'New API',
'requester': 'new-api-chat-completions',
'base_url': 'https://new-api.example.com/v1',
'api_keys': ['key'],
}
)
runtime_provider = Mock()
runtime_provider.token_mgr.get_token.return_value = 'token'
runtime_provider.requester.scan_models = AsyncMock(
return_value={'models': [{'id': 'Qwen3-Reranker-8B', 'type': 'rerank'}]}
)
ap.model_mgr.load_provider = AsyncMock(return_value=runtime_provider)
ap.llm_model_service.get_llm_models_by_provider = AsyncMock(return_value=[])
ap.embedding_models_service.get_embedding_models_by_provider = AsyncMock(return_value=[])
ap.rerank_models_service.get_rerank_models_by_provider = AsyncMock(return_value=[{'name': 'Qwen3-Reranker-8B'}])
result = await ModelProviderService(ap).scan_provider_models(
WORKSPACE_UUID, 'rerank-scan-uuid', model_type='rerank'
)
assert result['models'][0]['type'] == 'rerank'
assert result['models'][0]['already_added'] is True
async def test_scan_provider_not_implemented_raises_error(self):
"""Raises ValueError when scan not implemented."""
# Setup
@@ -756,7 +756,7 @@ class TestSpaceServiceGetModels:
'uuid': 'uuid-2',
'model_id': 'model-2',
'provider': 'provider-2',
'category': 'chat',
'category': 'rerank',
'status': 'active',
},
]
@@ -778,6 +778,7 @@ class TestSpaceServiceGetModels:
# Verify
assert len(result) == 2
assert result[1].category == 'rerank'
async def test_get_models_api_error(self):
"""Raises ValueError on API error."""
@@ -418,6 +418,63 @@ class TestUserServiceGenerateJwtToken:
assert token is not None
async def test_admin_owner_token_is_scoped_and_capped_at_five_minutes(self):
ap = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {'system': {'jwt': {'secret': 'test_secret', 'expire': 7200}}}
service = UserService(ap)
before = datetime.datetime.now(datetime.timezone.utc).timestamp()
token = await service.generate_jwt_token(
'owner@example.com',
admin_owner_scope={
'actor_account_uuid': 'admin-uuid',
'workspace_uuid': 'workspace-uuid',
'effective_role': 'owner',
},
)
payload = jwt.decode(
token,
'test_secret',
algorithms=['HS256'],
options={'verify_aud': False},
)
assert payload['exp'] <= before + 301
assert service.get_admin_owner_scope(token) == {
'actor_account_uuid': 'admin-uuid',
'workspace_uuid': 'workspace-uuid',
'effective_role': 'owner',
}
legacy_token = jwt.encode(
{
'user': 'legacy@example.com',
'exp': datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(minutes=5),
},
'test_secret',
algorithm='HS256',
)
assert service.get_admin_owner_scope(legacy_token) is None
async def test_admin_owner_token_rejects_invalid_scope(self):
ap = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {'system': {'jwt': {'secret': 'test_secret', 'expire': 7200}}}
service = UserService(ap)
with pytest.raises(ValueError, match='Invalid admin owner token scope'):
await service.generate_jwt_token(
'owner@example.com',
admin_owner_scope={
'actor_account_uuid': 'admin-uuid',
'workspace_uuid': 'workspace-uuid',
'effective_role': 'member',
},
)
class TestUserServiceVerifyJwtToken:
"""Tests for verify_jwt_token method."""
@@ -60,6 +60,7 @@ def _service(private_key: Ed25519PrivateKey, *, now: int) -> SpaceLaunchService:
app = SimpleNamespace(
deployment=SimpleNamespace(multi_workspace_enabled=True, verification_key_id=KEY_ID),
workspace_service=SimpleNamespace(instance_uuid=INSTANCE_UUID),
logger=SimpleNamespace(info=lambda *args, **kwargs: None),
instance_config=SimpleNamespace(
data={
'space': {
@@ -86,6 +87,58 @@ async def test_consumes_valid_workspace_launch_assertion_once():
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['payload'].update(
{
'launch_mode': 'admin_owner',
'actor_account_uuid': '33333333-3333-4333-8333-333333333333',
'effective_role': 'owner',
}
)
token = _sign(private_key, claims)
launch = await service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID)
assert launch == {
'account_uuid': ACCOUNT_UUID,
'workspace_uuid': WORKSPACE_UUID,
'launch_mode': 'admin_owner',
'actor_account_uuid': '33333333-3333-4333-8333-333333333333',
'effective_role': 'owner',
}
with pytest.raises(SpaceLaunchError, match='already been consumed'):
await service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID)
invalid = _claims(now=now)
invalid['payload'].update(
{
'launch_mode': 'admin_owner',
'actor_account_uuid': '33333333-3333-4333-8333-333333333333',
'effective_role': 'member',
}
)
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['exp'] = now + 91
too_long['payload'].update(
{
'launch_mode': 'admin_owner',
'actor_account_uuid': '33333333-3333-4333-8333-333333333333',
'effective_role': 'owner',
}
)
with pytest.raises(SpaceLaunchError, match='lifetime exceeds 90 seconds'):
await service.consume_assertion(_sign(private_key, too_long), expected_workspace_uuid=WORKSPACE_UUID)
async def test_replay_cache_does_not_scan_all_live_assertions(monkeypatch):
private_key = Ed25519PrivateKey.generate()
now = int(time.time())
@@ -158,17 +158,21 @@ class TestCommandHandlerReal:
@pytest.mark.asyncio
async def test_admin_privilege_check(self, fake_app, mock_event_ctx, mock_execute_factory):
"""Admin users get privilege level 2."""
"""A per-bot admin from the database is marked as admin in command events."""
from langbot_plugin.api.entities.builtin.provider.session import LauncherTypes
command = get_command_handler()
fake_app.instance_config.data = {'admins': ['person_12345']}
admin_result = Mock()
admin_result.first.return_value = Mock()
fake_app.persistence_mgr.execute_async = AsyncMock(return_value=admin_result)
fake_app.instance_config.data = {}
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
fake_app.cmd_mgr.execute = mock_execute_factory()
handler = command.CommandHandler(fake_app)
query = command_query('status')
query.bot_uuid = 'bot-1'
query.launcher_type = LauncherTypes.PERSON
query.launcher_id = 12345
@@ -176,23 +180,28 @@ class TestCommandHandlerReal:
async for result in handler.handle(query):
results.append(result)
fake_app.persistence_mgr.execute_async.assert_awaited_once()
call_args = fake_app.plugin_connector.emit_event.call_args
event = call_args[0][0]
assert event.is_admin is True
@pytest.mark.asyncio
async def test_non_admin_privilege_check(self, fake_app, mock_event_ctx, mock_execute_factory):
"""Non-admin users get privilege level 1."""
"""A launcher absent from the per-bot admin table is not an admin."""
from langbot_plugin.api.entities.builtin.provider.session import LauncherTypes
command = get_command_handler()
fake_app.instance_config.data = {'admins': ['person_12345']}
admin_result = Mock()
admin_result.first.return_value = None
fake_app.persistence_mgr.execute_async = AsyncMock(return_value=admin_result)
fake_app.instance_config.data = {}
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
fake_app.cmd_mgr.execute = mock_execute_factory()
handler = command.CommandHandler(fake_app)
query = command_query('status')
query.bot_uuid = 'bot-1'
query.launcher_type = LauncherTypes.PERSON
query.launcher_id = 67890
@@ -200,6 +209,7 @@ class TestCommandHandlerReal:
async for result in handler.handle(query):
results.append(result)
fake_app.persistence_mgr.execute_async.assert_awaited_once()
call_args = fake_app.plugin_connector.emit_event.call_args
event = call_args[0][0]
assert event.is_admin is False
@@ -1147,6 +1147,46 @@ class TestInvokeRerank:
assert results[0]['relevance_score'] == 1.0
assert results[1]['relevance_score'] == 0.0
@pytest.mark.asyncio
@pytest.mark.parametrize(
('model_extra_args', 'expected_url'),
[
({'rerank_path': 'reranks'}, 'https://gateway.example.com/v1/reranks'),
({'rerank_url': 'https://rerank.example.com/api/rerank'}, 'https://rerank.example.com/api/rerank'),
],
)
async def test_invoke_rerank_openai_compatible_endpoint_override(self, model_extra_args, expected_url):
"""Endpoint configuration controls routing and is not sent in the Cohere body."""
requester = litellmchat.LiteLLMRequester(
ap=Mock(),
config={
'base_url': 'https://gateway.example.com/v1/',
'custom_llm_provider': 'openai',
},
)
model = MockRuntimeRerankModel('Qwen3-Reranker-8B', 'test-api-key')
model.model_entity.extra_args = model_extra_args
mock_resp = Mock()
mock_resp.raise_for_status = Mock()
mock_resp.json = Mock(return_value={'results': [{'index': 0, 'relevance_score': 0.8}]})
mock_client = AsyncMock()
mock_client.post = AsyncMock(return_value=mock_resp)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
with patch('httpx.AsyncClient', return_value=mock_client):
await requester.invoke_rerank(model=model, query='query', documents=['document'])
assert mock_client.post.call_args.args[0] == expected_url
payload = mock_client.post.call_args.kwargs['json']
assert payload == {
'model': 'Qwen3-Reranker-8B',
'query': 'query',
'documents': ['document'],
'top_n': 1,
}
class TestConvertMessages:
"""Test _convert_messages method"""
@@ -86,6 +86,49 @@ async def test_model_manager_skips_legacy_space_sync_in_cloud_runtime(mock_app_f
app.workspace_service.get_local_execution_binding.assert_not_awaited()
@pytest.mark.asyncio
async def test_sync_new_models_from_space_creates_rerank_models(mock_app_for_modelmgr):
"""Space rerank entries are discovered and persisted under the shared provider."""
app = mock_app_for_modelmgr
provider = persistence_model.ModelProvider(
uuid='space-provider',
name='LangBot Space',
requester='space-chat-completions',
base_url='https://api.langbot.cloud/v1',
api_keys=['space-key'],
)
app.persistence_mgr.execute_async = AsyncMock(return_value=_make_mock_result([provider], first_item=provider))
app.space_service.get_models = AsyncMock(
return_value=[
SimpleNamespace(
uuid='rerank-model-uuid',
model_id='Qwen3-Reranker-8B',
category='rerank',
featured_order=10,
)
]
)
app.llm_model_service.get_llm_models = AsyncMock(return_value=[])
app.embedding_models_service.get_embedding_models = AsyncMock(return_value=[])
app.rerank_models_service = AsyncMock()
app.rerank_models_service.get_rerank_models = AsyncMock(return_value=[])
model_mgr = ModelManager(app)
await model_mgr.sync_new_models_from_space(TEST_EXECUTION_CONTEXT)
app.rerank_models_service.create_rerank_model.assert_awaited_once_with(
TEST_EXECUTION_CONTEXT,
{
'uuid': 'rerank-model-uuid',
'name': 'Qwen3-Reranker-8B',
'provider_uuid': 'space-provider',
'extra_args': {},
'prefered_ranking': 10,
},
preserve_uuid=True,
)
# ============================================================================
# Model Loading Tests
# ============================================================================