feat(cloud): enforce plan policies and refine workspace interface

This commit is contained in:
RockChinQ
2026-10-15 05:31:04 +00:00
parent e29fd27f02
commit 14b5bdb8c2
24 changed files with 1745 additions and 2544 deletions
+4
View File
@@ -238,3 +238,7 @@ are not rewritten. Embedding and rerank models are not eligible.
### Reset a bot session context
Use `reset_session_context(bot_id, session_id)` only when a user asks to start a session afresh. It requires `resource.manage`, preserves monitoring records, and refuses sessions with active tasks. It clears conversation runner state and excludes earlier transcript entries from subsequent model context. Files and long-term memory are not removed.
## Cloud plan platform restrictions
Bot creation and enabling use the same Workspace entitlement policy as HTTP. A disabled platform is rejected even through MCP or plugin calls; do not retry using an Omni or legacy alias to bypass the restriction. The Workspace-scoped HTTP adapter catalog omits disabled platforms.
@@ -296,6 +296,8 @@ class RouterGroup(abc.ABC):
)
if isinstance(e, CodexProviderError):
return self.http_status(e.status_code, e.error_code, str(e))
if isinstance(e, EntitlementUnavailableError):
return self.http_status(403, 'entitlement_unavailable', str(e))
if isinstance(e, AuthorizationError):
return self.http_status(e.status_code, e.error_code, str(e))
if isinstance(e, WorkspaceNotFoundError):
@@ -8,6 +8,7 @@ import quart
from langbot.pkg.api.http.authz import Permission
from langbot.pkg.api.http.context import RequestContext
from langbot.pkg.cloud.plan_policy import workspace_entitlement, platform_feature_names, require_workspace_platform
from langbot.pkg.core.errors import TaskCapacityError
from langbot.pkg.utils import httpclient, importutil
@@ -238,11 +239,16 @@ class AdaptersRouterGroup(group.RouterGroup):
)
@self.route('', methods=['GET'])
async def _() -> str:
return self.success(data={'adapters': self.ap.platform_mgr.get_available_adapters_info()})
async def _(request_context: RequestContext) -> str:
snapshot = await workspace_entitlement(self.ap, request_context.workspace_uuid)
adapters = self.ap.platform_mgr.get_available_adapters_info()
if snapshot is not None:
adapters = [a for a in adapters if not any(snapshot.features.get(f) is False for f in platform_feature_names(a['name']))]
return self.success(data={'adapters': adapters})
@self.route('/<adapter_name>', methods=['GET'])
async def _(adapter_name: str) -> str:
async def _(adapter_name: str, request_context: RequestContext) -> str:
await require_workspace_platform(self.ap, request_context.workspace_uuid, adapter_name)
adapter_info = self.ap.platform_mgr.get_available_adapter_info_by_name(adapter_name)
if adapter_info is None:
+5
View File
@@ -16,6 +16,7 @@ from .tenant import TenantContext, require_workspace_uuid, scope_statement
from ....utils import httpclient
from ....platform.sources import http_bot_signing
from ....platform.adapter_names import canonical_adapter_name
from ....cloud.plan_policy import require_workspace_platform
from ....agent.runner.errors import RunnerNotFoundError
@@ -696,6 +697,7 @@ class BotService:
# TODO: 检查配置信息格式
bot_data = await self._prepare_bot_data(context, bot_data, include_uuid=True)
await require_workspace_platform(self.ap, workspace_uuid, bot_data['adapter'])
bot_data['uuid'] = str(uuid.uuid4())
bot_data['workspace_uuid'] = workspace_uuid
bot_data.setdefault('event_bindings', [])
@@ -718,6 +720,9 @@ class BotService:
workspace_uuid = require_workspace_uuid(context)
update_data = await self._prepare_bot_data(context, bot_data, include_uuid=False)
update_data.pop('workspace_uuid', None)
if 'adapter' in update_data or update_data.get('enable'):
current = await self.get_bot(context, bot_uuid, include_secret=True)
await require_workspace_platform(self.ap, workspace_uuid, update_data.get('adapter', current['adapter']))
result = await self.ap.persistence_mgr.execute_async(
scope_statement(
@@ -260,6 +260,9 @@ class MonitoringService(ExecutionDetailsMixin):
A dict mapping table name to the number of deleted rows.
"""
workspace_uuid = self._require_write_context(context)
from ....cloud.plan_policy import workspace_history_retention_days
retention_days = await workspace_history_retention_days(self.ap, workspace_uuid, retention_days)
if retention_days < 1:
raise ValueError('retention_days must be >= 1')
if batch_size < 1:
+1 -1
View File
@@ -347,7 +347,7 @@ class LangBotMCPServer:
@mcp.tool(
description=(
'Create a bot. `bot_data` is a JSON object matching the LangBot '
'Create a bot. Cloud Workspace platform restrictions also apply to this tool. `bot_data` is a JSON object matching the LangBot '
'POST /api/v1/platform/bots body (e.g. name, adapter, config). '
'Use event_bindings for exclusive Agent/Pipeline routes; plugin_processors is an array '
'of {processor_uuid, enabled} subscriptions that automatically match Runner-declared events. '
+42
View File
@@ -0,0 +1,42 @@
"""Workspace plan policies evaluated from trusted entitlement projections."""
from .entitlements import EntitlementFeatureUnavailableError, EntitlementUnavailableError
from ..platform.adapter_names import canonical_adapter_name
def platform_feature_names(adapter: str) -> set[str]:
canonical = canonical_adapter_name(adapter)
names = {adapter, canonical}
if adapter.lower() == "line":
names.update({"line", "LINE"})
if canonical.endswith('-omni'):
names.add(canonical[:-5])
names.add(canonical[:-5] + '-eba')
return {'platform.' + name for name in names}
async def workspace_entitlement(ap, workspace_uuid: str):
if getattr(getattr(ap, 'deployment', None), 'mode', None) != 'cloud':
return None
resolver = getattr(ap, 'entitlement_resolver', None)
if resolver is None:
raise EntitlementUnavailableError('Workspace entitlement resolver is unavailable')
return await resolver.resolve(workspace_uuid)
async def require_workspace_platform(ap, workspace_uuid: str, adapter: str) -> None:
snapshot = await workspace_entitlement(ap, workspace_uuid)
if snapshot is None:
return
for feature in platform_feature_names(adapter):
if snapshot.features.get(feature) is False:
raise EntitlementFeatureUnavailableError(feature, entitlement_revision=snapshot.entitlement_revision)
async def workspace_history_retention_days(ap, workspace_uuid: str, fallback: int = 30) -> int:
snapshot = await workspace_entitlement(ap, workspace_uuid)
if snapshot is None:
return fallback
# Old signed snapshots inherit the existing 30-day policy. Never interpret
# missing entitlement data as an unlimited retention policy.
return max(1, snapshot.limits.get('chat_history.retention_days', 30))
+1 -1
View File
@@ -427,7 +427,7 @@ class Application:
monitoring_cfg = self.instance_config.data.get('monitoring', {})
auto_cleanup_cfg = monitoring_cfg.get('auto_cleanup', {})
monitoring_enabled = auto_cleanup_cfg.get('enabled', True)
monitoring_enabled = auto_cleanup_cfg.get('enabled', True) or getattr(getattr(self, 'deployment', None), 'mode', None) == 'cloud'
retention_days = self._get_positive_int_config(
auto_cleanup_cfg.get('retention_days', 30),
default=30,
+6
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
from ..cloud.plan_policy import require_workspace_platform
from ..i18n import language_scope
from ..i18n.workspace import get_workspace_language
@@ -117,6 +119,7 @@ class RuntimeBot:
async def assert_execution_active(self) -> None:
"""Fail closed when this long-lived adapter belongs to a stale placement."""
await require_workspace_platform(self.ap, self.workspace_uuid, self.bot_entity.adapter)
await self.ap.workspace_service.get_execution_binding(
self.workspace_uuid,
expected_generation=self.placement_generation,
@@ -919,6 +922,7 @@ class RuntimeBot:
from ..telemetry import trace as trace_mod
from ..telemetry.execution import ingress
await self.assert_execution_active()
event.bot_uuid = self.bot_entity.uuid
execution_id = self._platform_event_execution_id(event)
if getattr(event, 'adapter_name', None) == 'wecomcs-omni':
@@ -2316,6 +2320,7 @@ class PlatformManager:
bot_entity = persistence_bot.Bot(**bot_entity)
execution_context = self._normalize_execution_context(context, bot_uuid=bot_entity.uuid)
await require_workspace_platform(self.ap, execution_context.workspace_uuid, bot_entity.adapter)
if bot_entity.workspace_uuid != execution_context.workspace_uuid:
raise WorkspaceRequiredError('Bot entity Workspace does not match its runtime context')
if not _binding_validated:
@@ -2405,6 +2410,7 @@ class PlatformManager:
)
if bot is None or bot.placement_generation != execution_context.placement_generation:
return None
await require_workspace_platform(self.ap, execution_context.workspace_uuid, bot.bot_entity.adapter)
return bot
async def resolve_public_bot(self, route_key: str) -> RuntimeBot | None:
@@ -76,3 +76,21 @@ def test_execution_context_preserves_entitlement_revision():
)
assert ExecutionContext.from_request(request).entitlement_revision == 11
@pytest.mark.asyncio
async def test_handler_entitlement_denial_is_403_not_internal_error():
from langbot.pkg.api.http.controller.group import AuthType
from langbot.pkg.cloud.entitlements import EntitlementFeatureUnavailableError
router = _router(SimpleNamespace(mode='cloud'))
router.path = '/policy'
router.name = 'policy'
@router.route('', methods=['GET'], auth_type=AuthType.NONE)
async def denied():
raise EntitlementFeatureUnavailableError('platform.telegram', entitlement_revision=2)
response = await router.quart_app.test_client().get('/policy')
assert response.status_code == 403
assert (await response.get_json())['code'] == 'entitlement_unavailable'
@@ -423,3 +423,21 @@ async def test_cleanup_commits_sqlite_delete_before_vacuum(tmp_path):
assert remaining == 3
finally:
await engine.dispose()
async def test_cloud_cleanup_uses_plan_retention_and_isolates_workspaces(service):
from unittest.mock import AsyncMock
service.ap.deployment = SimpleNamespace(mode='cloud')
service.ap.entitlement_resolver = SimpleNamespace(resolve=AsyncMock(return_value=SimpleNamespace(limits={'chat_history.retention_days': 7})))
old = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) - datetime.timedelta(days=10)
for workspace in (WORKSPACE_A, WORKSPACE_B):
await service.ap.persistence_mgr.execute_async(sqlalchemy.insert(MonitoringMessage).values(
id=f'policy-{workspace}', workspace_uuid=workspace, timestamp=old,
bot_id='bot', bot_name='Bot', pipeline_id='pipeline', pipeline_name='Pipeline',
message_content='history', session_id='session', status='success', level='info',
))
deleted = await service.cleanup_expired_records(_context(WORKSPACE_A), retention_days=90)
assert deleted['monitoring_messages'] == 1
rows = await service.ap.persistence_mgr.execute_async(sqlalchemy.select(MonitoringMessage.workspace_uuid))
assert rows.scalars().all() == [WORKSPACE_B]
@@ -0,0 +1,44 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from langbot.pkg.cloud.entitlements import EntitlementFeatureUnavailableError, EntitlementUnavailableError
from langbot.pkg.cloud.plan_policy import require_workspace_platform, workspace_history_retention_days
def cloud_app():
snapshots = {
'restricted': SimpleNamespace(features={'platform.telegram': False}, limits={'chat_history.retention_days': 7}, entitlement_revision=2),
'open': SimpleNamespace(features={}, limits={'chat_history.retention_days': 90}, entitlement_revision=3),
}
return SimpleNamespace(deployment=SimpleNamespace(mode='cloud'), entitlement_resolver=SimpleNamespace(resolve=AsyncMock(side_effect=lambda ws: snapshots[ws])))
@pytest.mark.asyncio
async def test_platform_policy_is_scoped_and_covers_aliases():
ap = cloud_app()
for adapter in ['telegram', 'telegram-omni', 'telegram-eba']:
with pytest.raises(EntitlementFeatureUnavailableError):
await require_workspace_platform(ap, 'restricted', adapter)
await require_workspace_platform(ap, 'open', adapter)
await require_workspace_platform(ap, 'restricted', 'discord')
@pytest.mark.asyncio
async def test_retention_uses_workspace_entitlement():
ap = cloud_app()
assert await workspace_history_retention_days(ap, 'restricted', 100) == 7
assert await workspace_history_retention_days(ap, 'open', 100) == 90
@pytest.mark.asyncio
async def test_oss_and_unavailable_projection():
ap = SimpleNamespace(deployment=SimpleNamespace(mode='local'))
await require_workspace_platform(ap, 'any', 'telegram')
assert await workspace_history_retention_days(ap, 'any', 60) == 60
ap.deployment.mode = 'cloud'
with pytest.raises(EntitlementUnavailableError):
await require_workspace_platform(ap, 'any', 'telegram')
with pytest.raises(EntitlementUnavailableError):
await workspace_history_retention_days(ap, 'any')
+1519 -2512
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -195,3 +195,28 @@
body:has([data-tour-modal='true']) [data-tour-modal='false'] {
display: none;
}
/* Reserve native scrollbar space before content overflows. Extending the
utilities also covers responsive variants, nested panels and popup menus.
Horizontal-only scrollers and custom overlay scroll areas stay unchanged. */
@utility overflow-auto {
scrollbar-gutter: stable;
}
@utility overflow-y-auto {
scrollbar-gutter: stable;
}
@utility overflow-scroll {
scrollbar-gutter: stable;
}
@utility overflow-y-scroll {
scrollbar-gutter: stable;
}
@layer base {
textarea {
scrollbar-gutter: stable;
}
}
@@ -225,11 +225,21 @@ export default function WorkspaceSettingsPanel({
return (
<>
<PanelToolbar>
<div className="min-w-0">
<p className="truncate text-sm font-medium">
{workspaceInfo?.workspace.name ?? t('workspace.title')}
</p>
<PanelToolbar className="gap-4 py-4">
<div className="min-w-0 flex-1 basis-56 space-y-1.5">
<div className="flex min-w-0 items-center gap-2">
<p className="truncate text-sm font-semibold">
{workspaceInfo?.workspace.name ?? t('workspace.title')}
</p>
{workspaceInfo && (
<Badge
variant="secondary"
className="shrink-0 text-[11px] font-normal"
>
{t(`workspace.roles.${workspaceInfo.membership.role}`)}
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">
{t(
isCloudProjection
@@ -238,18 +248,27 @@ export default function WorkspaceSettingsPanel({
)}
</p>
</div>
{workspaceInfo && (
<Badge variant="secondary">
{t(`workspace.roles.${workspaceInfo.membership.role}`)}
</Badge>
)}
{isCloudProjection && workspaceInfo && (
<Button asChild size="sm">
<a href={cloudPortalURL} target="_blank" rel="noopener noreferrer">
{t('workspace.upgradePlan')}
<ExternalLink className="size-3.5" />
</a>
</Button>
<div className="ml-auto flex max-w-full flex-wrap items-center justify-end gap-3">
<div className="space-y-1 text-right">
<p className="text-[11px] text-muted-foreground">
{t('workspace.currentPlan')}
</p>
<p className="text-sm font-semibold uppercase">
{workspaceInfo.plan_name || t('workspace.planUnavailable')}
</p>
</div>
<Button asChild size="sm" className="shrink-0">
<a
href={cloudPortalURL}
target="_blank"
rel="noopener noreferrer"
>
{t('workspace.upgradePlan')}
<ExternalLink className="size-3.5" />
</a>
</Button>
</div>
)}
</PanelToolbar>
@@ -1,4 +1,4 @@
import { Building2, Check, Settings } from 'lucide-react';
import { Building2, Settings } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import {
@@ -56,7 +56,10 @@ export default function WorkspaceSwitcher({
return (
<DropdownMenuItem
key={entry.workspace.uuid}
className="min-h-11 gap-2 px-2 py-1.5"
className={cn(
'min-h-11 gap-2 px-2 py-1.5',
selected && 'bg-muted',
)}
onClick={() => {
if (!selected)
void switchWorkspaceAndReload(entry.workspace.uuid);
@@ -71,7 +74,6 @@ export default function WorkspaceSwitcher({
{entry.plan_name || t('workspace.planUnavailable')}
</span>
)}
{selected && <Check className="size-4" />}
{selected && (
<Button
variant="ghost"
+1 -1
View File
@@ -2567,7 +2567,7 @@ const enUS = {
ossSingletonDescription:
'This self-hosted instance has one Workspace and can include multiple users.',
cloudManagedDescription:
'This Workspace is hosted by LangBot Cloud. Manage members here; billing opens in Cloud.',
'This Workspace is hosted by LangBot Cloud.',
loadFailed: 'Failed to load Workspace information',
members: 'Members',
you: 'You',
+1 -1
View File
@@ -2618,7 +2618,7 @@ const esES = {
ossSingletonDescription:
'This self-hosted instance has one Workspace and can include multiple users.',
cloudManagedDescription:
'This Workspace is hosted by LangBot Cloud. Manage members here; billing opens in Cloud.',
'This Workspace is hosted by LangBot Cloud.',
loadFailed: 'Failed to load Workspace information',
members: 'Members',
you: 'You',
+1 -1
View File
@@ -2581,7 +2581,7 @@ const jaJP = {
ossSingletonDescription:
'このセルフホストインスタンスには1つのワークスペースがあり、複数のユーザーを追加できます。',
cloudManagedDescription:
'このワークスペースは LangBot Cloud でホストされています。メンバーはここで管理し、請求は Cloud で開きます。',
'このワークスペースは LangBot Cloud でホストされています。',
loadFailed: 'ワークスペース情報の読み込みに失敗しました',
members: 'メンバー',
you: 'あなた',
+1 -1
View File
@@ -2590,7 +2590,7 @@ const ruRU = {
ossSingletonDescription:
'This self-hosted instance has one Workspace and can include multiple users.',
cloudManagedDescription:
'This Workspace is hosted by LangBot Cloud. Manage members here; billing opens in Cloud.',
'This Workspace is hosted by LangBot Cloud.',
loadFailed: 'Failed to load Workspace information',
members: 'Members',
you: 'You',
+1 -1
View File
@@ -2511,7 +2511,7 @@ const thTH = {
ossSingletonDescription:
'This self-hosted instance has one Workspace and can include multiple users.',
cloudManagedDescription:
'This Workspace is hosted by LangBot Cloud. Manage members here; billing opens in Cloud.',
'This Workspace is hosted by LangBot Cloud.',
loadFailed: 'Failed to load Workspace information',
members: 'Members',
you: 'You',
+1 -1
View File
@@ -2549,7 +2549,7 @@ const viVN = {
ossSingletonDescription:
'This self-hosted instance has one Workspace and can include multiple users.',
cloudManagedDescription:
'This Workspace is hosted by LangBot Cloud. Manage members here; billing opens in Cloud.',
'This Workspace is hosted by LangBot Cloud.',
loadFailed: 'Failed to load Workspace information',
members: 'Members',
you: 'You',
+1 -1
View File
@@ -2407,7 +2407,7 @@ const zhHans = {
ossSingletonDescription:
'当前自托管实例只有一个工作区,但可以包含多个用户。',
cloudManagedDescription:
'此工作区托管于 LangBot Cloud。成员在此管理,计费在 Cloud 中打开。',
'此工作区托管于 LangBot Cloud。',
loadFailed: '加载工作区信息失败',
members: '成员',
you: '你',
+1 -1
View File
@@ -2405,7 +2405,7 @@ const zhHant = {
ossSingletonDescription:
'This self-hosted instance has one Workspace and can include multiple users.',
cloudManagedDescription:
'This Workspace is hosted by LangBot Cloud. Manage members here; billing opens in Cloud.',
'This Workspace is hosted by LangBot Cloud.',
loadFailed: 'Failed to load Workspace information',
members: 'Members',
you: 'You',