Compare commits

..

1 Commits

Author SHA1 Message Date
dadachann 24602abcc8 fix(cloud): authenticate plugin assets and report workspace resources
(cherry picked from commit 61faa68194)
2026-07-31 10:17:43 +00:00
83 changed files with 978 additions and 3812 deletions
+20 -1
View File
@@ -15,7 +15,7 @@ concurrency:
env:
CORE_IMAGE: ${{ secrets.DOCKER_USERNAME }}/langbot
CLOUD_IMAGE: ${{ secrets.DOCKER_USERNAME }}/langbot-cloud-core
SPACE_REF: 178b1634c104e635c706e1fb4ca37cb3de073cf9
SPACE_REF: e1b261dac45e886efc667b1096a4ec493c6a6111
jobs:
build-and-deploy:
@@ -57,3 +57,22 @@ jobs:
${{ env.CLOUD_IMAGE }}:deploy-prod
cache-from: type=gha,scope=cloud-core-prod
cache-to: type=gha,mode=max,scope=cloud-core-prod
- name: Configure SSH
env:
SSH_KEY: ${{ secrets.JP09_SSH_KEY }}
KNOWN_HOSTS: ${{ secrets.JP09_KNOWN_HOSTS }}
run: |
install -m 700 -d ~/.ssh
install -m 600 /dev/null ~/.ssh/id_ed25519
printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519
printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts
- name: Upload release manifest and deploy
env:
HOST: ${{ secrets.JP09_HOST }}
USER: ${{ secrets.JP09_USER }}
PORT: ${{ secrets.JP09_PORT }}
run: |
remote="$USER@$HOST"
ssh -p "$PORT" "$remote" 'install -d -m 700 /opt/langbot-cloud-prod'
scp -P "$PORT" deploy/prod/docker-compose.yml deploy/prod/deploy.sh "$remote:/opt/langbot-cloud-prod/"
ssh -p "$PORT" "$remote" "chmod 700 /opt/langbot-cloud-prod/deploy.sh && /opt/langbot-cloud-prod/deploy.sh prod-${GITHUB_SHA}"
-5
View File
@@ -15,10 +15,6 @@ 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
@@ -48,7 +44,6 @@ 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
-1
View File
@@ -88,7 +88,6 @@ services:
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
@@ -81,10 +81,10 @@ This log records implementation choices made while delivering the Workspace arch
- Decision: The MCP ASGI mount authenticates the API key once, binds an immutable per-request `RequestContext`, and every tool checks a fixed permission before calling tenant services with that same context.
- Reason: Authenticating the transport without propagating Workspace identity into tool calls would leave the direct service path globally scoped.
### Unreleased SDK protocol is pinned reproducibly without publishing
### Released SDK protocol is pinned from PyPI
- Decision: The SDK tenancy protocol is versioned as 0.4.18. This task does not create a GitHub release or publish PyPI because the user authorized pushing code, not a package release. After the SDK feature branch is final, LangBot's feature branch temporarily pins the exact pushed SDK Git commit. Before merging to master, the release gate is to publish `langbot-plugin==0.4.18` and replace the Git pin with the registry pin.
- Reason: The current registry release does not contain the complete tenant action context and shared Runtime hardening. An exact Git commit is reproducible and keeps the feature branch testable without expanding release authority.
- Decision: The SDK tenancy protocol is released as `langbot-plugin==0.5.0` and LangBot pins that exact registry version.
- Reason: The final PyPI release contains the complete tenant action context and shared Runtime hardening, while the exact version pin keeps production installs reproducible.
### Cloud directory writes stay outside Core
+2 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "langbot"
version = "4.10.7"
version = "4.10.6"
description = "Production-grade platform for building agentic IM bots"
readme = "README.md"
license-files = ["LICENSE"]
@@ -71,7 +71,7 @@ dependencies = [
"chromadb>=1.0.0,<2.0.0",
"qdrant-client (>=1.15.1,<2.0.0)",
"pyseekdb==1.1.0.post3",
"langbot-plugin @ git+https://github.com/langbot-app/langbot-plugin-sdk.git@101e453e916b39465a6294d6471c9eaae8725d5c",
"langbot-plugin==0.5.0",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"matrix-nio>=0.25.2",
-5
View File
@@ -74,11 +74,6 @@ class AuthorizationError(Exception):
error_code = 'forbidden'
class AuthenticationDeniedError(AuthorizationError):
status_code = 401
error_code = 'invalid_authentication'
class WorkspaceRequiredError(AuthorizationError):
status_code = 400
error_code = 'workspace_required'
-3
View File
@@ -9,7 +9,6 @@ class PrincipalType(enum.StrEnum):
ACCOUNT = 'account'
API_KEY = 'api_key'
SUPPORT_ADMIN = 'support_admin'
SYSTEM = 'system'
PUBLIC_BOT = 'public_bot'
@@ -20,9 +19,7 @@ class PrincipalContext:
principal_type: PrincipalType
account_uuid: str | None = None
actor_account_uuid: str | None = None
api_key_uuid: str | None = None
support_session_id: str | None = None
@dataclasses.dataclass(frozen=True, slots=True)
+11 -118
View File
@@ -14,18 +14,10 @@ from ....utils import bounded_executor
from ....workspace.collaboration import MembershipPermissionError, WorkspaceCollaborationError
from ....workspace.errors import WorkspaceNotFoundError
from ....cloud.entitlements import EntitlementUnavailableError
from ....cloud.quotas import WorkspaceQuotaExceededError
from ....core.errors import TaskCapacityError
from ..authz import (
AuthenticationDeniedError,
AuthorizationError,
Permission,
PermissionDeniedError,
WorkspaceRequiredError,
permissions_for_role,
require_permission,
)
from ..authz import AuthorizationError, Permission, permissions_for_role, require_permission
from ..context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext
from ....cloud.support_admin import SupportAdminSessionError
if typing.TYPE_CHECKING:
from ....core.app import Application
@@ -60,17 +52,6 @@ class AuthType(enum.Enum):
USER_TOKEN_OR_API_KEY = 'user-token-or-api-key'
_SUPPORT_ADMIN_DENIED_PERMISSIONS = frozenset(
{
Permission.OWNER_TRANSFER.value,
Permission.MEMBER_VIEW.value,
Permission.MEMBER_INVITE.value,
Permission.MEMBER_UPDATE_ROLE.value,
Permission.MEMBER_REMOVE.value,
}
)
class RouterGroup(abc.ABC):
name: str
@@ -115,10 +96,6 @@ class RouterGroup(abc.ABC):
return self.http_status(401, -1, 'No valid user token provided')
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-token routes deliberately stop before Workspace
# selection. They may bootstrap a selector, but cannot
@@ -135,13 +112,8 @@ class RouterGroup(abc.ABC):
return self.http_status(401, -1, 'No valid user token provided')
try:
request_context = await self._authenticate_support_admin(token, auth_type)
if request_context is not None:
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)
account, user_email = await self._authenticate_account(token)
request_context = await self._resolve_account_context(account, auth_type)
if permission is not None:
if request_context is None:
raise AuthorizationError('Workspace authorization is unavailable')
@@ -170,20 +142,10 @@ class RouterGroup(abc.ABC):
return self._auth_error_response(e)
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)
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
try:
request_context = await self._authenticate_api_key(api_key, auth_type)
@@ -194,6 +156,8 @@ class RouterGroup(abc.ABC):
return self._auth_error_response(e)
else:
# Try user token authentication (Authorization header)
token = quart.request.headers.get('Authorization', '').replace('Bearer ', '')
if not token:
return self.http_status(
401, -1, 'No valid authentication provided (user token or API key required)'
@@ -256,6 +220,8 @@ class RouterGroup(abc.ABC):
return self.http_status(403, e.code, str(e))
if isinstance(e, WorkspaceCollaborationError):
return self.http_status(400, e.code, str(e))
if isinstance(e, WorkspaceQuotaExceededError):
return self.http_status(409, e.error_code, str(e))
if isinstance(e, TaskCapacityError):
return self.http_status(429, 'task_capacity_exceeded', str(e))
if isinstance(
@@ -305,83 +271,10 @@ class RouterGroup(abc.ABC):
raise ValueError('User not found')
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(
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)
@@ -97,16 +97,6 @@ class WebSocketChatRouterGroup(group.RouterGroup):
if not token or not workspace_uuid:
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_uuid = getattr(account, 'uuid', None)
collaboration_service = getattr(self.ap, 'workspace_collaboration_service', None)
@@ -141,23 +131,6 @@ class WebSocketChatRouterGroup(group.RouterGroup):
) -> RequestContext:
"""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_uuid = getattr(account, 'uuid', None)
if account_uuid != request_context.account_uuid:
@@ -238,7 +211,6 @@ class WebSocketChatRouterGroup(group.RouterGroup):
scope=WebSocketScope.from_context(request_context),
pipeline_uuid=pipeline_uuid,
session_type=session_type,
trigger_principal=request_context.principal,
metadata={'user_agent': quart.websocket.headers.get('User-Agent', '')},
send_queue_size=(
self.ap.instance_config.data.get('system', {})
@@ -419,7 +391,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
)
elif message_type == 'message':
try:
request_context = await self._revalidate_websocket_authorization(request_context, token)
await self._revalidate_websocket_authorization(request_context, token)
except Exception:
await connection.send_queue.put({'type': 'error', 'message': 'Unauthorized'})
break
@@ -22,7 +22,6 @@ class _AdapterSessionScope:
principal_type: str
account_uuid: str | None
api_key_uuid: str | None
support_session_id: str | None
@classmethod
def from_request_context(cls, request_context: RequestContext) -> '_AdapterSessionScope':
@@ -34,7 +33,6 @@ class _AdapterSessionScope:
principal_type=principal.principal_type.value,
account_uuid=principal.account_uuid,
api_key_uuid=principal.api_key_uuid,
support_session_id=principal.support_session_id,
)
def matches(self, request_context: RequestContext) -> bool:
@@ -285,17 +285,8 @@ class UserRouterGroup(group.RouterGroup):
request_context.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_has_local_space_credentials = bool(owner and owner.space_account_uuid)
# 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
credits = (
await self.ap.space_service.get_credits(owner.user)
if owner is not None and owner.space_account_uuid
else None
)
owner_space_bound = bool(owner and owner.space_account_uuid)
credits = await self.ap.space_service.get_credits(owner.user) if owner_space_bound else None
return self.success(
data={
'credits': credits,
@@ -311,10 +302,8 @@ class UserRouterGroup(group.RouterGroup):
return self.success(data={'initialized': False})
capabilities = await self.ap.user_service.get_login_capabilities()
cloud_mode = getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud'
if cloud_mode:
if getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud':
capabilities['password_login_enabled'] = False
capabilities['authenticated_invitation_acceptance_enabled'] = cloud_mode
return self.success(data={'initialized': True, **capabilities})
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
@@ -407,19 +396,6 @@ class UserRouterGroup(group.RouterGroup):
launch_assertion,
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'])
if account is None:
raise SpaceLaunchError('Launch Account is not projected into Core')
@@ -434,6 +410,7 @@ class UserRouterGroup(group.RouterGroup):
'token': token,
'user': account.user,
'workspace_uuid': access.workspace.uuid,
'return_path': launch.get('return_path', '/home'),
}
)
except SpaceLaunchError:
@@ -5,7 +5,7 @@ import typing
import quart
from ...authz import Permission, permissions_for_role
from ...context import PrincipalType, RequestContext
from ...context import RequestContext
from ...service.user import AccountExistsLoginRequiredError, ControlPlaneDirectoryRequiredError
from .....entity.persistence.workspace import Workspace, WorkspaceInvitation, WorkspaceMembership
from .....entity.persistence.workspace import WorkspaceSource
@@ -120,6 +120,9 @@ class WorkspacesRouterGroup(group.RouterGroup):
@self.route('/current', methods=['GET'], permission=Permission.WORKSPACE_VIEW)
async def _(request_context: RequestContext) -> typing.Any:
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)
plan_name: str | None = None
resolver = getattr(self.ap, 'entitlement_resolver', None)
@@ -129,28 +132,6 @@ class WorkspacesRouterGroup(group.RouterGroup):
minimum_revision=request_context.entitlement_revision,
)
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,
'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(
data={
'workspace': _workspace_payload(workspace),
+28 -8
View File
@@ -4,6 +4,7 @@ import uuid
import sqlalchemy
from ....core import app
from ....cloud.quotas import require_resource_capacity, resolve_workspace_quota
from ....entity.persistence import bot as persistence_bot
from ....entity.persistence import pipeline as persistence_pipeline
from ....workspace.errors import WorkspaceNotFoundError
@@ -101,20 +102,21 @@ class BotService:
async def create_bot(self, context: TenantContext, bot_data: dict) -> str:
"""Create bot"""
workspace_uuid = require_workspace_uuid(context)
# Check limitation
limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})
max_bots = limitation.get('max_bots', -1)
if max_bots >= 0:
existing_bots = await self.get_bots(context)
if len(existing_bots) >= max_bots:
raise ValueError(f'Maximum number of bots ({max_bots}) reached')
quota = await resolve_workspace_quota(
self.ap,
workspace_uuid,
'bots.max',
fallback=limitation.get('max_bots', -1),
)
# TODO: 检查配置信息格式
bot_data = bot_data.copy()
bot_data['uuid'] = str(uuid.uuid4())
bot_data['workspace_uuid'] = workspace_uuid
# bind the most recently updated pipeline if any exist
# Preserve the legacy flat-row result shape for this optional lookup;
# quota admission and insertion below still share one transaction.
result = await self.ap.persistence_mgr.execute_async(
scope_statement(
sqlalchemy.select(persistence_pipeline.LegacyPipeline),
@@ -129,7 +131,25 @@ class BotService:
bot_data['use_pipeline_uuid'] = pipeline.uuid
bot_data['use_pipeline_name'] = pipeline.name
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_bot.Bot).values(bot_data))
async def persist(execute) -> None:
await require_resource_capacity(
execute,
workspace_uuid=workspace_uuid,
model=persistence_bot.Bot,
quota=quota,
resource_name='bots',
)
await execute(sqlalchemy.insert(persistence_bot.Bot).values(bot_data))
tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
if quota.requires_transaction_lock:
if not callable(tenant_uow):
raise RuntimeError('Cloud bot quota enforcement requires transactional persistence')
async with tenant_uow(workspace_uuid) as uow:
await persist(uow.execute)
else:
await persist(self.ap.persistence_mgr.execute_async)
bot = await self.get_bot(context, bot_data['uuid'], include_secret=True)
+5 -70
View File
@@ -5,7 +5,6 @@ import uuid
import sqlalchemy
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 ....entity.persistence import model as persistence_model
from ....entity.persistence import pipeline as persistence_pipeline
@@ -114,23 +113,6 @@ async def _require_workspace_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(
ap: app.Application,
context: TenantContext,
@@ -231,7 +213,6 @@ class LLMModelsService:
model_data['provider_uuid'] = 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 self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_model.LLMModel).values(**model_data))
@@ -310,17 +291,11 @@ class LLMModelsService:
return model_dict
async def update_llm_model(
self,
context: TenantContext,
model_uuid: str,
model_data: dict,
) -> None:
async def update_llm_model(self, context: TenantContext, model_uuid: str, model_data: dict) -> None:
"""Update an existing LLM model"""
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'])
model_data = model_data.copy()
model_data.pop('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'])
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')
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:
"""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(
scope_statement(
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]
async def create_embedding_model(
self,
context: TenantContext,
model_data: dict,
preserve_uuid: bool = False,
self, context: TenantContext, model_data: dict, preserve_uuid: bool = False
) -> str:
"""Create a new embedding model"""
model_data = model_data.copy()
@@ -506,7 +472,6 @@ class EmbeddingModelsService:
model_data['provider_uuid'] = 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 self.ap.persistence_mgr.execute_async(
@@ -565,17 +530,11 @@ class EmbeddingModelsService:
return model_dict
async def update_embedding_model(
self,
context: TenantContext,
model_uuid: str,
model_data: dict,
) -> None:
async def update_embedding_model(self, context: TenantContext, model_uuid: str, model_data: dict) -> None:
"""Update an existing embedding model"""
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'])
model_data = model_data.copy()
model_data.pop('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'])
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')
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:
"""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(
scope_statement(
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]
return serialized if include_secret else [_redact_model_secrets(model) for model in serialized]
async def create_rerank_model(
self,
context: TenantContext,
model_data: dict,
preserve_uuid: bool = False,
) -> str:
async def create_rerank_model(self, context: TenantContext, model_data: dict, preserve_uuid: bool = False) -> str:
"""Create a new rerank model"""
model_data = model_data.copy()
if not preserve_uuid:
@@ -760,7 +708,6 @@ class RerankModelsService:
model_data['provider_uuid'] = 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 self.ap.persistence_mgr.execute_async(
@@ -819,17 +766,11 @@ class RerankModelsService:
return model_dict
async def update_rerank_model(
self,
context: TenantContext,
model_uuid: str,
model_data: dict,
) -> None:
async def update_rerank_model(self, context: TenantContext, model_uuid: str, model_data: dict) -> None:
"""Update an existing rerank model"""
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'])
model_data = model_data.copy()
model_data.pop('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'])
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')
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:
"""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(
scope_statement(
sqlalchemy.delete(persistence_model.RerankModel).where(
@@ -5,7 +5,6 @@ import traceback
import sqlalchemy
from ....cloud.model_catalog import LANGBOT_MODELS_PROVIDER_REQUESTER
from ....core import app
from ....entity.persistence import model as persistence_model
from ....workspace.errors import WorkspaceNotFoundError
@@ -21,20 +20,6 @@ class ModelProviderService:
def __init__(self, ap: app.Application) -> None:
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
def _normalize_api_keys(api_keys: str | list[str] | tuple[str, ...] | None) -> list[str]:
if api_keys is None:
@@ -114,8 +99,6 @@ class ModelProviderService:
async def create_provider(self, context: TenantContext, provider_data: dict) -> str:
"""Create a new provider"""
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['workspace_uuid'] = require_workspace_uuid(context)
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:
"""Update an existing provider"""
await self._assert_provider_mutable(context, provider_uuid)
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('workspace_uuid', None)
if 'api_keys' in provider_data:
@@ -165,7 +145,6 @@ class ModelProviderService:
async def delete_provider(self, context: TenantContext, provider_uuid: str) -> None:
"""Delete a provider (only if no models reference it)"""
await self._assert_provider_mutable(context, provider_uuid)
workspace_uuid = require_workspace_uuid(context)
# Check if any models use this provider
llm_result = await self.ap.persistence_mgr.execute_async(
@@ -266,8 +245,6 @@ class ModelProviderService:
api_keys: list,
) -> str:
"""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)
api_keys = self._normalize_api_keys(restore_secret_placeholders(api_keys, sensitive=True))
+2 -5
View File
@@ -400,10 +400,7 @@ 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) -> str:
jwt_secret = self.ap.instance_config.data['system']['jwt']['secret']
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.
account_obj = None
payload: dict[str, typing.Any] = {
payload = {
'user': user_email,
'iss': self._jwt_identity()[0],
'aud': self._jwt_identity()[1],
-5
View File
@@ -13,7 +13,6 @@ from typing import Any, Protocol, runtime_checkable
from ..workspace.policy import CloudWorkspacePolicy, SingleWorkspacePolicy
from .directory import DirectoryProjectionProvider, directory_projection_limits_from_config
from .entitlements import EntitlementProvider, OpenSourceEntitlementProvider
from .model_catalog import CloudModelCatalogProvider
CLOUD_BOOTSTRAP_ENTRY_POINT = 'langbot.cloud_bootstrap'
@@ -51,7 +50,6 @@ class OpenSourceDeployment:
)
directory_provider: None = None
manifest_provider: None = None
model_catalog_provider: None = None
persistence_mode: str = 'oss_compat'
required_vector_backend: str | None = None
@@ -82,7 +80,6 @@ class VerifiedCloudDeployment:
entitlement_provider: EntitlementProvider
directory_provider: DirectoryProjectionProvider
manifest_provider: CloudManifestProvider
model_catalog_provider: CloudModelCatalogProvider
verification_key_id: str
mode: str = dataclasses.field(default='cloud', 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')
if not isinstance(self.manifest_provider, CloudManifestProvider):
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:
try:
+48 -62
View File
@@ -3,9 +3,11 @@ from __future__ import annotations
import asyncio
import base64
import binascii
import datetime
import hashlib
import heapq
import json
import math
import os
import time
import typing
@@ -14,8 +16,10 @@ from collections.abc import Callable, Iterable
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
import sqlalchemy
from sqlalchemy.dialects.postgresql import insert as pg_insert
from .support_admin import SupportAdminReplayError, SupportAdminSessionError, hash_grant_jti
from ..entity.persistence.cloud_directory import SpaceLaunchAssertionConsumption
if typing.TYPE_CHECKING:
from ..core.app import Application
@@ -23,7 +27,6 @@ if typing.TYPE_CHECKING:
CONTROL_PLANE_TYP = 'langbot-control-plane+jwt'
LAUNCH_KIND = 'workspace.launch'
SUPPORT_ADMIN_LAUNCH_KIND = 'workspace.support_admin_launch'
EXPECTED_ISSUER = 'langbot-space'
EXPECTED_AUDIENCE = 'langbot-cloud-runtime'
_CONSUMED_JTI_MAX_ENTRIES = 4096
@@ -122,66 +125,30 @@ class SpaceLaunchService:
*,
expected_workspace_uuid: str | None = None,
) -> dict[str, str]:
claims = self._verify_assertion(assertion)
claims, clock_skew_seconds = self._verify_assertion(assertion)
payload = claims.get('payload')
if not isinstance(payload, dict):
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')
return_path = _required_string(payload, 'return_path')
if (
not return_path.startswith('/')
or return_path.startswith('//')
or any(character in return_path for character in ('\\', '\r', '\n', '\t'))
):
raise SpaceLaunchError('Launch assertion return path is invalid')
if expected_workspace_uuid is not None and workspace_uuid != expected_workspace_uuid:
raise SpaceLaunchError('Launch assertion targets another Workspace')
if kind == SUPPORT_ADMIN_LAUNCH_KIND:
if 'account_uuid' in payload:
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 = {
replay_retention_expires_at = _required_int(claims, 'exp', minimum=1) + math.ceil(clock_skew_seconds)
await self._consume_jti(_required_string(claims, 'jti'), replay_retention_expires_at)
return {
'account_uuid': account_uuid,
'workspace_uuid': workspace_uuid,
'return_path': return_path,
}
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) -> tuple[dict[str, typing.Any], float]:
if not getattr(getattr(self.ap, 'deployment', None), 'multi_workspace_enabled', False):
raise SpaceLaunchError('Space direct launch requires verified Cloud mode')
public_key, key_id, clock_skew_seconds = self._trust_config()
@@ -217,9 +184,8 @@ class SpaceLaunchService:
raise SpaceLaunchError('Launch assertion subject targets another instance')
if _required_string(claims, 'instance_uuid') != instance_uuid:
raise SpaceLaunchError('Launch assertion instance UUID does not match this Core')
kind = _required_string(claims, 'kind')
if kind not in {LAUNCH_KIND, SUPPORT_ADMIN_LAUNCH_KIND}:
raise SpaceLaunchError('Launch assertion kind is not supported')
if _required_string(claims, 'kind') != LAUNCH_KIND:
raise SpaceLaunchError('Launch assertion kind is not workspace.launch')
issued_at = _required_int(claims, 'iat')
not_before = _required_int(claims, 'nbf')
@@ -233,7 +199,7 @@ class SpaceLaunchService:
raise SpaceLaunchError('Launch assertion is expired')
if expires_at <= max(issued_at, not_before):
raise SpaceLaunchError('Launch assertion expiry must follow issue time')
return claims
return claims, clock_skew_seconds
def _trust_config(self) -> tuple[Ed25519PublicKey, str, float]:
data = getattr(getattr(self.ap, 'instance_config', None), 'data', {}) or {}
@@ -263,19 +229,39 @@ class SpaceLaunchService:
async def _consume_jti(self, jti: str, expires_at: int) -> None:
digest = hashlib.sha256(jti.encode('utf-8')).hexdigest()
now = int(self._wall_time())
persistence_mgr = getattr(self.ap, 'persistence_mgr', None)
instance_uuid = str(self.ap.workspace_service.instance_uuid)
if persistence_mgr is not None:
expires_at_datetime = datetime.datetime.fromtimestamp(expires_at, tz=datetime.timezone.utc)
now_datetime = datetime.datetime.fromtimestamp(now, tz=datetime.timezone.utc)
async with persistence_mgr.directory_projection_uow(instance_uuid) as uow:
await uow.session.execute(
sqlalchemy.delete(SpaceLaunchAssertionConsumption).where(
SpaceLaunchAssertionConsumption.instance_uuid == instance_uuid,
SpaceLaunchAssertionConsumption.expires_at < now_datetime,
)
)
statement = (
pg_insert(SpaceLaunchAssertionConsumption)
.values(instance_uuid=instance_uuid, jti=digest, expires_at=expires_at_datetime)
.on_conflict_do_nothing(index_elements=['instance_uuid', 'jti'])
.returning(SpaceLaunchAssertionConsumption.jti)
)
result = await uow.session.execute(statement)
if result.scalar_one_or_none() is None:
raise SpaceLaunchError('Launch assertion has already been consumed')
return
# Lightweight unit-test and OSS compatibility fallback. Verified Cloud
# runtime always supplies the durable PostgreSQL persistence manager.
async with self._replay_lock:
self._prune_consumed_jtis(now)
if digest in self._consumed_jtis:
raise SpaceLaunchError('Launch assertion has already been consumed')
if len(self._consumed_jtis) >= _CONSUMED_JTI_MAX_ENTRIES:
# Evicting a still-valid digest would make a signed launch
# assertion replayable. Bound memory by failing closed instead.
raise SpaceLaunchError('Launch assertion replay cache capacity reached')
self._consumed_jtis[digest] = expires_at
heapq.heappush(
self._consumed_jti_expiry_heap,
(expires_at, digest),
)
heapq.heappush(self._consumed_jti_expiry_heap, (expires_at, digest))
def _prune_consumed_jtis(self, now: int) -> None:
while self._consumed_jti_expiry_heap:
-320
View File
@@ -1,320 +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
@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
async def initialize(self) -> None:
await self.sync_once(reload_runtime=False)
async def run(self) -> None:
while True:
await asyncio.sleep(self.sync_interval_seconds)
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]
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}')
+82
View File
@@ -0,0 +1,82 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Awaitable, Callable
import sqlalchemy
from ..entity.persistence import workspace as persistence_workspace
from .entitlements import EntitlementResolver
Execute = Callable[[Any], Awaitable[Any]]
class WorkspaceQuotaExceededError(ValueError):
"""A stable business error raised when a workspace has no free slots."""
error_code = 'workspace_quota_exceeded'
def __init__(self, resource_name: str, limit: int) -> None:
self.resource_name = resource_name
self.limit = limit
super().__init__(f'Maximum number of {resource_name} ({limit}) reached')
@dataclass(frozen=True, slots=True)
class WorkspaceQuota:
limit: int
requires_transaction_lock: bool
async def resolve_workspace_quota(
ap: Any,
workspace_uuid: str,
limit_name: str,
*,
fallback: int = -1,
) -> WorkspaceQuota:
"""Resolve a plan-agnostic Cloud limit while preserving OSS configuration."""
resolver = getattr(ap, 'entitlement_resolver', None)
if isinstance(resolver, EntitlementResolver):
snapshot = await resolver.resolve(workspace_uuid)
return WorkspaceQuota(
limit=snapshot.limit(limit_name),
requires_transaction_lock=True,
)
return WorkspaceQuota(limit=fallback, requires_transaction_lock=False)
async def lock_workspace_for_quota(execute: Execute, workspace_uuid: str) -> None:
"""Serialize quota checks on the durable Workspace row within one transaction."""
result = await execute(
sqlalchemy.select(persistence_workspace.Workspace.uuid)
.where(persistence_workspace.Workspace.uuid == workspace_uuid)
.with_for_update()
)
if result.first() is None:
raise ValueError('Workspace does not exist')
async def require_resource_capacity(
execute: Execute,
*,
workspace_uuid: str,
model: type,
quota: WorkspaceQuota,
resource_name: str,
workspace_locked: bool = False,
) -> None:
if quota.limit < 0:
return
if quota.requires_transaction_lock and not workspace_locked:
await lock_workspace_for_quota(execute, workspace_uuid)
result = await execute(
sqlalchemy.select(sqlalchemy.func.count())
.select_from(model)
.where(model.workspace_uuid == workspace_uuid)
)
if int(result.scalar_one()) >= quota.limit:
raise WorkspaceQuotaExceededError(resource_name, quota.limit)
-248
View File
@@ -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)
+3 -12
View File
@@ -51,10 +51,8 @@ from ..workspace import collaboration as workspace_collaboration_module
from ..workspace import invitation_delivery as invitation_delivery_module
from ..cloud import bootstrap as cloud_bootstrap_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 entitlements as cloud_entitlements_module
from ..cloud import model_catalog as cloud_model_catalog_module
from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType
@@ -138,17 +136,16 @@ class Application:
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_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
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
http_ctrl: http_controller.HTTPController = None
@@ -306,12 +303,6 @@ class Application:
name='cloud-directory-projection',
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:
self.task_mgr.create_task(
self.manifest_refresh_service.run(),
-13
View File
@@ -42,11 +42,9 @@ from ...workspace import collaboration as workspace_collaboration_module
from ...workspace import invitation_delivery as invitation_delivery_module
from ...cloud import bootstrap as cloud_bootstrap
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_projection import DirectoryProjectionService
from ...cloud.entitlements import EntitlementResolver
from ...cloud.model_catalog import CloudModelCatalogSyncService
from ...api.http.context import ExecutionContext, PrincipalContext, PrincipalType
from ...api.http.authz import WorkspaceRequiredError
@@ -177,22 +175,11 @@ class BuildAppStage(stage.BootingStage):
# of repeating tenant validation for every manager.
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_service_inst,
)
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)
user_service_inst = user_service.UserService(ap)
@@ -67,3 +67,26 @@ class DirectoryProjectionInbox(Base):
name='ck_directory_projection_inbox_fingerprint',
),
)
class SpaceLaunchAssertionConsumption(Base):
"""Durable, instance-scoped replay ledger for signed Space launch assertions."""
__tablename__ = 'space_launch_assertion_consumptions'
instance_uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
jti = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
expires_at = sqlalchemy.Column(sqlalchemy.DateTime(timezone=True), nullable=False)
consumed_at = sqlalchemy.Column(
sqlalchemy.DateTime(timezone=True),
nullable=False,
server_default=sqlalchemy.func.now(),
)
__table_args__ = (
sqlalchemy.Index(
'ix_space_launch_assertion_consumptions_expiry',
'instance_uuid',
'expires_at',
),
)
@@ -1,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',
),
)
@@ -18,17 +18,6 @@ down_revision = '0008_mcp_resource_prefs'
branch_labels = 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]:
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)
)
else:
workspace_uuid = _workspace_uuid_from_instance_id(instance_uuid)
workspace_uuid = str(uuid.uuid4())
conn.execute(
workspaces.insert().values(
uuid=workspace_uuid,
@@ -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
View File
@@ -54,7 +54,6 @@ _ALEMBIC_TENANT_TABLES = {
'workspace_memberships',
'workspace_invitations',
'workspace_execution_states',
'support_admin_temporary_sessions',
'workspace_metadata',
'api_keys',
'bots',
+2 -2
View File
@@ -43,7 +43,6 @@ TENANT_TABLE_COLUMNS: dict[str, str] = {
'workspace_memberships': 'workspace_uuid',
'workspace_invitations': 'workspace_uuid',
'workspace_execution_states': 'workspace_uuid',
'support_admin_temporary_sessions': 'workspace_uuid',
'workspace_metadata': 'workspace_uuid',
'api_keys': 'workspace_uuid',
'bots': 'workspace_uuid',
@@ -76,6 +75,7 @@ TENANT_TABLE_COLUMNS: dict[str, str] = {
DIRECTORY_PROJECTION_TABLE_COLUMNS: dict[str, str] = {
'directory_projection_states': 'instance_uuid',
'directory_projection_inbox': 'instance_uuid',
'space_launch_assertion_consumptions': 'instance_uuid',
}
DIRECTORY_PROJECTED_TENANT_TABLES = frozenset(
@@ -209,7 +209,7 @@ _ALLOWED_SCOPED_BUILTIN_FUNCTION_TYPES = {
'now': sqlalchemy.sql.functions.now,
'sum': sqlalchemy.sql.functions.sum,
}
_ALLOWED_SCOPED_GENERIC_FUNCTIONS = frozenset({'date_trunc', 'length', 'nullif'})
_ALLOWED_SCOPED_GENERIC_FUNCTIONS = frozenset({'length', 'nullif'})
_ALLOWED_SCOPED_CUSTOM_OPERATORS = frozenset({'<=>'})
_ALLOWED_SCOPED_STATEMENT_TYPES = (
sqlalchemy.sql.dml.UpdateBase,
+3 -1
View File
@@ -132,7 +132,9 @@ class Controller:
break
if not selected_query: # 找到 说明:没有请求 或者 所有query对应的session都已达到并发上限
if selected_query: # 找到
queries.remove(selected_query)
else: # 没找到 说明:没有请求 或者 所有query对应的session都已达到并发上限
await self.ap.query_pool.condition.wait()
continue
@@ -15,7 +15,6 @@ from ....provider import runner as runner_module
import langbot_plugin.api.entities.events as events
from ....utils import importutil, constants, runner as runner_utils
from ....telemetry import features as telemetry_features
from ....telemetry.identity import workspace_identity
from ....provider import runners
import langbot_plugin.api.entities.builtin.provider.session as provider_session
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
@@ -266,8 +265,7 @@ class ChatMessageHandler(handler.MessageHandler):
'duration_ms': duration_ms,
'model_name': model_name,
'version': constants.semantic_version,
**workspace_identity(get_query_execution_context(query)),
'runtime_instance_id': constants.instance_id,
'instance_id': constants.instance_id,
'edition': constants.edition,
'pipeline_plugins': pipeline_plugins,
'features': features,
+1 -4
View File
@@ -179,13 +179,10 @@ class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverte
)
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)
message_components.append(
platform_message.Image(
url=file.file_path,
base64=f'data:{file_format};base64,{encoded.decode("utf-8")}',
)
)
@@ -707,37 +707,28 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
if len(listener_tasks) >= 100:
await self.logger.warning('WebSocket inbound listener capacity reached; dropping message')
return
listener = typing.cast(
typing.Callable[[typing.Any, typing.Any], typing.Awaitable[None]],
listeners[event.__class__],
)
async def run_listener():
token = _current_pipeline_uuid.set(pipeline_uuid)
try:
await listener(event, callback_adapter)
finally:
_current_pipeline_uuid.reset(token)
listener_coro = run_listener()
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(listener_coro)
else:
listener_task = task_manager.create_task(
listener_coro,
kind='websocket-message',
name=f'websocket-message-{connection.connection_id}',
scopes=[
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)
token = _current_pipeline_uuid.set(pipeline_uuid)
try:
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))
else:
listener_task = task_manager.create_task(
listeners[event.__class__](event, callback_adapter),
kind='websocket-message',
name=f'websocket-message-{connection.connection_id}',
scopes=[
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)
finally:
_current_pipeline_uuid.reset(token)
def get_websocket_messages(
self,
@@ -9,7 +9,7 @@ from datetime import datetime
import pydantic
from ...api.http.context import ExecutionContext, PrincipalContext
from ...api.http.context import ExecutionContext
logger = logging.getLogger(__name__)
_SESSION_FILTER_UNSET = object()
@@ -95,9 +95,6 @@ class WebSocketConnection(pydantic.BaseModel):
metadata: dict = pydantic.Field(default_factory=dict)
"""连接元数据(可存储额外信息)"""
trigger_principal: PrincipalContext | None = None
"""Authenticated principal that opened this dashboard connection."""
@property
def scope(self) -> WebSocketScope:
return WebSocketScope(
@@ -115,7 +112,6 @@ class WebSocketConnection(pydantic.BaseModel):
workspace_uuid=self.workspace_uuid,
placement_generation=self.placement_generation,
pipeline_uuid=self.pipeline_uuid,
trigger_principal=self.trigger_principal,
)
@@ -142,7 +138,6 @@ class WebSocketConnectionManager:
pipeline_uuid: str,
session_type: str,
metadata: dict | None = None,
trigger_principal: PrincipalContext | None = None,
session_id: str | None = None,
send_queue_size: int = _DEFAULT_SEND_QUEUE_SIZE,
max_connections: int = 1024,
@@ -179,7 +174,6 @@ class WebSocketConnectionManager:
session_id=session_id,
websocket=websocket,
metadata=metadata or {},
trigger_principal=trigger_principal,
send_queue=asyncio.Queue(maxsize=send_queue_size),
)
+22
View File
@@ -19,6 +19,11 @@ from urllib.parse import urljoin, urlparse
from langbot_plugin.api.entities.builtin.pipeline.query import provider_session
from ..core import app
from ..cloud.quotas import (
lock_workspace_for_quota,
require_resource_capacity,
resolve_workspace_quota,
)
from . import handler
from .archive import inspect_plugin_archive_metadata
from .github import (
@@ -1295,6 +1300,11 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
install_info: dict[str, Any],
artifact_digest: str,
) -> tuple[InstallationBinding, str | None, bool]:
quota = await resolve_workspace_quota(
self.ap,
execution_context.workspace_uuid,
'plugins.max',
)
safe_install_info = {
key: value
for key, value in install_info.items()
@@ -1316,9 +1326,19 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
)
async def persist(execute):
if quota.requires_transaction_lock:
await lock_workspace_for_quota(execute, execution_context.workspace_uuid)
result = await execute(statement)
setting = result.first()
if setting is None:
await require_resource_capacity(
execute,
workspace_uuid=execution_context.workspace_uuid,
model=persistence_plugin.PluginSetting,
quota=quota,
resource_name='plugins',
workspace_locked=quota.requires_transaction_lock,
)
installation_uuid = str(uuid.uuid4())
runtime_revision = 1
previous_digest = None
@@ -1373,6 +1393,8 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
)
tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
if quota.requires_transaction_lock and not callable(tenant_uow):
raise RuntimeError('Cloud plugin quota enforcement requires transactional persistence')
if callable(tenant_uow):
async with tenant_uow(execution_context.workspace_uuid) as uow:
return await persist(uow.execute)
+13 -60
View File
@@ -27,19 +27,6 @@ if typing.TYPE_CHECKING:
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(
ap: core_app.Application,
table,
@@ -65,13 +52,14 @@ async def _count(
return -1
async def _cloud_workspace_resource_counts(ap: core_app.Application, bindings) -> list[WorkspaceResourceSnapshot]:
async def _cloud_workspace_resource_counts(ap: core_app.Application) -> list[dict]:
"""Summarize already-loaded Cloud registries without per-tenant SQL."""
persistence_mgr = ap.persistence_mgr
if getattr(getattr(persistence_mgr, 'mode', None), 'value', None) != 'cloud_runtime':
return []
counts: dict[str, WorkspaceResourceSnapshot] = {
bindings = await ap.workspace_service.list_active_execution_bindings()
counts = {
binding.workspace_uuid: {
'workspace_uuid': binding.workspace_uuid,
'bot_count': 0,
@@ -80,20 +68,13 @@ async def _cloud_workspace_resource_counts(ap: core_app.Application, bindings) -
'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():
for key in getattr(ap.platform_mgr, '_bots_by_key', {}):
if len(key) >= 2 and key[1] in counts:
counts[key[1]]['bot_count'] += 1
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
@@ -106,23 +87,14 @@ async def _cloud_workspace_resource_counts(ap: core_app.Application, bindings) -
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():
for resource in counts.values():
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_resource: WorkspaceResourceSnapshot | None = None,
) -> dict:
"""Collect one anonymous Workspace profile snapshot."""
async def build_heartbeat_payload(ap: core_app.Application) -> dict:
"""Collect the anonymous instance profile snapshot."""
from ..entity.persistence import bot as persistence_bot
from ..entity.persistence import mcp as persistence_mcp
from ..entity.persistence import pipeline as persistence_pipeline
@@ -205,14 +177,15 @@ async def build_heartbeat_payload(
except Exception:
pass
if workspace_resource is not None:
features.update({key: value for key, value in workspace_resource.items() if key != 'workspace_uuid'})
workspace_resources = await _cloud_workspace_resource_counts(ap)
if workspace_resources:
features['workspace_resources'] = workspace_resources
return {
'event_type': 'instance_heartbeat',
'query_id': '',
'version': constants.semantic_version,
'workspace_uuid': workspace_uuid,
'instance_id': constants.instance_id,
'instance_create_ts': constants.instance_create_ts,
'edition': constants.edition,
'features': features,
@@ -220,34 +193,14 @@ async def build_heartbeat_payload(
}
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})
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_resource=resources.get(workspace_uuid),
)
for workspace_uuid in workspace_uuids
]
async def heartbeat_loop(ap: core_app.Application) -> None:
"""Send one heartbeat shortly after startup, then daily."""
# Small delay so managers (platform, skills, plugins) finish loading first
await asyncio.sleep(30)
while True:
try:
for payload in await build_heartbeat_payloads(ap):
# Heartbeats are a daily bounded batch, not best-effort query events.
# Await each send so the TelemetryManager's 8-task queue cannot drop
# Workspaces after the first batch.
await ap.telemetry.send(payload)
payload = await build_heartbeat_payload(ap)
await ap.telemetry.start_send_task(payload)
except Exception as e:
try:
ap.logger.debug(f'Telemetry heartbeat failed: {e}')
-16
View File
@@ -1,16 +0,0 @@
from __future__ import annotations
import typing
class WorkspaceExecutionContext(typing.Protocol):
@property
def workspace_uuid(self) -> str: ...
def workspace_identity(execution_context: WorkspaceExecutionContext) -> dict[str, str]:
"""Build the canonical telemetry identity for one Workspace execution."""
workspace_uuid = execution_context.workspace_uuid.strip()
if not workspace_uuid:
raise ValueError('Telemetry execution Workspace UUID is empty')
return {'workspace_uuid': workspace_uuid}
+3 -17
View File
@@ -2,11 +2,7 @@ from __future__ import annotations
import asyncio
import contextlib
import os
import typing
import httpx
from ..core import app as core_app
from ..utils import httpclient
@@ -25,7 +21,7 @@ class TelemetryManager:
def __init__(self, ap: core_app.Application):
self.ap = ap
self.telemetry_config: dict[str, typing.Any] = {}
self.telemetry_config = {}
self.send_tasks: list[asyncio.Task] = []
self._client: httpx.AsyncClient | None = None
@@ -135,16 +131,7 @@ class TelemetryManager:
async with self._client_context() as client:
try:
# Use asyncio.wait_for to ensure we always bound the total time
telemetry_token = os.getenv('LANGBOT_TELEMETRY_INGEST_TOKEN', '').strip()
if telemetry_token:
request = client.post(
url,
json=sanitized,
headers={'X-LangBot-Telemetry-Token': telemetry_token},
)
else:
request = client.post(url, json=sanitized)
resp = await asyncio.wait_for(request, timeout=10 + 1)
resp = await asyncio.wait_for(client.post(url, json=sanitized), timeout=10 + 1)
if resp.status_code >= 400:
body = await httpclient.response_text(resp, max_chars=200)
@@ -156,8 +143,7 @@ class TelemetryManager:
app_err = False
try:
j = await httpclient.parse_json_response(resp)
app_code = j.get('code') if isinstance(j, dict) else None
if app_code is not None and int(app_code) >= 400:
if isinstance(j, dict) and j.get('code') is not None and int(j.get('code')) >= 400:
app_err = True
self.ap.logger.warning(
f'Telemetry post to {url} returned application error code {j.get("code")} - {j.get("msg")}'
-20
View File
@@ -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))
+1 -2
View File
@@ -30,7 +30,6 @@ from .errors import (
WorkspaceOwnerAlreadyExistsError,
)
from .entities import WorkspaceExecutionBinding
from .identity import workspace_uuid_from_instance_id
from .policy import CloudWorkspacePolicy, SingleWorkspacePolicy
from .repository import WorkspaceRepository
@@ -498,7 +497,7 @@ class WorkspaceService:
created_by_account_uuid: str | None = None,
) -> Workspace:
return Workspace(
uuid=workspace_uuid_from_instance_id(self.instance_uuid),
uuid=str(uuid.uuid4()),
instance_uuid=self.instance_uuid,
name=name,
slug=slug,
-24
View File
@@ -9,8 +9,6 @@ Run: uv run pytest tests/integration/api/test_smoke.py -q
from __future__ import annotations
from types import SimpleNamespace
import pytest
from unittest.mock import MagicMock, AsyncMock, Mock
@@ -306,34 +304,12 @@ class TestUserInitEndpoint:
data = await response.get_json()
assert data['data'] == {
'initialized': True,
'authenticated_invitation_acceptance_enabled': False,
'password_login_enabled': True,
'space_login_enabled': False,
}
fake_api_app.user_service.get_login_capabilities.assert_awaited_once_with()
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
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
@@ -1,453 +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.OWNER_TRANSFER.value,
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 -25
View File
@@ -270,12 +270,11 @@ async def test_space_credits_are_resolved_from_workspace_owner(space_oauth_api):
response = await client.get(
'/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 payload['data'] == {
assert (await response.get_json())['data'] == {
'credits': 25000,
'owner_space_bound': True,
'is_workspace_owner': True,
@@ -283,28 +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')
@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()
response = await client.get(
'/api/v1/user/space-credits',
headers={'Authorization': 'Bearer account-token', 'X-Workspace-Id': WORKSPACE_UUID},
)
payload = await response.get_json()
assert response.status_code == 200
assert payload['data'] == {
'credits': None,
'owner_space_bound': True,
'is_workspace_owner': True,
}
application.space_service.get_credits.assert_not_awaited()
@pytest.mark.asyncio
async def test_bind_callback_uses_opaque_state_and_never_treats_it_as_jwt(space_oauth_api):
application, client = space_oauth_api
@@ -95,18 +95,6 @@ class TestSQLiteMigrationBaseline:
class TestSQLiteMigrationUpgrade:
"""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() == '0018_merge_launch_replay'
@pytest.mark.asyncio
async def test_upgrade_from_baseline_to_head(self, sqlite_engine):
"""
@@ -22,7 +22,6 @@ from langbot.pkg.persistence.alembic_runner import (
from langbot.pkg.utils import constants
from langbot.pkg.utils import importutil
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]
@@ -110,7 +109,6 @@ async def test_legacy_instance_gets_stable_accounts_and_default_workspace(legacy
.mappings()
.one()
)
assert workspace['uuid'] == workspace_uuid_from_instance_id('instance_migration_test')
assert workspace['instance_uuid'] == 'instance_migration_test'
assert workspace['slug'] == 'default'
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
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):
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-round-trip.db"}')
try:
@@ -411,47 +362,6 @@ async def test_persistence_startup_defers_workspace_tables_until_account_upgrade
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):
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "instance-drift.db"}')
try:
@@ -0,0 +1,137 @@
"""PostgreSQL integration coverage for durable workspace quota locking.
Run with TEST_POSTGRES_URL=postgresql+asyncpg://... pytest ...
"""
from __future__ import annotations
import asyncio
import os
import uuid
import pytest
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from langbot.pkg.cloud import quotas as quota_module
from langbot.pkg.cloud.quotas import WorkspaceQuota, WorkspaceQuotaExceededError, require_resource_capacity
pytestmark = [pytest.mark.integration, pytest.mark.slow, pytest.mark.asyncio]
class _Base(DeclarativeBase):
pass
class _Workspace(_Base):
__tablename__ = 'quota_integration_workspaces'
uuid: Mapped[str] = mapped_column(sa.String(36), primary_key=True)
class _Resource(_Base):
__tablename__ = 'quota_integration_resources'
uuid: Mapped[str] = mapped_column(sa.String(36), primary_key=True)
workspace_uuid: Mapped[str] = mapped_column(
sa.String(36),
sa.ForeignKey('quota_integration_workspaces.uuid', ondelete='CASCADE'),
nullable=False,
index=True,
)
@pytest.fixture
async def quota_postgres(monkeypatch):
url = os.environ.get('TEST_POSTGRES_URL')
if not url:
pytest.skip('TEST_POSTGRES_URL not set')
if url.startswith('postgresql://'):
url = url.replace('postgresql://', 'postgresql+asyncpg://', 1)
engine = create_async_engine(url, pool_size=5, max_overflow=0)
monkeypatch.setattr(quota_module.persistence_workspace, 'Workspace', _Workspace)
async with engine.begin() as connection:
await connection.run_sync(_Base.metadata.drop_all)
await connection.run_sync(_Base.metadata.create_all)
try:
yield url, engine
finally:
async with engine.begin() as connection:
await connection.run_sync(_Base.metadata.drop_all)
await engine.dispose()
async def test_workspace_row_lock_is_atomic_isolated_and_survives_pool_restart(quota_postgres) -> None:
url, engine = quota_postgres
workspace_a = str(uuid.uuid4())
workspace_b = str(uuid.uuid4())
quota = WorkspaceQuota(limit=1, requires_transaction_lock=True)
sessions = async_sessionmaker(engine, expire_on_commit=False)
async with engine.begin() as connection:
await connection.execute(sa.insert(_Workspace), [{'uuid': workspace_a}, {'uuid': workspace_b}])
lock_acquired = asyncio.Event()
release_first = asyncio.Event()
async def admit(workspace_uuid: str, *, hold: bool = False) -> None:
async with sessions() as session:
async with session.begin():
await require_resource_capacity(
session.execute,
workspace_uuid=workspace_uuid,
model=_Resource,
quota=quota,
resource_name='resources',
)
if hold:
lock_acquired.set()
await release_first.wait()
await session.execute(
sa.insert(_Resource).values(uuid=str(uuid.uuid4()), workspace_uuid=workspace_uuid)
)
first = asyncio.create_task(admit(workspace_a, hold=True))
await asyncio.wait_for(lock_acquired.wait(), timeout=2)
same_workspace = asyncio.create_task(admit(workspace_a))
other_workspace = asyncio.create_task(admit(workspace_b))
await asyncio.wait_for(other_workspace, timeout=2)
assert not same_workspace.done(), 'same-workspace transaction bypassed SELECT FOR UPDATE'
release_first.set()
await first
with pytest.raises(WorkspaceQuotaExceededError, match=r'Maximum number of resources \(1\) reached'):
await same_workspace
async with sessions() as session:
counts = dict(
(
await session.execute(
sa.select(_Resource.workspace_uuid, sa.func.count())
.group_by(_Resource.workspace_uuid)
.order_by(_Resource.workspace_uuid)
)
).all()
)
assert counts == {workspace_a: 1, workspace_b: 1}
await engine.dispose()
restarted_engine = create_async_engine(url, pool_size=2, max_overflow=0)
restarted_sessions = async_sessionmaker(restarted_engine, expire_on_commit=False)
try:
async with restarted_sessions() as session:
async with session.begin():
with pytest.raises(WorkspaceQuotaExceededError):
await require_resource_capacity(
session.execute,
workspace_uuid=workspace_a,
model=_Resource,
quota=quota,
resource_name='resources',
)
finally:
await restarted_engine.dispose()
@@ -9,6 +9,7 @@ import quart
from langbot.pkg.api.http.controller import group
from langbot.pkg.api.http.controller.groups.webhooks import WebhookRouterGroup
from langbot.pkg.cloud.quotas import WorkspaceQuotaExceededError
from langbot.pkg.utils.bounded_executor import (
BlockingWorkCapacityError,
current_blocking_work_scope,
@@ -48,6 +49,16 @@ class _BlockingCapacityRouterGroup(group.RouterGroup):
raise BlockingWorkCapacityError('Workspace blocking executor capacity reached')
class _QuotaRouterGroup(group.RouterGroup):
name = 'quota-test'
path = '/quota-test'
async def initialize(self) -> None:
@self.route('', methods=['POST'], auth_type=group.AuthType.NONE)
async def _():
raise WorkspaceQuotaExceededError('bots', 2)
class _InvalidAccountRouterGroup(group.RouterGroup):
name = 'invalid-account-test'
path = '/invalid-account-test'
@@ -130,6 +141,20 @@ async def test_blocking_work_capacity_maps_to_retryable_http_response():
}
async def test_workspace_quota_maps_to_stable_conflict_response():
application = SimpleNamespace(logger=Mock())
quart_app = quart.Quart(__name__)
await _QuotaRouterGroup(application, quart_app).initialize()
response = await quart_app.test_client().post('/quota-test')
assert response.status_code == 409
assert await response.get_json() == {
'code': 'workspace_quota_exceeded',
'msg': 'Maximum number of bots (2) reached',
}
async def test_public_webhook_carries_scope_without_holding_database_session():
class ScopeOnlyPersistenceManager:
mode = SimpleNamespace(value='cloud_runtime')
@@ -311,10 +311,9 @@ class TestBotServiceCreateBot:
ap.platform_mgr = SimpleNamespace()
ap.platform_mgr.load_bot = AsyncMock()
# Mock get_bots to return 2 bots already
bot1 = _create_mock_bot(bot_uuid='uuid-1')
bot2 = _create_mock_bot(bot_uuid='uuid-2')
mock_result = _create_mock_result([bot1, bot2])
# Mock the atomic count query to report 2 existing bots.
mock_result = _create_mock_result()
mock_result.scalar_one = Mock(return_value=2)
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'uuid-1', 'name': 'Bot 1'})
@@ -0,0 +1,129 @@
from __future__ import annotations
import asyncio
from collections import defaultdict
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
import sqlalchemy
from langbot.pkg.api.http.service.bot import BotService
from langbot.pkg.cloud.entitlements import EntitlementResolver, EntitlementSnapshot
INSTANCE_UUID = 'cloud-instance'
WORKSPACE_A = '11111111-1111-1111-1111-111111111111'
WORKSPACE_B = '22222222-2222-2222-2222-222222222222'
class _Provider:
async def get_workspace_entitlement(self, workspace_uuid: str) -> EntitlementSnapshot:
return EntitlementSnapshot(
instance_uuid=INSTANCE_UUID,
workspace_uuid=workspace_uuid,
entitlement_revision=1,
status='active',
not_before=0,
expires_at=4_102_444_800,
features={},
limits={'bots.max': 2},
)
class _Result:
def __init__(self, *, first=None, scalar=None) -> None:
self._first = first
self._scalar = scalar
def first(self):
return self._first
def scalar_one(self):
return self._scalar
class _TenantUow:
def __init__(self, manager: '_Persistence', workspace_uuid: str) -> None:
self.manager = manager
self.workspace_uuid = workspace_uuid
self.lock = manager.locks[workspace_uuid]
async def __aenter__(self):
await self.lock.acquire()
return self
async def __aexit__(self, exc_type, exc, tb):
self.lock.release()
async def execute(self, statement):
sql = str(statement)
if isinstance(statement, sqlalchemy.sql.dml.Insert):
assert statement.table.name == 'bots'
self.manager.bots[self.workspace_uuid].append(statement.compile().params)
return _Result()
if 'FROM workspaces' in sql:
assert statement._for_update_arg is not None
self.manager.workspace_locks_seen += 1
return _Result(first=(self.workspace_uuid,))
if 'count(' in sql.lower() and 'FROM bots' in sql:
return _Result(scalar=len(self.manager.bots[self.workspace_uuid]))
if 'FROM legacy_pipelines' in sql:
return _Result(first=None)
raise AssertionError(f'unexpected statement: {sql}')
class _Persistence:
def __init__(self) -> None:
self.locks = defaultdict(asyncio.Lock)
self.bots = defaultdict(list)
self.workspace_locks_seen = 0
def tenant_uow(self, workspace_uuid: str) -> _TenantUow:
return _TenantUow(self, workspace_uuid)
async def execute_async(self, statement):
assert 'FROM legacy_pipelines' in str(statement)
return _Result(first=None)
async def _service(manager: _Persistence) -> BotService:
resolver = EntitlementResolver(INSTANCE_UUID, _Provider())
await resolver.reconcile_active_workspaces({WORKSPACE_A, WORKSPACE_B})
ap = SimpleNamespace(
entitlement_resolver=resolver,
persistence_mgr=manager,
instance_config=SimpleNamespace(data={'system': {'limitation': {'max_bots': 99}}}),
platform_mgr=SimpleNamespace(load_bot=AsyncMock()),
)
service = BotService(ap)
service.get_bot = AsyncMock(return_value={'uuid': 'created'})
return service
@pytest.mark.asyncio
async def test_cloud_bot_quota_is_atomic_isolated_and_persists_across_service_restart() -> None:
manager = _Persistence()
service = await _service(manager)
async def create(workspace_uuid: str, index: int):
return await service.create_bot(workspace_uuid, {'name': f'bot-{index}'})
results = await asyncio.gather(
*(create(WORKSPACE_A, index) for index in range(8)),
*(create(WORKSPACE_B, index) for index in range(8)),
return_exceptions=True,
)
successes = [result for result in results if isinstance(result, str)]
failures = [result for result in results if isinstance(result, ValueError)]
assert len(successes) == 4
assert len(failures) == 12
assert len(manager.bots[WORKSPACE_A]) == 2
assert len(manager.bots[WORKSPACE_B]) == 2
assert manager.workspace_locks_seen == 16
restarted_service = await _service(manager)
with pytest.raises(ValueError, match=r'Maximum number of bots \(2\) reached'):
await restarted_service.create_bot(WORKSPACE_A, {'name': 'after-restart'})
assert len(manager.bots[WORKSPACE_A]) == 2
@@ -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()
@@ -25,7 +25,6 @@ from langbot.pkg.workspace.errors import WorkspaceNotFoundError
pytestmark = pytest.mark.asyncio
WORKSPACE_UUID = 'workspace-a'
SYSTEM_REQUESTER = 'space-chat-completions'
def _create_mock_provider(
@@ -1006,56 +1005,3 @@ class TestProviderSecretRoundtrip:
)
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
@@ -418,7 +418,6 @@ class TestUserServiceGenerateJwtToken:
assert token is not None
class TestUserServiceVerifyJwtToken:
"""Tests for verify_jwt_token method."""
@@ -149,36 +149,6 @@ async def test_session_scope_matches_exact_tenant_placement_and_principal():
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():
owner_context = _request_context()
sessions: dict[str, dict] = {}
-5
View File
@@ -66,10 +66,6 @@ class _Provider:
def __init__(self):
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):
del instance_config
return VerifiedCloudDeployment(
@@ -83,7 +79,6 @@ class _Provider:
entitlement_provider=_Entitlements(),
directory_provider=_Directory(),
manifest_provider=self.manifest_provider,
model_catalog_provider=self,
verification_key_id='root-2026',
)
@@ -1,372 +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,
},
{
'workspace_uuid': WORKSPACE_B,
'owner_account_uuid': OWNER_B,
'api_key': 'owner-b-key',
},
],
}
)
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
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 _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 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
+28 -83
View File
@@ -11,7 +11,6 @@ from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from langbot.pkg.cloud.launch import SpaceLaunchError, SpaceLaunchService
from langbot.pkg.cloud.support_admin import SupportAdminReplayError
pytestmark = pytest.mark.asyncio
@@ -49,6 +48,7 @@ def _claims(*, now: int, jti: str | None = None, workspace_uuid: str = WORKSPACE
'payload': {
'account_uuid': ACCOUNT_UUID,
'workspace_uuid': workspace_uuid,
'return_path': '/',
},
}
@@ -58,21 +58,9 @@ def _service(private_key: Ed25519PrivateKey, *, now: int) -> SpaceLaunchService:
encoding=serialization.Encoding.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(
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),
support_admin_session_service=DurableSupportAdminService(),
instance_config=SimpleNamespace(
data={
'space': {
@@ -94,84 +82,29 @@ async def test_consumes_valid_workspace_launch_assertion_once():
launch = await service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID)
assert launch == {'account_uuid': ACCOUNT_UUID, 'workspace_uuid': WORKSPACE_UUID}
with pytest.raises(SpaceLaunchError, match='already been consumed'):
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 == {
'account_uuid': ACCOUNT_UUID,
'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',
'return_path': '/',
}
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)
async def test_consumed_assertion_remains_blocked_through_clock_skew_window():
private_key = Ed25519PrivateKey.generate()
now = int(time.time())
service = _service(private_key, now=now)
claims = _claims(now=now)
claims['iat'] = now - 10
claims['nbf'] = now - 10
claims['exp'] = now - 1
token = _sign(private_key, claims)
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)
await service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID)
with pytest.raises(SpaceLaunchError, match='already been consumed'):
await service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID)
async def test_replay_cache_does_not_scan_all_live_assertions(monkeypatch):
@@ -250,3 +183,15 @@ async def test_rejects_invalid_signature_and_non_cloud_mode():
oss_service.ap.deployment.multi_workspace_enabled = False
with pytest.raises(SpaceLaunchError, match='verified Cloud mode'):
await oss_service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID)
@pytest.mark.asyncio
async def test_rejects_unsafe_signed_return_path() -> None:
private_key = Ed25519PrivateKey.generate()
now = int(time.time())
service = _service(private_key, now=now)
claims = _claims(now=now)
claims['payload']['return_path'] = '//evil.example'
with pytest.raises(SpaceLaunchError, match='return path'):
await service.consume_assertion(_sign(private_key, claims))
@@ -0,0 +1,102 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
import sqlalchemy
from langbot.pkg.cloud.entitlements import EntitlementResolver, EntitlementSnapshot
from langbot.pkg.cloud.quotas import (
WorkspaceQuota,
require_resource_capacity,
resolve_workspace_quota,
)
from langbot.pkg.entity.persistence.bot import Bot
WORKSPACE_UUID = '11111111-1111-1111-1111-111111111111'
INSTANCE_UUID = 'cloud-instance'
class _Provider:
def __init__(self, limits: dict[str, int]) -> None:
self.limits = limits
async def get_workspace_entitlement(self, workspace_uuid: str) -> EntitlementSnapshot:
return EntitlementSnapshot(
instance_uuid=INSTANCE_UUID,
workspace_uuid=workspace_uuid,
entitlement_revision=1,
status='active',
not_before=0,
expires_at=4_102_444_800,
features={},
limits=self.limits,
plan_name='test',
)
async def _resolver(limits: dict[str, int]) -> EntitlementResolver:
resolver = EntitlementResolver(INSTANCE_UUID, _Provider(limits))
await resolver.reconcile_active_workspaces({WORKSPACE_UUID})
return resolver
@pytest.mark.asyncio
async def test_resolve_workspace_quota_uses_signed_cloud_limit() -> None:
ap = SimpleNamespace(entitlement_resolver=await _resolver({'bots.max': 2}))
quota = await resolve_workspace_quota(ap, WORKSPACE_UUID, 'bots.max', fallback=99)
assert quota == WorkspaceQuota(limit=2, requires_transaction_lock=True)
@pytest.mark.asyncio
async def test_resolve_workspace_quota_preserves_oss_fallback() -> None:
quota = await resolve_workspace_quota(SimpleNamespace(), WORKSPACE_UUID, 'bots.max', fallback=7)
assert quota == WorkspaceQuota(limit=7, requires_transaction_lock=False)
@pytest.mark.asyncio
async def test_require_resource_capacity_locks_workspace_before_counting() -> None:
statements: list[object] = []
lock_result = Mock()
lock_result.first.return_value = (WORKSPACE_UUID,)
count_result = Mock()
count_result.scalar_one.return_value = 1
execute = AsyncMock(side_effect=[lock_result, count_result])
await require_resource_capacity(
execute,
workspace_uuid=WORKSPACE_UUID,
model=Bot,
quota=WorkspaceQuota(limit=2, requires_transaction_lock=True),
resource_name='bots',
)
statements.extend(call.args[0] for call in execute.await_args_list)
assert len(statements) == 2
assert isinstance(statements[0], sqlalchemy.sql.Select)
assert statements[0]._for_update_arg is not None
assert 'workspaces' in str(statements[0])
assert 'count' in str(statements[1]).lower()
@pytest.mark.asyncio
async def test_require_resource_capacity_rejects_at_boundary() -> None:
lock_result = Mock()
lock_result.first.return_value = (WORKSPACE_UUID,)
count_result = Mock()
count_result.scalar_one.return_value = 2
execute = AsyncMock(side_effect=[lock_result, count_result])
with pytest.raises(ValueError, match=r'Maximum number of bots \(2\) reached'):
await require_resource_capacity(
execute,
workspace_uuid=WORKSPACE_UUID,
model=Bot,
quota=WorkspaceQuota(limit=2, requires_transaction_lock=True),
resource_name='bots',
)
@@ -961,7 +961,6 @@ 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.now(),
sa.func.date_trunc('hour', sa.column('timestamp')),
sa.func.length(sa.literal('value')),
sa.func.nullif(sa.literal('value'), sa.literal('')),
),
@@ -11,7 +11,6 @@ from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
from langbot.pkg.persistence.tenant_uow import PersistenceScopeKind
from langbot.pkg.pipeline.controller import Controller
from langbot.pkg.pipeline.pool import QueryPool
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)
query_pool.remove_query.assert_awaited_once_with(sample_query)
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 == []
@@ -1,8 +1,7 @@
"""Tests for Telegram Dify form callback helpers."""
import json
from contextlib import asynccontextmanager
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import AsyncMock, MagicMock
import pytest
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.events as platform_events
import langbot_plugin.api.entities.builtin.platform.message as platform_message
from langbot.pkg.platform.sources.telegram import (
TelegramAdapter,
TelegramMessageConverter,
_decode_telegram_base64_limited,
_telegram_form_action_from_callback,
_telegram_select_field_options,
@@ -29,70 +26,6 @@ def test_telegram_base64_decode_is_bounded(monkeypatch):
_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:
return {
'_current_input_field': 'choice',
@@ -1,7 +1,6 @@
"""Regression tests for isolated embed-widget conversations."""
import asyncio
import contextvars
from pathlib import Path
from unittest.mock import AsyncMock, Mock
@@ -205,48 +204,6 @@ async def test_embed_event_uses_stable_session_launcher(monkeypatch):
assert received[0].sender.id == f'websocket_pipeline-1:{session_id}'
@pytest.mark.asyncio
async def test_pipeline_override_survives_detached_listener_task(monkeypatch):
manager = WebSocketConnectionManager()
connection = await manager.add_connection(
websocket=Mock(),
scope=SCOPE_A,
pipeline_uuid='pipeline-1',
session_type='person',
)
monkeypatch.setattr(websocket_adapter_module, 'ws_connection_manager', manager)
class DetachedTaskManager:
def __init__(self):
self.tasks = []
def create_task(self, coro, **_kwargs):
task = asyncio.create_task(coro, context=contextvars.Context())
self.tasks.append(task)
return Mock(task=task)
task_manager = DetachedTaskManager()
adapter = WebSocketAdapter.model_construct(
ap=Mock(task_mgr=task_manager),
logger=_adapter_logger(),
)
adapter.websocket_person_session = WebSocketSession(id='person')
adapter.websocket_group_session = WebSocketSession(id='group')
pipeline_overrides = []
async def listener(_event, callback_adapter):
pipeline_overrides.append(callback_adapter.get_pipeline_uuid_override())
adapter.listeners = {platform_events.FriendMessage: listener}
await adapter.handle_websocket_message(
connection,
{'message': [{'type': 'Plain', 'text': 'hello'}], 'stream': False},
)
await asyncio.gather(*task_manager.tasks)
assert pipeline_overrides == ['pipeline-1']
@pytest.mark.asyncio
async def test_embed_group_event_uses_stable_session_launcher(monkeypatch):
manager = WebSocketConnectionManager()
@@ -0,0 +1,191 @@
from __future__ import annotations
import asyncio
from collections import defaultdict
from types import SimpleNamespace
import pytest
import sqlalchemy
from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.cloud.entitlements import EntitlementResolver, EntitlementSnapshot
from langbot.pkg.cloud.quotas import WorkspaceQuotaExceededError
from langbot.pkg.plugin.connector import PluginRuntimeConnector
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
INSTANCE_UUID = 'cloud-instance'
WORKSPACE_A = '11111111-1111-1111-1111-111111111111'
WORKSPACE_B = '22222222-2222-2222-2222-222222222222'
class _Provider:
async def get_workspace_entitlement(self, workspace_uuid: str) -> EntitlementSnapshot:
return EntitlementSnapshot(
instance_uuid=INSTANCE_UUID,
workspace_uuid=workspace_uuid,
entitlement_revision=1,
status='active',
not_before=0,
expires_at=4_102_444_800,
features={},
limits={'plugins.max': 3},
)
class _Result:
def __init__(self, *, first=None, scalar=None) -> None:
self._first = first
self._scalar = scalar
def first(self):
return self._first
def scalar_one(self):
return self._scalar
class _TenantUow:
def __init__(self, manager: '_Persistence', workspace_uuid: str) -> None:
self.manager = manager
self.workspace_uuid = workspace_uuid
self.lock = manager.locks[workspace_uuid]
async def __aenter__(self):
await self.lock.acquire()
return self
async def __aexit__(self, exc_type, exc, tb):
self.lock.release()
async def execute(self, statement):
sql = str(statement)
params = statement.compile().params
if isinstance(statement, sqlalchemy.sql.dml.Insert):
assert statement.table.name == 'plugin_settings'
key = (params['plugin_author'], params['plugin_name'])
self.manager.plugins[self.workspace_uuid][key] = dict(params)
return _Result()
if isinstance(statement, sqlalchemy.sql.dml.Update):
return _Result()
if 'FROM workspaces' in sql:
assert statement._for_update_arg is not None
self.manager.workspace_locks_seen += 1
return _Result(first=(self.workspace_uuid,))
if 'count(' in sql.lower() and 'FROM plugin_settings' in sql:
return _Result(scalar=len(self.manager.plugins[self.workspace_uuid]))
if 'FROM plugin_settings' in sql:
author = next(value for name, value in params.items() if 'plugin_author' in name)
name = next(value for param, value in params.items() if 'plugin_name' in param)
row = self.manager.plugins[self.workspace_uuid].get((author, name))
if row is None:
return _Result(first=None)
return _Result(
first=SimpleNamespace(
installation_uuid=row['installation_uuid'],
runtime_revision=row['runtime_revision'],
artifact_digest=row['artifact_digest'],
install_info=row['install_info'],
)
)
raise AssertionError(f'unexpected statement: {sql}')
class _Persistence:
def __init__(self) -> None:
self.locks = defaultdict(asyncio.Lock)
self.plugins = defaultdict(dict)
self.workspace_locks_seen = 0
def tenant_uow(self, workspace_uuid: str) -> _TenantUow:
return _TenantUow(self, workspace_uuid)
async def _connector(manager: _Persistence) -> PluginRuntimeConnector:
resolver = EntitlementResolver(INSTANCE_UUID, _Provider())
await resolver.reconcile_active_workspaces({WORKSPACE_A, WORKSPACE_B})
connector = object.__new__(PluginRuntimeConnector)
connector.ap = SimpleNamespace(entitlement_resolver=resolver, persistence_mgr=manager)
return connector
def _context(workspace_uuid: str) -> ExecutionContext:
return ExecutionContext(
instance_uuid=INSTANCE_UUID,
workspace_uuid=workspace_uuid,
placement_generation=1,
entitlement_revision=1,
)
@pytest.mark.asyncio
async def test_cloud_plugin_quota_is_atomic_isolated_and_persists_across_connector_restart() -> None:
manager = _Persistence()
connector = await _connector(manager)
async def install(workspace_uuid: str, index: int):
return await connector._persist_installation_package(
_context(workspace_uuid),
plugin_author='test-author',
plugin_name=f'plugin-{index}',
install_source=PluginInstallSource.MARKETPLACE,
install_info={'author': 'test-author', 'name': f'plugin-{index}'},
artifact_digest=f'{index:064x}',
)
results = await asyncio.gather(
*(install(WORKSPACE_A, index) for index in range(10)),
*(install(WORKSPACE_B, index) for index in range(10)),
return_exceptions=True,
)
successes = [result for result in results if isinstance(result, tuple)]
failures = [result for result in results if isinstance(result, WorkspaceQuotaExceededError)]
assert len(successes) == 6
assert len(failures) == 14
assert len(manager.plugins[WORKSPACE_A]) == 3
assert len(manager.plugins[WORKSPACE_B]) == 3
assert manager.workspace_locks_seen == 20
restarted_connector = await _connector(manager)
with pytest.raises(WorkspaceQuotaExceededError, match=r'Maximum number of plugins \(3\) reached'):
await restarted_connector._persist_installation_package(
_context(WORKSPACE_A),
plugin_author='test-author',
plugin_name='after-restart',
install_source=PluginInstallSource.MARKETPLACE,
install_info={},
artifact_digest='f' * 64,
)
assert len(manager.plugins[WORKSPACE_A]) == 3
installed_name = next(iter(manager.plugins[WORKSPACE_A]))[1]
async def reinstall():
return await restarted_connector._persist_installation_package(
_context(WORKSPACE_A),
plugin_author='test-author',
plugin_name=installed_name,
install_source=PluginInstallSource.MARKETPLACE,
install_info={'author': 'test-author', 'name': installed_name, 'revision': 2},
artifact_digest='e' * 64,
)
reinstall_results = await asyncio.gather(reinstall(), reinstall())
assert all(result[2] is True for result in reinstall_results)
mixed_results = await asyncio.gather(
reinstall(),
restarted_connector._persist_installation_package(
_context(WORKSPACE_A),
plugin_author='test-author',
plugin_name='new-at-capacity',
install_source=PluginInstallSource.MARKETPLACE,
install_info={},
artifact_digest='d' * 64,
),
return_exceptions=True,
)
assert isinstance(mixed_results[0], tuple)
assert isinstance(mixed_results[1], WorkspaceQuotaExceededError)
assert len(manager.plugins[WORKSPACE_A]) == 3
+23 -40
View File
@@ -60,12 +60,10 @@ class TestBuildHeartbeatPayload:
async def test_payload_shape(self):
heartbeat = get_heartbeat_module()
ap = make_app()
payload = await heartbeat.build_heartbeat_payload(ap, workspace_uuid='workspace-a')
payload = await heartbeat.build_heartbeat_payload(ap)
assert payload['event_type'] == 'instance_heartbeat'
assert payload['query_id'] == ''
assert payload['workspace_uuid'] == 'workspace-a'
assert 'instance_id' not in payload
assert 'instance_create_ts' in payload
assert 'timestamp' in payload
f = payload['features']
@@ -88,7 +86,7 @@ class TestBuildHeartbeatPayload:
@pytest.mark.asyncio
async def test_payload_is_json_serializable(self):
heartbeat = get_heartbeat_module()
payload = await heartbeat.build_heartbeat_payload(make_app(), workspace_uuid='workspace-a')
payload = await heartbeat.build_heartbeat_payload(make_app())
json.dumps(payload)
@pytest.mark.asyncio
@@ -96,7 +94,7 @@ class TestBuildHeartbeatPayload:
heartbeat = get_heartbeat_module()
ap = make_app()
ap.persistence_mgr.execute_async = AsyncMock(side_effect=RuntimeError('db down'))
payload = await heartbeat.build_heartbeat_payload(ap, workspace_uuid='workspace-a')
payload = await heartbeat.build_heartbeat_payload(ap)
assert payload['features']['pipeline_count'] == -1
@pytest.mark.asyncio
@@ -113,10 +111,8 @@ class TestBuildHeartbeatPayload:
('instance-a', 'workspace-a', 'pipeline-b'): object(),
},
)
adapter_a = type('WorkspaceAAdapter', (), {})()
adapter_b = type('WorkspaceBAdapter', (), {})()
ap.platform_mgr._bots_by_key = {
('instance-a', 'workspace-a', 'bot-a'): SimpleNamespace(enable=True, adapter=adapter_a),
('instance-a', 'workspace-a', 'bot-a'): object(),
}
ap.tool_mgr = SimpleNamespace(
mcp_tool_loader=SimpleNamespace(
@@ -133,48 +129,35 @@ class TestBuildHeartbeatPayload:
ap.plugin_connector._workspace_installations = {
'workspace-a': {'plugin-a', 'plugin-b'},
}
ap.skill_mgr._skills_by_scope = {
('instance-a', 'workspace-a', 1): {'skill-a': {}, 'skill-b': {}},
('instance-a', 'workspace-b', 1): {'skill-c': {}},
}
ap.workspace_service.list_active_execution_bindings = AsyncMock(
return_value=[
SimpleNamespace(workspace_uuid='workspace-a', placement_generation=7),
SimpleNamespace(workspace_uuid='workspace-b', placement_generation=9),
],
)
ap.platform_mgr._bots_by_key[('instance-a', 'workspace-b', 'bot-b')] = SimpleNamespace(
enable=True, adapter=adapter_b
return_value=[SimpleNamespace(workspace_uuid='workspace-a')],
)
payloads = await heartbeat.build_heartbeat_payloads(ap)
payload = await heartbeat.build_heartbeat_payload(ap)
assert [payload['workspace_uuid'] for payload in payloads] == ['workspace-a', 'workspace-b']
assert all('instance_id' not in payload for payload in payloads)
by_workspace = {payload['workspace_uuid']: payload['features'] for payload in payloads}
assert by_workspace['workspace-a']['pipeline_count'] == 2
assert by_workspace['workspace-a']['mcp_server_count'] == 3
assert by_workspace['workspace-a']['knowledge_base_count'] == 1
assert by_workspace['workspace-a']['bot_count'] == 1
assert by_workspace['workspace-a']['plugin_count'] == 2
assert by_workspace['workspace-a']['extension_count'] == 5
assert by_workspace['workspace-a']['skill_count'] == 2
assert by_workspace['workspace-a']['execution_generation'] == 7
assert by_workspace['workspace-a']['adapters'] == ['WorkspaceAAdapter']
assert by_workspace['workspace-b']['bot_count'] == 1
assert by_workspace['workspace-b']['pipeline_count'] == 0
assert by_workspace['workspace-b']['skill_count'] == 1
assert by_workspace['workspace-b']['execution_generation'] == 9
assert by_workspace['workspace-b']['adapters'] == ['WorkspaceBAdapter']
assert 'workspace_resources' not in by_workspace['workspace-a']
features = payload['features']
assert features['pipeline_count'] == 2
assert features['mcp_server_count'] == 3
assert features['knowledge_base_count'] == 1
assert features['bot_count'] == 1
assert features['workspace_resources'] == [
{
'workspace_uuid': 'workspace-a',
'bot_count': 1,
'pipeline_count': 2,
'knowledge_base_count': 1,
'plugin_count': 2,
'mcp_server_count': 3,
'extension_count': 5,
}
]
ap.persistence_mgr.execute_async.assert_not_awaited()
ap.workspace_service.list_active_execution_bindings.assert_awaited_once()
@pytest.mark.asyncio
async def test_no_user_content_fields(self):
"""The heartbeat must never carry message content / credentials keys."""
heartbeat = get_heartbeat_module()
payload = await heartbeat.build_heartbeat_payload(make_app(), workspace_uuid='workspace-a')
payload = await heartbeat.build_heartbeat_payload(make_app())
flat = json.dumps(payload).lower()
for forbidden in ('api_key', 'password', 'token', 'message_content'):
assert forbidden not in flat
@@ -569,33 +569,6 @@ class TestHTTPScenarios:
await manager.send({'query_id': 'test'})
class TestTelemetryManagedRuntimeAuthentication:
@pytest.mark.asyncio
async def test_send_includes_managed_runtime_token_header(self):
telemetry = get_telemetry_module()
mock_app = Mock()
mock_app.logger = Mock()
manager = telemetry.TelemetryManager(mock_app)
manager.telemetry_config = {'url': 'https://example.com'}
captured = {}
async def mock_post(url, json, headers):
captured['headers'] = headers
return Mock(status_code=200, text='', json=Mock(return_value={'code': 0}))
mock_client = Mock()
mock_client.post = mock_post
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
with (
patch.dict('os.environ', {'LANGBOT_TELEMETRY_INGEST_TOKEN': 'managed-runtime-secret'}),
patch.object(httpx, 'AsyncClient', return_value=mock_client),
):
await manager.send({'event_type': 'instance_heartbeat'})
assert captured['headers'] == {'X-LangBot-Telemetry-Token': 'managed-runtime-secret'}
class TestStartSendTask:
"""Tests for start_send_task() method."""
@@ -1,31 +0,0 @@
from __future__ import annotations
import uuid
from types import SimpleNamespace
def test_standard_oss_instance_id_aligns_to_embedded_uuid():
from langbot.pkg.workspace.identity import workspace_uuid_from_instance_id
instance_uuid = "a711d9e4-0953-443f-a0e9-7dd50193a79f"
assert workspace_uuid_from_instance_id(instance_uuid) == instance_uuid
assert workspace_uuid_from_instance_id(f"instance_{instance_uuid}") == instance_uuid
def test_custom_legacy_instance_id_maps_to_stable_valid_uuid():
from langbot.pkg.workspace.identity import workspace_uuid_from_instance_id
first = workspace_uuid_from_instance_id("instance_migration_test")
second = workspace_uuid_from_instance_id("instance_migration_test")
assert first == second
assert str(uuid.UUID(first)) == first
def test_query_telemetry_identity_uses_execution_workspace_only():
from langbot.pkg.telemetry.identity import workspace_identity
identity = workspace_identity(SimpleNamespace(workspace_uuid="workspace-a", instance_uuid="instance-a"))
assert identity == {"workspace_uuid": "workspace-a"}
@@ -26,7 +26,6 @@ from langbot.pkg.workspace import (
WorkspaceOwnerAlreadyExistsError,
WorkspaceService,
)
from langbot.pkg.workspace.identity import workspace_uuid_from_instance_id
from langbot.pkg.workspace.policy import CloudWorkspacePolicy
@@ -114,7 +113,6 @@ async def test_ensure_singleton_workspace_is_idempotent(workspace_test_context):
first = await service.ensure_singleton_workspace()
second = await service.ensure_singleton_workspace()
assert first.uuid == workspace_uuid_from_instance_id('instance_service_test')
assert second.uuid == first.uuid
async with session_factory() as session:
assert await session.scalar(sqlalchemy.select(sqlalchemy.func.count()).select_from(Workspace)) == 1
Generated
+7 -3
View File
@@ -1999,7 +1999,7 @@ wheels = [
[[package]]
name = "langbot"
version = "4.10.7"
version = "4.10.6"
source = { editable = "." }
dependencies = [
{ name = "aiocqhttp" },
@@ -2116,7 +2116,7 @@ requires-dist = [
{ name = "ebooklib", specifier = ">=0.18" },
{ name = "gewechat-client", specifier = ">=0.1.5" },
{ name = "html2text", specifier = ">=2024.2.26" },
{ name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=101e453e916b39465a6294d6471c9eaae8725d5c" },
{ name = "langbot-plugin", specifier = "==0.5.0" },
{ name = "langchain", specifier = ">=1.3.9" },
{ name = "langchain-core", specifier = ">=1.3.3" },
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
@@ -2183,7 +2183,7 @@ dev = [
[[package]]
name = "langbot-plugin"
version = "0.5.0"
source = { git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=101e453e916b39465a6294d6471c9eaae8725d5c#101e453e916b39465a6294d6471c9eaae8725d5c" }
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiofiles" },
{ name = "aiohttp" },
@@ -2203,6 +2203,10 @@ dependencies = [
{ name = "watchdog" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5c/09/697037dea617a235c9b3df85174badfb44886e3c67149a928049839a959a/langbot_plugin-0.5.0.tar.gz", hash = "sha256:9b81fa0f73cde1fe199a746e03ad891f8ced8f4fa0d05aeb6d78bfe7d755a976", size = 464033, upload-time = "2026-07-30T19:22:57.503Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/70/93e544c3a120953f4871266db9ef84ea9a58daf01d493fafa7c45674ad36/langbot_plugin-0.5.0-py3-none-any.whl", hash = "sha256:2b078db96d869de55d08304465974f51765e3cfe582ff79b32f09161292fb877", size = 300758, upload-time = "2026-07-30T19:22:56.248Z" },
]
[[package]]
name = "langchain"
+13 -42
View File
@@ -3,9 +3,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
import { httpClient } from '@/app/infra/http/HttpClient';
import {
beginAuthenticatedSession,
beginSupportAdminSession,
bootstrapWorkspaceSession,
clearPendingInvitationToken,
getPendingInvitationToken,
} from '@/app/infra/http';
import { toast } from 'sonner';
@@ -29,10 +27,9 @@ import langbotIcon from '@/app/assets/langbot-logo.webp';
type SpaceOAuthLoginResult = {
token: string;
user?: string;
user: string;
workspace_uuid?: string;
principal_type?: 'account' | 'support_admin';
actor_account_uuid?: string;
return_path?: string;
};
const pendingSpaceOAuthLogins = new Map<
@@ -102,42 +99,9 @@ function SpaceOAuthCallbackContent() {
return;
}
if (response.principal_type === 'support_admin') {
if (!response.workspace_uuid) {
throw new Error('Support admin launch did not include a Workspace');
}
beginSupportAdminSession(response.token, response.workspace_uuid);
await bootstrapWorkspaceSession();
navigate('/home', { replace: true });
return;
}
beginAuthenticatedSession(response.token, response.user);
const invitationToken = getPendingInvitationToken();
if (invitationToken) {
let invitation;
try {
invitation =
await httpClient.acceptWorkspaceInvitation(invitationToken);
} catch (error) {
const code = (error as { code?: string }).code;
const path = code
? `/invitations/accept?error=${encodeURIComponent(code)}`
: '/invitations/accept';
navigate(path, { replace: true });
return;
}
beginAuthenticatedSession(invitation.token, response.user);
clearPendingInvitationToken();
const workspaceResult = await bootstrapWorkspaceSession({
preferredWorkspaceUuid: invitation.workspace_uuid,
});
if (workspaceResult.status === 'unavailable') {
navigate('/workspace-unavailable', { replace: true });
return;
}
navigate('/home', { replace: true });
if (getPendingInvitationToken()) {
navigate('/invitations/accept', { replace: true });
return;
}
const workspaceResult = await bootstrapWorkspaceSession({
@@ -147,7 +111,13 @@ function SpaceOAuthCallbackContent() {
throw new Error('No Workspace is available for this Account');
}
if (response.workspace_uuid) {
navigate('/home', { replace: true });
const returnPath =
typeof response.return_path === 'string' &&
response.return_path.startsWith('/') &&
!response.return_path.startsWith('//')
? response.return_path
: '/home';
navigate(returnPath, { replace: true });
return;
}
setStatus('success');
@@ -269,7 +239,8 @@ function SpaceOAuthCallbackContent() {
const workspaceUuid =
directLaunchFragmentRef.current.workspaceUuid ??
searchParams.get('workspace_uuid');
const launchAssertion = directLaunchFragmentRef.current.launchAssertion;
const launchAssertion =
directLaunchFragmentRef.current.launchAssertion;
if (error) {
setStatus('error');
+1 -2
View File
@@ -17,7 +17,6 @@ import {
bootstrapWorkspaceSession,
systemInfo,
initializeSystemInfo,
isSupportAdminSession,
useCurrentWorkspace,
} from '@/app/infra/http';
import { useNavigate, useLocation } from 'react-router-dom';
@@ -157,7 +156,7 @@ export default function HomeLayout({
// selected Workspace's wizard state.
useEffect(() => {
if (!identityReady) return;
if (systemInfo?.wizard_status === 'none' && !isSupportAdminSession()) {
if (systemInfo.wizard_status === 'none') {
navigate('/wizard', { replace: true });
}
}, [identityReady, navigate]);
+1 -4
View File
@@ -1179,7 +1179,6 @@ export class BackendClient extends BaseHttpClient {
public getAccountInfo(): Promise<{
initialized: boolean;
authenticated_invitation_acceptance_enabled?: boolean;
password_login_enabled?: boolean;
space_login_enabled?: boolean;
}> {
@@ -1354,10 +1353,8 @@ export class BackendClient extends BaseHttpClient {
launchAssertion?: string,
): Promise<{
token: string;
user?: string;
user: string;
workspace_uuid?: string;
principal_type?: 'account' | 'support_admin';
actor_account_uuid?: string;
}> {
const response = await this.instance.post(
'/api/v1/user/space/callback',
-48
View File
@@ -217,34 +217,10 @@ export function beginAuthenticatedSession(
if (typeof window === 'undefined') return;
localStorage.removeItem('token');
localStorage.removeItem('userEmail');
localStorage.removeItem('authPrincipalType');
localStorage.setItem('token', token);
if (userEmail) localStorage.setItem('userEmail', userEmail);
}
export function beginSupportAdminSession(
token: string,
workspaceUuid: string,
): void {
userInfo = null;
clearWorkspaceSelection();
clearWorkspaceBootstrapSnapshot();
if (typeof window === 'undefined') return;
localStorage.removeItem('token');
localStorage.removeItem('userEmail');
localStorage.setItem('token', token);
localStorage.setItem('authPrincipalType', 'support_admin');
setActiveWorkspaceUuid(workspaceUuid);
}
export function isSupportAdminSession(): boolean {
return (
typeof window !== 'undefined' &&
localStorage.getItem('authPrincipalType') === 'support_admin'
);
}
async function initializeSelectedWorkspace(
workspaceUuid: string,
workspaces: WorkspaceBootstrapEntry[],
@@ -276,27 +252,6 @@ async function initializeSelectedWorkspace(
export async function bootstrapWorkspaceSession(
options: WorkspaceBootstrapOptions = {},
): Promise<WorkspaceBootstrapResult> {
if (isSupportAdminSession()) {
const selectedWorkspaceUuid = getActiveWorkspaceUuid();
if (!selectedWorkspaceUuid) {
throw new Error('Support admin session is missing its Workspace scope');
}
if (
options.preferredWorkspaceUuid &&
options.preferredWorkspaceUuid !== selectedWorkspaceUuid
) {
throw new Error('Support admin session cannot change Workspace scope');
}
await initializeWorkspaceInfo();
const workspace = getCurrentWorkspaceSnapshot();
if (!workspace || workspace.workspace.uuid !== selectedWorkspaceUuid) {
clearWorkspaceSelection();
throw new Error('Support admin Workspace scope could not be initialized');
}
clearWorkspaceBootstrapSnapshot();
return { status: 'ready', workspace, workspaces: [] };
}
if (options.resetSelection) {
clearWorkspaceSelection();
clearWorkspaceBootstrapSnapshot();
@@ -384,9 +339,6 @@ export const clearUserInfo = (): void => {
userInfo = null;
clearWorkspaceSelection();
clearWorkspaceBootstrapSnapshot();
if (typeof window !== 'undefined') {
localStorage.removeItem('authPrincipalType');
}
};
export {
+1 -19
View File
@@ -93,10 +93,6 @@ export default function AcceptInvitationPage() {
const [confirmPassword, setConfirmPassword] = useState('');
const [passwordRegistrationEnabled, setPasswordRegistrationEnabled] =
useState(false);
const [
authenticatedInvitationAcceptanceEnabled,
setAuthenticatedInvitationAcceptanceEnabled,
] = useState(false);
useEffect(() => {
const handleHashChange = () => setInvitationHash(window.location.hash);
@@ -117,9 +113,6 @@ export default function AcceptInvitationPage() {
.getAccountInfo()
.then((info) => {
setPasswordRegistrationEnabled(info.password_login_enabled !== false);
setAuthenticatedInvitationAcceptanceEnabled(
info.authenticated_invitation_acceptance_enabled === true,
);
})
.catch(() => setPasswordRegistrationEnabled(false));
if (!invitationToken) {
@@ -311,18 +304,7 @@ export default function AcceptInvitationPage() {
</div>
)}
{hasLoginToken && authenticatedInvitationAcceptanceEnabled ? (
<Button
className="w-full"
disabled={status === 'submitting'}
onClick={() => void finishAcceptance()}
>
{status === 'submitting' ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : null}
{t('workspace.acceptInvitation')}
</Button>
) : hasLoginToken ? (
{hasLoginToken ? (
<div className="space-y-3">
<div className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:bg-amber-950/30 dark:text-amber-100">
{t('workspace.authenticatedInvitationNotice')}
-100
View File
@@ -175,8 +175,6 @@ const esES = {
more: 'Más ({{count}})',
less: 'Menos',
noItems: 'Sin elementos',
apiKeyStoredSecurely: 'Secret shown only when created',
},
notFound: {
title: 'Página no encontrada',
@@ -326,11 +324,6 @@ const esES = {
fallbackList: 'Modelos de respaldo',
addFallback: 'Añadir modelo de respaldo',
},
ownerMustBindSpace:
'The Workspace owner must connect Space for LangBot Models.',
usesOwnerSpaceBilling:
"Uses the Workspace owner's Space billing and credits.",
},
bots: {
title: 'Bots',
@@ -1322,13 +1315,6 @@ const esES = {
'Establece una contraseña para iniciar sesión con correo y contraseña',
spaceEmailMismatch:
'El correo de inicio de sesión de Space no coincide con el correo de la cuenta local',
space_account_not_registeredTitle: 'Account not registered',
space_account_not_registered:
'No local account is registered for this Space email. Ask the Workspace owner for an invitation.',
space_account_binding_requiredTitle: 'Space connection required',
space_account_binding_required:
'This local account must connect Space from Account settings before using Space login.',
},
monitoring: {
title: 'Panel de control',
@@ -1587,8 +1573,6 @@ const esES = {
api: 'API',
storage: 'Almacenamiento',
account: 'Cuenta',
workspace: 'Workspace',
},
},
storageAnalysis: {
@@ -1910,90 +1894,6 @@ const esES = {
unsupportedFileType:
'Tipo de archivo no admitido. Solo se admiten archivos .zip y .lbpkg',
},
workspace: {
title: 'Workspace',
description: 'Manage members, roles, and invitation links',
selectTitle: 'Choose a Workspace',
selectDescription: 'Select where you want to continue in LangBot.',
selectionLoadFailed:
'Your Workspaces could not be loaded. Please try again.',
switchWorkspace: 'Switch Workspace',
settings: 'Workspace Settings',
currentPlan: 'Current plan',
planUnavailable: 'Unavailable',
upgradePlan: 'Change or upgrade plan',
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.',
loadFailed: 'Failed to load Workspace information',
members: 'Members',
you: 'You',
inviteMember: 'Invite a member',
inviteDescription:
'Create a one-time link to add another user to this Workspace.',
emailPlaceholder: 'member@example.com',
createInvitation: 'Create invitation',
invitationCreated: 'Invitation created',
delivery: {
sent: 'Invitation sent',
link_only: 'Invitation link created',
failed: 'Invitation link created, but email could not be sent',
},
invitationCreateFailed: 'Failed to create invitation',
oneTimeLinkWarning: 'Copy this link now. It is shown only once.',
copyInvitation: 'Copy invitation link',
invitationCopied: 'Invitation link copied',
pendingInvitations: 'Pending invitations',
expiresAt: 'Expires {{date}}',
revokeInvitation: 'Revoke invitation',
invitationRevoked: 'Invitation revoked',
invitationRevokeFailed: 'Failed to revoke invitation',
acceptInvitation: 'Accept invitation',
invitedToWorkspace: 'You were invited to {{workspace}}',
checkingInvitation: 'Checking this invitation...',
invitationMissing: 'This invitation link is missing required information.',
invitationExpired: 'This invitation has expired.',
invitationAlreadyRevoked: 'This invitation was revoked.',
invitationAlreadyUsed: 'This invitation was already used.',
invitationInvalid: 'This invitation is invalid or no longer available.',
invitationAccepted: 'Invitation accepted',
invitationAcceptFailed: 'Failed to accept invitation',
invitationEmailMismatch:
'This invitation belongs to a different email address.',
existingAccountLoginRequired:
'An account already exists for this email. Sign in to continue.',
acceptAsCurrentAccount: 'Accept with current account',
authenticatedInvitationNotice:
'Sign out first, then sign in with the invited account. Your invitation will be preserved.',
logoutAndReturn: 'Sign out and return to this invitation',
switchAccount: 'Switch account',
registerAndAccept: 'Create account and accept',
alreadyHaveAccount: 'I already have an account',
confirmPassword: 'Confirm password',
passwordMinimum: 'Password must contain at least 8 characters.',
passwordMismatch: 'The passwords do not match.',
backToLogin: 'Back to sign in',
memberUpdated: 'Member role updated',
memberUpdateFailed: 'Failed to update member role',
removeMember: 'Remove member',
removeMemberConfirm: 'Remove this member from the Workspace?',
memberRemoved: 'Member removed',
memberRemoveFailed: 'Failed to remove member',
transferOwnership: 'Transfer ownership',
types: {
personal: 'Personal',
team: 'Team',
},
roles: {
owner: 'Owner',
admin: 'Admin',
developer: 'Developer',
operator: 'Operator',
viewer: 'Viewer',
},
},
};
export default esES;
-5
View File
@@ -1378,11 +1378,6 @@ const jaJP = {
operator: 'オペレーター',
viewer: '閲覧者',
},
settings: 'Workspace Settings',
currentPlan: 'Current plan',
planUnavailable: 'Unavailable',
upgradePlan: 'Change or upgrade plan',
},
monitoring: {
title: 'ダッシュボード',
-100
View File
@@ -172,8 +172,6 @@ const ruRU = {
less: 'Свернуть',
noItems: 'Нет элементов',
termsOfService: 'Условия обслуживания',
apiKeyStoredSecurely: 'Secret shown only when created',
},
notFound: {
title: 'Страница не найдена',
@@ -325,11 +323,6 @@ const ruRU = {
fallbackList: 'Резервные модели',
addFallback: 'Добавить резервную модель',
},
ownerMustBindSpace:
'The Workspace owner must connect Space for LangBot Models.',
usesOwnerSpaceBilling:
"Uses the Workspace owner's Space billing and credits.",
},
bots: {
title: 'Боты',
@@ -1299,13 +1292,6 @@ const ruRU = {
setPasswordHint: 'Установите пароль для входа с email и паролем',
spaceEmailMismatch:
'Email входа через Space не совпадает с email локальной учётной записи',
space_account_not_registeredTitle: 'Account not registered',
space_account_not_registered:
'No local account is registered for this Space email. Ask the Workspace owner for an invitation.',
space_account_binding_requiredTitle: 'Space connection required',
space_account_binding_required:
'This local account must connect Space from Account settings before using Space login.',
},
monitoring: {
title: 'Мониторинг',
@@ -1563,8 +1549,6 @@ const ruRU = {
api: 'API',
storage: 'Хранилище',
account: 'Аккаунт',
workspace: 'Workspace',
},
},
storageAnalysis: {
@@ -1878,90 +1862,6 @@ const ruRU = {
unsupportedFileType:
'Неподдерживаемый тип файла. Поддерживаются только файлы .zip и .lbpkg',
},
workspace: {
title: 'Workspace',
description: 'Manage members, roles, and invitation links',
selectTitle: 'Choose a Workspace',
selectDescription: 'Select where you want to continue in LangBot.',
selectionLoadFailed:
'Your Workspaces could not be loaded. Please try again.',
switchWorkspace: 'Switch Workspace',
settings: 'Workspace Settings',
currentPlan: 'Current plan',
planUnavailable: 'Unavailable',
upgradePlan: 'Change or upgrade plan',
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.',
loadFailed: 'Failed to load Workspace information',
members: 'Members',
you: 'You',
inviteMember: 'Invite a member',
inviteDescription:
'Create a one-time link to add another user to this Workspace.',
emailPlaceholder: 'member@example.com',
createInvitation: 'Create invitation',
invitationCreated: 'Invitation created',
delivery: {
sent: 'Invitation sent',
link_only: 'Invitation link created',
failed: 'Invitation link created, but email could not be sent',
},
invitationCreateFailed: 'Failed to create invitation',
oneTimeLinkWarning: 'Copy this link now. It is shown only once.',
copyInvitation: 'Copy invitation link',
invitationCopied: 'Invitation link copied',
pendingInvitations: 'Pending invitations',
expiresAt: 'Expires {{date}}',
revokeInvitation: 'Revoke invitation',
invitationRevoked: 'Invitation revoked',
invitationRevokeFailed: 'Failed to revoke invitation',
acceptInvitation: 'Accept invitation',
invitedToWorkspace: 'You were invited to {{workspace}}',
checkingInvitation: 'Checking this invitation...',
invitationMissing: 'This invitation link is missing required information.',
invitationExpired: 'This invitation has expired.',
invitationAlreadyRevoked: 'This invitation was revoked.',
invitationAlreadyUsed: 'This invitation was already used.',
invitationInvalid: 'This invitation is invalid or no longer available.',
invitationAccepted: 'Invitation accepted',
invitationAcceptFailed: 'Failed to accept invitation',
invitationEmailMismatch:
'This invitation belongs to a different email address.',
existingAccountLoginRequired:
'An account already exists for this email. Sign in to continue.',
acceptAsCurrentAccount: 'Accept with current account',
authenticatedInvitationNotice:
'Sign out first, then sign in with the invited account. Your invitation will be preserved.',
logoutAndReturn: 'Sign out and return to this invitation',
switchAccount: 'Switch account',
registerAndAccept: 'Create account and accept',
alreadyHaveAccount: 'I already have an account',
confirmPassword: 'Confirm password',
passwordMinimum: 'Password must contain at least 8 characters.',
passwordMismatch: 'The passwords do not match.',
backToLogin: 'Back to sign in',
memberUpdated: 'Member role updated',
memberUpdateFailed: 'Failed to update member role',
removeMember: 'Remove member',
removeMemberConfirm: 'Remove this member from the Workspace?',
memberRemoved: 'Member removed',
memberRemoveFailed: 'Failed to remove member',
transferOwnership: 'Transfer ownership',
types: {
personal: 'Personal',
team: 'Team',
},
roles: {
owner: 'Owner',
admin: 'Admin',
developer: 'Developer',
operator: 'Operator',
viewer: 'Viewer',
},
},
};
export default ruRU;
-100
View File
@@ -169,8 +169,6 @@ const thTH = {
more: 'เพิ่มเติม ({{count}})',
less: 'น้อยลง',
noItems: 'ไม่มีรายการ',
apiKeyStoredSecurely: 'Secret shown only when created',
},
notFound: {
title: 'ไม่พบหน้า',
@@ -312,11 +310,6 @@ const thTH = {
fallbackList: 'โมเดลสำรอง',
addFallback: 'เพิ่มโมเดลสำรอง',
},
ownerMustBindSpace:
'The Workspace owner must connect Space for LangBot Models.',
usesOwnerSpaceBilling:
"Uses the Workspace owner's Space billing and credits.",
},
bots: {
title: 'บอท',
@@ -1267,13 +1260,6 @@ const thTH = {
bindSpaceInvalidState: 'คำขอผูกไม่ถูกต้อง กรุณาลองใหม่จากการตั้งค่าบัญชี',
setPasswordHint: 'ตั้งรหัสผ่านเพื่อเข้าสู่ระบบด้วยอีเมลและรหัสผ่าน',
spaceEmailMismatch: 'อีเมลเข้าสู่ระบบ Space ไม่ตรงกับอีเมลบัญชีท้องถิ่น',
space_account_not_registeredTitle: 'Account not registered',
space_account_not_registered:
'No local account is registered for this Space email. Ask the Workspace owner for an invitation.',
space_account_binding_requiredTitle: 'Space connection required',
space_account_binding_required:
'This local account must connect Space from Account settings before using Space login.',
},
monitoring: {
title: 'แดชบอร์ด',
@@ -1530,8 +1516,6 @@ const thTH = {
api: 'API',
storage: 'พื้นที่จัดเก็บ',
account: 'บัญชี',
workspace: 'Workspace',
},
},
storageAnalysis: {
@@ -1835,90 +1819,6 @@ const thTH = {
createSkillHint: 'นำเข้าจากไดเรกทอรีในเครื่องหรือสร้างด้วยตนเอง',
unsupportedFileType: 'ประเภทไฟล์ไม่รองรับ รองรับเฉพาะไฟล์ .zip และ .lbpkg',
},
workspace: {
title: 'Workspace',
description: 'Manage members, roles, and invitation links',
selectTitle: 'Choose a Workspace',
selectDescription: 'Select where you want to continue in LangBot.',
selectionLoadFailed:
'Your Workspaces could not be loaded. Please try again.',
switchWorkspace: 'Switch Workspace',
settings: 'Workspace Settings',
currentPlan: 'Current plan',
planUnavailable: 'Unavailable',
upgradePlan: 'Change or upgrade plan',
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.',
loadFailed: 'Failed to load Workspace information',
members: 'Members',
you: 'You',
inviteMember: 'Invite a member',
inviteDescription:
'Create a one-time link to add another user to this Workspace.',
emailPlaceholder: 'member@example.com',
createInvitation: 'Create invitation',
invitationCreated: 'Invitation created',
delivery: {
sent: 'Invitation sent',
link_only: 'Invitation link created',
failed: 'Invitation link created, but email could not be sent',
},
invitationCreateFailed: 'Failed to create invitation',
oneTimeLinkWarning: 'Copy this link now. It is shown only once.',
copyInvitation: 'Copy invitation link',
invitationCopied: 'Invitation link copied',
pendingInvitations: 'Pending invitations',
expiresAt: 'Expires {{date}}',
revokeInvitation: 'Revoke invitation',
invitationRevoked: 'Invitation revoked',
invitationRevokeFailed: 'Failed to revoke invitation',
acceptInvitation: 'Accept invitation',
invitedToWorkspace: 'You were invited to {{workspace}}',
checkingInvitation: 'Checking this invitation...',
invitationMissing: 'This invitation link is missing required information.',
invitationExpired: 'This invitation has expired.',
invitationAlreadyRevoked: 'This invitation was revoked.',
invitationAlreadyUsed: 'This invitation was already used.',
invitationInvalid: 'This invitation is invalid or no longer available.',
invitationAccepted: 'Invitation accepted',
invitationAcceptFailed: 'Failed to accept invitation',
invitationEmailMismatch:
'This invitation belongs to a different email address.',
existingAccountLoginRequired:
'An account already exists for this email. Sign in to continue.',
acceptAsCurrentAccount: 'Accept with current account',
authenticatedInvitationNotice:
'Sign out first, then sign in with the invited account. Your invitation will be preserved.',
logoutAndReturn: 'Sign out and return to this invitation',
switchAccount: 'Switch account',
registerAndAccept: 'Create account and accept',
alreadyHaveAccount: 'I already have an account',
confirmPassword: 'Confirm password',
passwordMinimum: 'Password must contain at least 8 characters.',
passwordMismatch: 'The passwords do not match.',
backToLogin: 'Back to sign in',
memberUpdated: 'Member role updated',
memberUpdateFailed: 'Failed to update member role',
removeMember: 'Remove member',
removeMemberConfirm: 'Remove this member from the Workspace?',
memberRemoved: 'Member removed',
memberRemoveFailed: 'Failed to remove member',
transferOwnership: 'Transfer ownership',
types: {
personal: 'Personal',
team: 'Team',
},
roles: {
owner: 'Owner',
admin: 'Admin',
developer: 'Developer',
operator: 'Operator',
viewer: 'Viewer',
},
},
};
export default thTH;
-100
View File
@@ -172,8 +172,6 @@ const viVN = {
more: 'Thêm ({{count}})',
less: 'Thu gọn',
noItems: 'Không có mục nào',
apiKeyStoredSecurely: 'Secret shown only when created',
},
notFound: {
title: 'Không tìm thấy trang',
@@ -321,11 +319,6 @@ const viVN = {
fallbackList: 'Mô hình dự phòng',
addFallback: 'Thêm mô hình dự phòng',
},
ownerMustBindSpace:
'The Workspace owner must connect Space for LangBot Models.',
usesOwnerSpaceBilling:
"Uses the Workspace owner's Space billing and credits.",
},
bots: {
title: 'Bot',
@@ -1293,13 +1286,6 @@ const viVN = {
setPasswordHint: 'Đặt mật khẩu để đăng nhập bằng email và mật khẩu',
spaceEmailMismatch:
'Email đăng nhập Space không khớp với email tài khoản cục bộ',
space_account_not_registeredTitle: 'Account not registered',
space_account_not_registered:
'No local account is registered for this Space email. Ask the Workspace owner for an invitation.',
space_account_binding_requiredTitle: 'Space connection required',
space_account_binding_required:
'This local account must connect Space from Account settings before using Space login.',
},
monitoring: {
title: 'Bảng điều khiển',
@@ -1556,8 +1542,6 @@ const viVN = {
api: 'API',
storage: 'Lưu trữ',
account: 'Tài khoản',
workspace: 'Workspace',
},
},
storageAnalysis: {
@@ -1869,90 +1853,6 @@ const viVN = {
unsupportedFileType:
'Loại tệp không được hỗ trợ. Chỉ hỗ trợ tệp .zip và .lbpkg',
},
workspace: {
title: 'Workspace',
description: 'Manage members, roles, and invitation links',
selectTitle: 'Choose a Workspace',
selectDescription: 'Select where you want to continue in LangBot.',
selectionLoadFailed:
'Your Workspaces could not be loaded. Please try again.',
switchWorkspace: 'Switch Workspace',
settings: 'Workspace Settings',
currentPlan: 'Current plan',
planUnavailable: 'Unavailable',
upgradePlan: 'Change or upgrade plan',
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.',
loadFailed: 'Failed to load Workspace information',
members: 'Members',
you: 'You',
inviteMember: 'Invite a member',
inviteDescription:
'Create a one-time link to add another user to this Workspace.',
emailPlaceholder: 'member@example.com',
createInvitation: 'Create invitation',
invitationCreated: 'Invitation created',
delivery: {
sent: 'Invitation sent',
link_only: 'Invitation link created',
failed: 'Invitation link created, but email could not be sent',
},
invitationCreateFailed: 'Failed to create invitation',
oneTimeLinkWarning: 'Copy this link now. It is shown only once.',
copyInvitation: 'Copy invitation link',
invitationCopied: 'Invitation link copied',
pendingInvitations: 'Pending invitations',
expiresAt: 'Expires {{date}}',
revokeInvitation: 'Revoke invitation',
invitationRevoked: 'Invitation revoked',
invitationRevokeFailed: 'Failed to revoke invitation',
acceptInvitation: 'Accept invitation',
invitedToWorkspace: 'You were invited to {{workspace}}',
checkingInvitation: 'Checking this invitation...',
invitationMissing: 'This invitation link is missing required information.',
invitationExpired: 'This invitation has expired.',
invitationAlreadyRevoked: 'This invitation was revoked.',
invitationAlreadyUsed: 'This invitation was already used.',
invitationInvalid: 'This invitation is invalid or no longer available.',
invitationAccepted: 'Invitation accepted',
invitationAcceptFailed: 'Failed to accept invitation',
invitationEmailMismatch:
'This invitation belongs to a different email address.',
existingAccountLoginRequired:
'An account already exists for this email. Sign in to continue.',
acceptAsCurrentAccount: 'Accept with current account',
authenticatedInvitationNotice:
'Sign out first, then sign in with the invited account. Your invitation will be preserved.',
logoutAndReturn: 'Sign out and return to this invitation',
switchAccount: 'Switch account',
registerAndAccept: 'Create account and accept',
alreadyHaveAccount: 'I already have an account',
confirmPassword: 'Confirm password',
passwordMinimum: 'Password must contain at least 8 characters.',
passwordMismatch: 'The passwords do not match.',
backToLogin: 'Back to sign in',
memberUpdated: 'Member role updated',
memberUpdateFailed: 'Failed to update member role',
removeMember: 'Remove member',
removeMemberConfirm: 'Remove this member from the Workspace?',
memberRemoved: 'Member removed',
memberRemoveFailed: 'Failed to remove member',
transferOwnership: 'Transfer ownership',
types: {
personal: 'Personal',
team: 'Team',
},
roles: {
owner: 'Owner',
admin: 'Admin',
developer: 'Developer',
operator: 'Operator',
viewer: 'Viewer',
},
},
};
export default viVN;
-100
View File
@@ -160,8 +160,6 @@ const zhHant = {
more: '更多 ({{count}})',
less: '收起',
noItems: '暫無內容',
apiKeyStoredSecurely: 'Secret shown only when created',
},
notFound: {
title: '頁面不存在',
@@ -302,11 +300,6 @@ const zhHant = {
fallbackList: '備用模型',
addFallback: '新增備用模型',
},
ownerMustBindSpace:
'The Workspace owner must connect Space for LangBot Models.',
usesOwnerSpaceBilling:
"Uses the Workspace owner's Space billing and credits.",
},
bots: {
title: '機器人',
@@ -1223,13 +1216,6 @@ const zhHant = {
bindSpaceInvalidState: '無效的綁定請求,請從帳戶設定重新發起',
setPasswordHint: '設定密碼後可使用電子郵件密碼登入',
spaceEmailMismatch: 'Space登入帳號電子郵件與本實例帳號電子郵件不匹配',
space_account_not_registeredTitle: 'Account not registered',
space_account_not_registered:
'No local account is registered for this Space email. Ask the Workspace owner for an invitation.',
space_account_binding_requiredTitle: 'Space connection required',
space_account_binding_required:
'This local account must connect Space from Account settings before using Space login.',
},
monitoring: {
title: '儀表盤',
@@ -1485,8 +1471,6 @@ const zhHant = {
api: 'API',
storage: '儲存',
account: '帳戶',
workspace: 'Workspace',
},
},
storageAnalysis: {
@@ -1778,90 +1762,6 @@ const zhHant = {
saveFileSuccess: '檔案儲存成功',
saveFileError: '檔案儲存失敗:',
},
workspace: {
title: 'Workspace',
description: 'Manage members, roles, and invitation links',
selectTitle: 'Choose a Workspace',
selectDescription: 'Select where you want to continue in LangBot.',
selectionLoadFailed:
'Your Workspaces could not be loaded. Please try again.',
switchWorkspace: 'Switch Workspace',
settings: 'Workspace Settings',
currentPlan: 'Current plan',
planUnavailable: 'Unavailable',
upgradePlan: 'Change or upgrade plan',
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.',
loadFailed: 'Failed to load Workspace information',
members: 'Members',
you: 'You',
inviteMember: 'Invite a member',
inviteDescription:
'Create a one-time link to add another user to this Workspace.',
emailPlaceholder: 'member@example.com',
createInvitation: 'Create invitation',
invitationCreated: 'Invitation created',
delivery: {
sent: 'Invitation sent',
link_only: 'Invitation link created',
failed: 'Invitation link created, but email could not be sent',
},
invitationCreateFailed: 'Failed to create invitation',
oneTimeLinkWarning: 'Copy this link now. It is shown only once.',
copyInvitation: 'Copy invitation link',
invitationCopied: 'Invitation link copied',
pendingInvitations: 'Pending invitations',
expiresAt: 'Expires {{date}}',
revokeInvitation: 'Revoke invitation',
invitationRevoked: 'Invitation revoked',
invitationRevokeFailed: 'Failed to revoke invitation',
acceptInvitation: 'Accept invitation',
invitedToWorkspace: 'You were invited to {{workspace}}',
checkingInvitation: 'Checking this invitation...',
invitationMissing: 'This invitation link is missing required information.',
invitationExpired: 'This invitation has expired.',
invitationAlreadyRevoked: 'This invitation was revoked.',
invitationAlreadyUsed: 'This invitation was already used.',
invitationInvalid: 'This invitation is invalid or no longer available.',
invitationAccepted: 'Invitation accepted',
invitationAcceptFailed: 'Failed to accept invitation',
invitationEmailMismatch:
'This invitation belongs to a different email address.',
existingAccountLoginRequired:
'An account already exists for this email. Sign in to continue.',
acceptAsCurrentAccount: 'Accept with current account',
authenticatedInvitationNotice:
'Sign out first, then sign in with the invited account. Your invitation will be preserved.',
logoutAndReturn: 'Sign out and return to this invitation',
switchAccount: 'Switch account',
registerAndAccept: 'Create account and accept',
alreadyHaveAccount: 'I already have an account',
confirmPassword: 'Confirm password',
passwordMinimum: 'Password must contain at least 8 characters.',
passwordMismatch: 'The passwords do not match.',
backToLogin: 'Back to sign in',
memberUpdated: 'Member role updated',
memberUpdateFailed: 'Failed to update member role',
removeMember: 'Remove member',
removeMemberConfirm: 'Remove this member from the Workspace?',
memberRemoved: 'Member removed',
memberRemoveFailed: 'Failed to remove member',
transferOwnership: 'Transfer ownership',
types: {
personal: 'Personal',
team: 'Team',
},
roles: {
owner: 'Owner',
admin: 'Admin',
developer: 'Developer',
operator: 'Operator',
viewer: 'Viewer',
},
},
};
export default zhHant;
-161
View File
@@ -165,164 +165,3 @@ test('an authenticated OSS invitation requires logout before registration', asyn
invitation: 'logout-invitation',
});
});
test('an authenticated Cloud Account can accept its invitation directly', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
storage: {
token: 'invited-account-token',
userEmail: 'invited@example.com',
},
});
await page.route('**/api/v1/user/account-info', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
code: 0,
data: {
initialized: true,
authenticated_invitation_acceptance_enabled: true,
password_login_enabled: false,
space_login_enabled: true,
},
msg: 'ok',
}),
});
});
await page.route('**/api/v1/invitations/inspect', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
code: 0,
data: {
invitation: {
uuid: 'cloud-invitation',
workspace_uuid: 'workspace-playwright',
normalized_email: 'invited@example.com',
role: 'viewer',
status: 'pending',
},
workspace: {
uuid: 'workspace-playwright',
name: 'Playwright Workspace',
},
},
msg: 'ok',
}),
});
});
let acceptanceAuthorization = '';
await page.route('**/api/v1/invitations/accept', async (route) => {
acceptanceAuthorization = route.request().headers().authorization ?? '';
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
code: 0,
data: {
token: 'accepted-cloud-account-token',
workspace_uuid: 'workspace-playwright',
},
msg: 'ok',
}),
});
});
await page.goto('/invitations/accept#token=cloud-invitation');
await expect(
page.getByRole('button', { name: 'Accept Invitation' }),
).toBeVisible();
await page.getByRole('button', { name: 'Accept Invitation' }).click();
await expect(page).toHaveURL(/\/home(?:\/monitoring)?$/);
expect(acceptanceAuthorization).toBe('Bearer invited-account-token');
});
test('Space OAuth accepts a pending invitation with the freshly authenticated account', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: false,
storage: {
token: 'stale-other-account-token',
userEmail: 'other@example.com',
},
});
await page.addInitScript(() => {
sessionStorage.setItem(
'langbot_pending_invitation_token',
'matching-invitation',
);
});
await page.route('**/api/v1/user/space/callback', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
code: 0,
data: {
token: 'fresh-invited-account-token',
user: 'invited@example.com',
},
msg: 'ok',
}),
});
});
await page.route('**/api/v1/user/info', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
code: 0,
data: {
account_uuid: 'invited-account',
user: 'invited@example.com',
account_type: 'space',
has_password: false,
},
msg: 'ok',
}),
});
});
let acceptanceAuthorization = '';
await page.route('**/api/v1/invitations/accept', async (route) => {
acceptanceAuthorization = route.request().headers().authorization ?? '';
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
code: 0,
data: {
token: 'accepted-invited-account-token',
workspace_uuid: 'workspace-playwright',
},
msg: 'ok',
}),
});
});
await page.goto('/auth/space/callback?code=oauth-code&state=oauth-state');
await expect(page).toHaveURL(/\/home(?:\/monitoring)?$/, {
timeout: 5_000,
});
expect(acceptanceAuthorization).toBe('Bearer fresh-invited-account-token');
expect(
await page.evaluate(() => ({
token: localStorage.getItem('token'),
userEmail: localStorage.getItem('userEmail'),
invitation: sessionStorage.getItem('langbot_pending_invitation_token'),
})),
).toEqual({
token: 'accepted-invited-account-token',
userEmail: 'invited@example.com',
invitation: null,
});
});
@@ -13,12 +13,12 @@ test('direct launch assertion is fragment-only and removed before exchange', ()
const clearIndex = source.indexOf('window.history.replaceState');
const exchangeIndex = source.indexOf('handleOAuthCallback(', clearIndex);
assert.ok(readIndex >= 0, 'fragment assertion read is missing');
assert.ok(
clearIndex > readIndex,
'URL fragment is not cleared after copying the assertion',
);
assert.ok(
exchangeIndex > clearIndex,
'assertion exchange starts before the fragment is cleared',
);
assert.ok(clearIndex > readIndex, 'URL fragment is not cleared after copying the assertion');
assert.ok(exchangeIndex > clearIndex, 'assertion exchange starts before the fragment is cleared');
});
test('direct launch honors only a local signed return path', () => {
assert.match(source, /response\.return_path\.startsWith\(['"]\/['"]\)/);
assert.match(source, /!response\.return_path\.startsWith\(['"]\/\/['"]\)/);
assert.match(source, /navigate\(returnPath, \{ replace: true \}\)/);
});
@@ -1,42 +0,0 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const root = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../..',
);
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
test('support-admin launch stores a scoped principal instead of starting an Account session', () => {
const callback = read('src/app/auth/space/callback/page.tsx');
assert.match(callback, /response\.principal_type === 'support_admin'/);
assert.match(
callback,
/beginSupportAdminSession\(response\.token, response\.workspace_uuid\)/,
);
});
test('support-admin workspace bootstrap never calls Account bootstrap', () => {
const source = read('src/app/infra/http/index.ts');
assert.match(source, /export function beginSupportAdminSession/);
assert.match(source, /export function isSupportAdminSession\(\): boolean/);
const supportBranch = source.indexOf('if (isSupportAdminSession())');
const accountBootstrap = source.indexOf(
'backendClient.getWorkspaceBootstrap()',
);
assert.ok(supportBranch >= 0);
assert.ok(accountBootstrap > supportBranch);
assert.match(
source.slice(supportBranch, accountBootstrap),
/initializeWorkspaceInfo\([\s\S]*status: 'ready'/,
);
assert.match(
source,
/localStorage\.setItem\('authPrincipalType', 'support_admin'\)/,
);
const homeLayout = read('src/app/home/layout.tsx');
assert.match(homeLayout, /!isSupportAdminSession\(\)/);
});