mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-20 19:36:08 +00:00
feat(tenancy): establish cloud isolation foundations
This commit is contained in:
@@ -69,6 +69,7 @@ class ExecutionContext:
|
||||
pipeline_uuid: str | None = None
|
||||
query_uuid: str | None = None
|
||||
trigger_principal: PrincipalContext | None = None
|
||||
entitlement_revision: int = 0
|
||||
|
||||
@classmethod
|
||||
def from_request(
|
||||
@@ -89,4 +90,5 @@ class ExecutionContext:
|
||||
pipeline_uuid=pipeline_uuid,
|
||||
query_uuid=query_uuid,
|
||||
trigger_principal=ctx.principal,
|
||||
entitlement_revision=ctx.entitlement_revision,
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ from quart.typing import RouteCallable
|
||||
from ....utils import constants
|
||||
from ....workspace.collaboration import MembershipPermissionError, WorkspaceCollaborationError
|
||||
from ....workspace.errors import WorkspaceNotFoundError
|
||||
from ....cloud.entitlements import EntitlementUnavailableError
|
||||
from ..authz import AuthorizationError, Permission, permissions_for_role, require_permission
|
||||
from ..context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext
|
||||
|
||||
@@ -244,6 +245,10 @@ class RouterGroup(abc.ABC):
|
||||
|
||||
requested_workspace_uuid = quart.request.headers.get('X-Workspace-Id')
|
||||
access = await collaboration_service.resolve_account_workspace(account_uuid, requested_workspace_uuid)
|
||||
entitlement_revision = await self._resolve_entitlement_revision(
|
||||
access.execution.instance_uuid,
|
||||
access.workspace.uuid,
|
||||
)
|
||||
request_context = RequestContext(
|
||||
instance_uuid=access.execution.instance_uuid,
|
||||
placement_generation=access.execution.placement_generation,
|
||||
@@ -260,6 +265,7 @@ class RouterGroup(abc.ABC):
|
||||
permissions=permissions_for_role(access.membership.role),
|
||||
membership_revision=access.membership.projection_revision,
|
||||
),
|
||||
entitlement_revision=entitlement_revision,
|
||||
)
|
||||
quart.g.request_context = request_context
|
||||
quart.g.workspace_membership = access.membership
|
||||
@@ -272,6 +278,10 @@ class RouterGroup(abc.ABC):
|
||||
if inspect.isawaitable(authenticated):
|
||||
identity = await authenticated
|
||||
if identity is not None:
|
||||
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,
|
||||
@@ -287,6 +297,7 @@ class RouterGroup(abc.ABC):
|
||||
role=None,
|
||||
permissions=identity.permissions,
|
||||
),
|
||||
entitlement_revision=entitlement_revision,
|
||||
)
|
||||
quart.g.request_context = request_context
|
||||
return request_context
|
||||
@@ -316,6 +327,18 @@ class RouterGroup(abc.ABC):
|
||||
quart.g.request_context = request_context
|
||||
return request_context
|
||||
|
||||
async def _resolve_entitlement_revision(self, instance_uuid: str, workspace_uuid: str) -> int:
|
||||
deployment = getattr(self.ap, 'deployment', None)
|
||||
if deployment is None or not getattr(deployment, 'multi_workspace_enabled', False):
|
||||
return 0
|
||||
resolver = getattr(self.ap, 'entitlement_resolver', None)
|
||||
if resolver is None:
|
||||
raise EntitlementUnavailableError('Workspace entitlement resolver is unavailable')
|
||||
if instance_uuid != resolver.instance_uuid:
|
||||
raise EntitlementUnavailableError('Workspace entitlement targets another LangBot instance')
|
||||
snapshot = await resolver.resolve(workspace_uuid)
|
||||
return snapshot.entitlement_revision
|
||||
|
||||
@staticmethod
|
||||
def _inject_handler_context(
|
||||
handler: RouteCallable,
|
||||
@@ -339,6 +362,8 @@ class RouterGroup(abc.ABC):
|
||||
return self.http_status(404, 'resource_not_found', 'Resource not found')
|
||||
if isinstance(error, MembershipPermissionError):
|
||||
return self.http_status(403, error.code, str(error))
|
||||
if isinstance(error, EntitlementUnavailableError):
|
||||
return self.http_status(403, 'entitlement_unavailable', str(error))
|
||||
request_id = self.request_id()
|
||||
logger = getattr(self.ap, 'logger', self.quart_app.logger)
|
||||
logger.warning(f'Authentication failed request_id={request_id} error_type={type(error).__name__}: {error}')
|
||||
|
||||
@@ -5,6 +5,7 @@ from urllib.parse import unquote
|
||||
|
||||
from ....authz import Permission
|
||||
from ....context import RequestContext
|
||||
from ......provider.tools.loaders.mcp_policy import MCPStdioDisabledError
|
||||
from ... import group
|
||||
|
||||
|
||||
@@ -31,6 +32,8 @@ class MCPRouterGroup(group.RouterGroup):
|
||||
data = await quart.request.json
|
||||
try:
|
||||
server_uuid = await self.ap.mcp_service.create_mcp_server(request_context, data)
|
||||
except MCPStdioDisabledError as exc:
|
||||
return self.http_status(403, exc.code, str(exc))
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
return self.success(data={'uuid': server_uuid})
|
||||
@@ -63,6 +66,8 @@ class MCPRouterGroup(group.RouterGroup):
|
||||
data = await quart.request.json
|
||||
try:
|
||||
await self.ap.mcp_service.update_mcp_server(request_context, server_data['uuid'], data)
|
||||
except MCPStdioDisabledError as exc:
|
||||
return self.http_status(403, exc.code, str(exc))
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
else:
|
||||
@@ -79,11 +84,14 @@ class MCPRouterGroup(group.RouterGroup):
|
||||
"""测试MCP服务器连接"""
|
||||
server_name = unquote(server_name)
|
||||
server_data = await quart.request.json
|
||||
task_id = await self.ap.mcp_service.test_mcp_server(
|
||||
request_context,
|
||||
server_name=server_name,
|
||||
server_data=server_data,
|
||||
)
|
||||
try:
|
||||
task_id = await self.ap.mcp_service.test_mcp_server(
|
||||
request_context,
|
||||
server_name=server_name,
|
||||
server_data=server_data,
|
||||
)
|
||||
except MCPStdioDisabledError as exc:
|
||||
return self.http_status(403, exc.code, str(exc))
|
||||
return self.success(data={'task_id': task_id})
|
||||
|
||||
@self.route(
|
||||
|
||||
@@ -8,6 +8,7 @@ from .....utils import constants
|
||||
from .....entity.persistence.metadata import WorkspaceMetadata
|
||||
from ...authz import Permission
|
||||
from ...context import RequestContext
|
||||
from .....provider.tools.loaders.mcp_policy import stdio_mcp_enabled
|
||||
|
||||
|
||||
@group.group_class('system', '/api/v1/system')
|
||||
@@ -70,6 +71,9 @@ class SystemRouterGroup(group.RouterGroup):
|
||||
'disable_models_service': self.ap.instance_config.data.get('space', {}).get(
|
||||
'disable_models_service', False
|
||||
),
|
||||
# Exposed independently of Box status so the WebUI cannot
|
||||
# infer stdio permission from sandbox availability.
|
||||
'mcp_stdio_enabled': stdio_mcp_enabled(self.ap),
|
||||
'limitation': self.ap.instance_config.data.get('system', {}).get('limitation', {}),
|
||||
'outbound_ips': outbound_ips,
|
||||
'wizard_status': wizard_status,
|
||||
|
||||
@@ -11,6 +11,7 @@ from ....core import app, taskmgr
|
||||
from ....entity.persistence import mcp as persistence_mcp
|
||||
from ....entity.persistence import plugin as persistence_plugin
|
||||
from ....provider.tools.loaders.mcp import MCPSessionStatus, RuntimeMCPSession
|
||||
from ....provider.tools.loaders.mcp_policy import require_stdio_mcp_enabled
|
||||
from ....workspace.errors import WorkspaceNotFoundError
|
||||
from ..context import ExecutionContext
|
||||
from .secrets import is_url_key, redact_url_secrets, restore_url_secret_placeholders
|
||||
@@ -207,6 +208,10 @@ class MCPService:
|
||||
execution_context = await self._execution_context(context)
|
||||
workspace_uuid = execution_context.workspace_uuid
|
||||
|
||||
# This gate is independent of Box availability. Cloud v2 disables
|
||||
# stdio MCP even though Box Runtime itself remains available.
|
||||
require_stdio_mcp_enabled(self.ap, server_data)
|
||||
|
||||
limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})
|
||||
max_extensions = limitation.get('max_extensions', -1)
|
||||
if max_extensions >= 0:
|
||||
@@ -315,6 +320,13 @@ class MCPService:
|
||||
if duplicate is not None and duplicate['uuid'] != server_uuid:
|
||||
raise ValueError(f'MCP server already exists: {payload["name"]}')
|
||||
|
||||
effective_server = {**old_server, **payload}
|
||||
# Existing disabled rows remain readable/deletable. Switching away
|
||||
# from stdio or explicitly disabling one is also allowed, but an
|
||||
# update may never leave a disabled stdio server enabled.
|
||||
if bool(effective_server.get('enable', True)):
|
||||
require_stdio_mcp_enabled(self.ap, effective_server)
|
||||
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.update(persistence_mcp.MCPServer)
|
||||
@@ -405,7 +417,8 @@ class MCPService:
|
||||
ctx = taskmgr.TaskContext.new()
|
||||
|
||||
if server_name != '_':
|
||||
await self._require_server(execution_context, server_name)
|
||||
_, persisted_server = await self._require_server(execution_context, server_name)
|
||||
require_stdio_mcp_enabled(self.ap, persisted_server)
|
||||
runtime_mcp_session = self.ap.tool_mgr.mcp_tool_loader.get_session(execution_context, server_name)
|
||||
if runtime_mcp_session is None:
|
||||
raise WorkspaceNotFoundError('MCP server not found')
|
||||
@@ -427,6 +440,7 @@ class MCPService:
|
||||
payload = dict(server_data)
|
||||
payload.pop('workspace_uuid', None)
|
||||
payload['workspace_uuid'] = execution_context.workspace_uuid
|
||||
require_stdio_mcp_enabled(self.ap, payload)
|
||||
runtime_mcp_session = await self.ap.tool_mgr.mcp_tool_loader.load_mcp_server(
|
||||
execution_context,
|
||||
payload,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Contracts used by the optional closed Cloud control-plane bootstrap."""
|
||||
|
||||
from .bootstrap import (
|
||||
CloudBootstrapError,
|
||||
OpenSourceDeployment,
|
||||
VerifiedCloudDeployment,
|
||||
resolve_deployment,
|
||||
)
|
||||
from .entitlements import (
|
||||
EntitlementProvider,
|
||||
EntitlementResolver,
|
||||
EntitlementSnapshot,
|
||||
EntitlementUnavailableError,
|
||||
OpenSourceEntitlementProvider,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'CloudBootstrapError',
|
||||
'EntitlementProvider',
|
||||
'EntitlementResolver',
|
||||
'EntitlementSnapshot',
|
||||
'EntitlementUnavailableError',
|
||||
'OpenSourceDeployment',
|
||||
'OpenSourceEntitlementProvider',
|
||||
'VerifiedCloudDeployment',
|
||||
'resolve_deployment',
|
||||
]
|
||||
@@ -0,0 +1,161 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import importlib.metadata
|
||||
import inspect
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, Protocol
|
||||
|
||||
from ..workspace.policy import CloudWorkspacePolicy, SingleWorkspacePolicy
|
||||
from .entitlements import EntitlementProvider, OpenSourceEntitlementProvider
|
||||
|
||||
|
||||
CLOUD_BOOTSTRAP_ENTRY_POINT = 'langbot.cloud_bootstrap'
|
||||
REQUIRED_TENANT_ISOLATION_VERSION = 2
|
||||
|
||||
|
||||
class CloudBootstrapError(RuntimeError):
|
||||
"""Fail-closed Cloud bootstrap validation error."""
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True, slots=True)
|
||||
class OpenSourceDeployment:
|
||||
"""Default deployment selected when no closed bootstrap is installed."""
|
||||
|
||||
mode: str = 'oss'
|
||||
workspace_policy: SingleWorkspacePolicy = dataclasses.field(default_factory=SingleWorkspacePolicy)
|
||||
entitlement_provider: OpenSourceEntitlementProvider = dataclasses.field(
|
||||
default_factory=OpenSourceEntitlementProvider
|
||||
)
|
||||
persistence_mode: str = 'oss_compat'
|
||||
required_vector_backend: str | None = None
|
||||
|
||||
@property
|
||||
def multi_workspace_enabled(self) -> bool:
|
||||
return False
|
||||
|
||||
def validate_instance_config(self, config: dict[str, Any]) -> None:
|
||||
del config
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True, slots=True)
|
||||
class VerifiedCloudDeployment:
|
||||
"""Receipt returned only after the closed package verifies a Manifest.
|
||||
|
||||
Core deliberately does not accept a config flag as a substitute for this
|
||||
object. The closed entry point owns root-key/JWS verification and the
|
||||
entitlement adapter; open Core validates the receipt's runtime invariants.
|
||||
"""
|
||||
|
||||
instance_uuid: str
|
||||
manifest_jti: str
|
||||
manifest_generation: int
|
||||
expires_at: int
|
||||
release: str
|
||||
capabilities: frozenset[str]
|
||||
tenant_isolation_version: int
|
||||
entitlement_provider: EntitlementProvider
|
||||
verification_key_id: str
|
||||
mode: str = dataclasses.field(default='cloud', init=False)
|
||||
workspace_policy: CloudWorkspacePolicy = dataclasses.field(default_factory=CloudWorkspacePolicy, init=False)
|
||||
persistence_mode: str = dataclasses.field(default='cloud_runtime', init=False)
|
||||
required_vector_backend: str = dataclasses.field(default='pgvector', init=False)
|
||||
|
||||
@property
|
||||
def multi_workspace_enabled(self) -> bool:
|
||||
return True
|
||||
|
||||
def validate(self, expected_instance_uuid: str, *, now: int | None = None) -> None:
|
||||
current_time = int(time.time()) if now is None else now
|
||||
if not self.instance_uuid or self.instance_uuid != expected_instance_uuid:
|
||||
raise CloudBootstrapError('Verified Cloud Manifest targets another LangBot instance')
|
||||
if not self.manifest_jti or not self.verification_key_id:
|
||||
raise CloudBootstrapError('Verified Cloud Manifest receipt is incomplete')
|
||||
if isinstance(self.manifest_generation, bool) or self.manifest_generation <= 0:
|
||||
raise CloudBootstrapError('Verified Cloud Manifest generation must be positive')
|
||||
if self.expires_at <= current_time:
|
||||
raise CloudBootstrapError('Verified Cloud Manifest is expired')
|
||||
if self.tenant_isolation_version < REQUIRED_TENANT_ISOLATION_VERSION:
|
||||
raise CloudBootstrapError('Verified Cloud Manifest requires an unsupported tenant isolation version')
|
||||
if 'multi_workspace_v2' not in self.capabilities:
|
||||
raise CloudBootstrapError('Verified Cloud Manifest does not grant multi_workspace_v2')
|
||||
if not isinstance(self.entitlement_provider, EntitlementProvider):
|
||||
raise CloudBootstrapError('Verified Cloud bootstrap did not provide an entitlement adapter')
|
||||
|
||||
def validate_instance_config(self, config: dict[str, Any]) -> None:
|
||||
if config.get('database', {}).get('use') != 'postgresql':
|
||||
raise CloudBootstrapError('Cloud runtime requires database.use=postgresql')
|
||||
if config.get('vdb', {}).get('use') != self.required_vector_backend:
|
||||
raise CloudBootstrapError('Cloud runtime requires vdb.use=pgvector')
|
||||
if config.get('mcp', {}).get('stdio', {}).get('enabled', True) is not False:
|
||||
raise CloudBootstrapError('Cloud runtime requires mcp.stdio.enabled=false')
|
||||
|
||||
|
||||
class CloudBootstrapProvider(Protocol):
|
||||
def bootstrap(
|
||||
self,
|
||||
*,
|
||||
instance_uuid: str,
|
||||
instance_config: dict[str, Any],
|
||||
) -> VerifiedCloudDeployment | Awaitable[VerifiedCloudDeployment]: ...
|
||||
|
||||
|
||||
async def _invoke_provider(
|
||||
loaded: Any,
|
||||
*,
|
||||
instance_uuid: str,
|
||||
instance_config: dict[str, Any],
|
||||
) -> VerifiedCloudDeployment:
|
||||
provider = loaded() if inspect.isclass(loaded) else loaded
|
||||
bootstrap = getattr(provider, 'bootstrap', None)
|
||||
if not callable(bootstrap):
|
||||
raise CloudBootstrapError('Cloud bootstrap entry point must expose bootstrap()')
|
||||
result = bootstrap(instance_uuid=instance_uuid, instance_config=instance_config)
|
||||
if inspect.isawaitable(result):
|
||||
result = await result
|
||||
if not isinstance(result, VerifiedCloudDeployment):
|
||||
raise CloudBootstrapError('Cloud bootstrap must return VerifiedCloudDeployment')
|
||||
return result
|
||||
|
||||
|
||||
async def resolve_deployment(
|
||||
*,
|
||||
instance_uuid: str,
|
||||
instance_config: dict[str, Any],
|
||||
entry_points: Callable[[], Any] | None = None,
|
||||
now: int | None = None,
|
||||
) -> OpenSourceDeployment | VerifiedCloudDeployment:
|
||||
"""Discover the optional closed bootstrap and validate its receipt.
|
||||
|
||||
Absence selects OSS singleton mode. Presence is fail-closed: duplicate,
|
||||
broken, invalid, or expired providers never fall back to an OSS Workspace.
|
||||
"""
|
||||
|
||||
discover = entry_points or importlib.metadata.entry_points
|
||||
discovered = discover()
|
||||
if hasattr(discovered, 'select'):
|
||||
candidates = list(discovered.select(group=CLOUD_BOOTSTRAP_ENTRY_POINT))
|
||||
else: # Python/importlib compatibility for dict-like EntryPoints
|
||||
candidates = list(discovered.get(CLOUD_BOOTSTRAP_ENTRY_POINT, ()))
|
||||
if not candidates:
|
||||
deployment = OpenSourceDeployment()
|
||||
deployment.validate_instance_config(instance_config)
|
||||
return deployment
|
||||
if len(candidates) != 1:
|
||||
raise CloudBootstrapError('Exactly one Cloud bootstrap provider may be installed')
|
||||
|
||||
try:
|
||||
loaded = candidates[0].load()
|
||||
deployment = await _invoke_provider(
|
||||
loaded,
|
||||
instance_uuid=instance_uuid,
|
||||
instance_config=instance_config,
|
||||
)
|
||||
deployment.validate(instance_uuid, now=now)
|
||||
deployment.validate_instance_config(instance_config)
|
||||
return deployment
|
||||
except CloudBootstrapError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise CloudBootstrapError('Closed Cloud bootstrap failed') from exc
|
||||
@@ -0,0 +1,142 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
import pydantic
|
||||
|
||||
|
||||
class EntitlementUnavailableError(RuntimeError):
|
||||
"""Raised when a trusted, currently-active entitlement is unavailable."""
|
||||
|
||||
|
||||
class EntitlementSnapshot(pydantic.BaseModel):
|
||||
"""Plan-agnostic capability projection consumed by open-source Core.
|
||||
|
||||
Product and billing names deliberately do not appear here. The closed
|
||||
Control Plane maps subscriptions to these generic features and limits.
|
||||
"""
|
||||
|
||||
model_config = pydantic.ConfigDict(frozen=True, extra='forbid')
|
||||
|
||||
instance_uuid: str = pydantic.Field(min_length=1, max_length=256)
|
||||
workspace_uuid: str = pydantic.Field(min_length=1, max_length=256)
|
||||
entitlement_revision: int = pydantic.Field(ge=1)
|
||||
status: str = pydantic.Field(pattern=r'^(active|suspended|cancelled)$')
|
||||
not_before: int = pydantic.Field(ge=0)
|
||||
expires_at: int = pydantic.Field(gt=0)
|
||||
features: dict[str, bool] = pydantic.Field(default_factory=dict)
|
||||
limits: dict[str, int] = pydantic.Field(default_factory=dict)
|
||||
|
||||
@pydantic.field_validator('features')
|
||||
@classmethod
|
||||
def _validate_feature_names(cls, value: dict[str, bool]) -> dict[str, bool]:
|
||||
if any(not str(name).strip() for name in value):
|
||||
raise ValueError('Entitlement feature names must be non-empty')
|
||||
return dict(value)
|
||||
|
||||
@pydantic.field_validator('limits')
|
||||
@classmethod
|
||||
def _validate_limits(cls, value: dict[str, int]) -> dict[str, int]:
|
||||
normalized: dict[str, int] = {}
|
||||
for name, limit in value.items():
|
||||
if not str(name).strip():
|
||||
raise ValueError('Entitlement limit names must be non-empty')
|
||||
if isinstance(limit, bool) or not isinstance(limit, int) or limit < 0:
|
||||
raise ValueError(f'Entitlement limit {name!r} must be a non-negative integer')
|
||||
normalized[str(name)] = limit
|
||||
return normalized
|
||||
|
||||
def require_active(
|
||||
self,
|
||||
*,
|
||||
instance_uuid: str,
|
||||
workspace_uuid: str,
|
||||
now: int | None = None,
|
||||
) -> EntitlementSnapshot:
|
||||
current_time = int(time.time()) if now is None else now
|
||||
if self.instance_uuid != instance_uuid or self.workspace_uuid != workspace_uuid:
|
||||
raise EntitlementUnavailableError('Entitlement scope does not match the Workspace execution context')
|
||||
if self.status != 'active':
|
||||
raise EntitlementUnavailableError('Workspace entitlement is not active')
|
||||
if current_time < self.not_before or current_time >= self.expires_at:
|
||||
raise EntitlementUnavailableError('Workspace entitlement is not currently valid')
|
||||
return self
|
||||
|
||||
def require_feature(self, feature: str) -> None:
|
||||
if self.features.get(feature) is not True:
|
||||
raise EntitlementUnavailableError(f'Workspace entitlement does not grant {feature}')
|
||||
|
||||
def limit(self, name: str) -> int:
|
||||
value = self.limits.get(name)
|
||||
if value is None:
|
||||
raise EntitlementUnavailableError(f'Workspace entitlement does not define limit {name}')
|
||||
return value
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class EntitlementProvider(Protocol):
|
||||
"""Closed Control Plane adapter injected by a verified Cloud bootstrap."""
|
||||
|
||||
async def get_workspace_entitlement(self, workspace_uuid: str) -> EntitlementSnapshot:
|
||||
"""Return the newest verified snapshot for one Workspace."""
|
||||
|
||||
|
||||
class OpenSourceEntitlementProvider:
|
||||
"""Marker provider for OSS; Cloud admission grants never use this class."""
|
||||
|
||||
async def get_workspace_entitlement(self, workspace_uuid: str) -> EntitlementSnapshot:
|
||||
del workspace_uuid
|
||||
raise EntitlementUnavailableError('Signed Workspace entitlements are only available in Cloud mode')
|
||||
|
||||
|
||||
class EntitlementResolver:
|
||||
"""Validate scope/freshness and reject revision rollback or equivocation."""
|
||||
|
||||
def __init__(self, instance_uuid: str, provider: EntitlementProvider) -> None:
|
||||
self.instance_uuid = instance_uuid
|
||||
self.provider = provider
|
||||
self._lock = asyncio.Lock()
|
||||
self._snapshots: dict[str, tuple[int, str, EntitlementSnapshot]] = {}
|
||||
|
||||
@staticmethod
|
||||
def _fingerprint(snapshot: EntitlementSnapshot) -> str:
|
||||
return json.dumps(snapshot.model_dump(mode='json'), sort_keys=True, separators=(',', ':'))
|
||||
|
||||
async def resolve(
|
||||
self,
|
||||
workspace_uuid: str,
|
||||
*,
|
||||
minimum_revision: int = 0,
|
||||
now: int | None = None,
|
||||
) -> EntitlementSnapshot:
|
||||
candidate = await self.provider.get_workspace_entitlement(workspace_uuid)
|
||||
if not isinstance(candidate, EntitlementSnapshot):
|
||||
raise EntitlementUnavailableError('Entitlement provider returned an invalid snapshot')
|
||||
# Deep-copy untrusted provider-owned containers before caching them.
|
||||
candidate = EntitlementSnapshot.model_validate(candidate.model_dump())
|
||||
candidate.require_active(
|
||||
instance_uuid=self.instance_uuid,
|
||||
workspace_uuid=workspace_uuid,
|
||||
now=now,
|
||||
)
|
||||
if candidate.entitlement_revision < minimum_revision:
|
||||
raise EntitlementUnavailableError('Workspace entitlement revision rolled back')
|
||||
|
||||
fingerprint = self._fingerprint(candidate)
|
||||
async with self._lock:
|
||||
previous = self._snapshots.get(workspace_uuid)
|
||||
if previous is not None:
|
||||
previous_revision, previous_fingerprint, _ = previous
|
||||
if candidate.entitlement_revision < previous_revision:
|
||||
raise EntitlementUnavailableError('Workspace entitlement revision rolled back')
|
||||
if candidate.entitlement_revision == previous_revision and fingerprint != previous_fingerprint:
|
||||
raise EntitlementUnavailableError('Workspace entitlement revision has conflicting contents')
|
||||
self._snapshots[workspace_uuid] = (
|
||||
candidate.entitlement_revision,
|
||||
fingerprint,
|
||||
candidate,
|
||||
)
|
||||
return candidate.model_copy(deep=True)
|
||||
@@ -48,6 +48,8 @@ from ..survey import manager as survey_module
|
||||
from ..skill import manager as skill_mgr
|
||||
from ..workspace import service as workspace_service_module
|
||||
from ..workspace import collaboration as workspace_collaboration_module
|
||||
from ..cloud import bootstrap as cloud_bootstrap_module
|
||||
from ..cloud import entitlements as cloud_entitlements_module
|
||||
from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType
|
||||
from ..entity.persistence.workspace import WorkspaceExecutionState, WorkspaceExecutionStatus
|
||||
|
||||
@@ -128,6 +130,10 @@ class Application:
|
||||
|
||||
workspace_collaboration_service: workspace_collaboration_module.WorkspaceCollaborationService = None
|
||||
|
||||
deployment: cloud_bootstrap_module.OpenSourceDeployment | cloud_bootstrap_module.VerifiedCloudDeployment = None
|
||||
|
||||
entitlement_resolver: cloud_entitlements_module.EntitlementResolver | None = None
|
||||
|
||||
vector_db_mgr: vectordb_mgr.VectorDBManager = None
|
||||
|
||||
http_ctrl: http_controller.HTTPController = None
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
|
||||
from .. import stage, app
|
||||
from ...utils import version, proxy
|
||||
from ...utils import version, proxy, constants
|
||||
from ...pipeline import pool, controller, pipelinemgr
|
||||
from ...pipeline import aggregator as message_aggregator
|
||||
from ...box import service as box_service
|
||||
@@ -41,7 +41,8 @@ from ...telemetry import telemetry as telemetry_module
|
||||
from ...survey import manager as survey_module
|
||||
from ...workspace import service as workspace_service_module
|
||||
from ...workspace import collaboration as workspace_collaboration_module
|
||||
from ...workspace import policy as workspace_policy_module
|
||||
from ...cloud import bootstrap as cloud_bootstrap
|
||||
from ...cloud.entitlements import EntitlementResolver
|
||||
from ...api.http.context import ExecutionContext, PrincipalContext, PrincipalType
|
||||
from ...api.http.authz import WorkspaceRequiredError
|
||||
|
||||
@@ -52,6 +53,20 @@ class BuildAppStage(stage.BootingStage):
|
||||
|
||||
async def run(self, ap: app.Application):
|
||||
"""Build LangBot application"""
|
||||
# Multi-Workspace mode is selected only by an installed closed
|
||||
# bootstrap that returns a verified Manifest receipt. Mutable values
|
||||
# such as system.edition are intentionally absent from this boundary.
|
||||
deployment = await cloud_bootstrap.resolve_deployment(
|
||||
instance_uuid=constants.instance_id,
|
||||
instance_config=ap.instance_config.data,
|
||||
)
|
||||
ap.deployment = deployment
|
||||
ap.entitlement_resolver = (
|
||||
EntitlementResolver(constants.instance_id, deployment.entitlement_provider)
|
||||
if deployment.multi_workspace_enabled
|
||||
else None
|
||||
)
|
||||
|
||||
ap.task_mgr = taskmgr.AsyncTaskManager(ap)
|
||||
|
||||
discover = discover_engine.ComponentDiscoveryEngine(ap)
|
||||
@@ -109,16 +124,14 @@ class BuildAppStage(stage.BootingStage):
|
||||
await storage_mgr_inst.initialize()
|
||||
ap.storage_mgr = storage_mgr_inst
|
||||
|
||||
persistence_mgr_inst = persistencemgr.PersistenceManager(ap)
|
||||
persistence_mgr_inst = persistencemgr.PersistenceManager(
|
||||
ap,
|
||||
mode=persistencemgr.PersistenceMode(deployment.persistence_mode),
|
||||
)
|
||||
ap.persistence_mgr = persistence_mgr_inst
|
||||
await persistence_mgr_inst.initialize()
|
||||
|
||||
# The open-source Core is intentionally single-Workspace. A mutable
|
||||
# config value such as ``system.edition`` is product metadata, not a
|
||||
# trust credential, and must never activate SaaS routing. The closed
|
||||
# Cloud bootstrap will install its verified policy only after checking
|
||||
# a signed InstanceManifest; until that bootstrap exists, fail closed.
|
||||
workspace_policy = workspace_policy_module.open_core_workspace_policy()
|
||||
workspace_policy = deployment.workspace_policy
|
||||
workspace_service_inst = workspace_service_module.WorkspaceService(
|
||||
ap,
|
||||
policy=workspace_policy,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import copy
|
||||
from typing import Any
|
||||
from langbot.pkg.utils import constants
|
||||
import yaml
|
||||
@@ -12,6 +13,44 @@ from .. import stage, app
|
||||
from ..bootutils import config
|
||||
|
||||
|
||||
_RUNTIME_POLICY_DEFAULTS = {
|
||||
'plugin': {
|
||||
'worker': {
|
||||
'max_cpus': 1.0,
|
||||
'max_memory_mb': 512,
|
||||
'max_pids': 128,
|
||||
'max_open_files': 256,
|
||||
'max_file_size_mb': 512,
|
||||
}
|
||||
},
|
||||
'mcp': {'stdio': {'enabled': True}},
|
||||
}
|
||||
|
||||
|
||||
def _complete_runtime_policy_defaults(cfg: dict) -> dict:
|
||||
"""Backfill typed security-policy leaves before applying env overrides.
|
||||
|
||||
The historic config loader intentionally does not deep-complete the whole
|
||||
template. These fields are different: their native env overrides must
|
||||
retain boolean/numeric types on upgraded instances, so their defaults must
|
||||
exist before ``PLUGIN__...`` and ``MCP__...`` are parsed.
|
||||
"""
|
||||
|
||||
def merge(target: dict, defaults: dict, path: tuple[str, ...] = ()) -> None:
|
||||
for key, default in defaults.items():
|
||||
if key not in target:
|
||||
target[key] = copy.deepcopy(default)
|
||||
continue
|
||||
if isinstance(default, dict):
|
||||
if not isinstance(target[key], dict):
|
||||
dotted_path = '.'.join((*path, key))
|
||||
raise ValueError(f'{dotted_path} must be a mapping')
|
||||
merge(target[key], default, (*path, key))
|
||||
|
||||
merge(cfg, _RUNTIME_POLICY_DEFAULTS)
|
||||
return cfg
|
||||
|
||||
|
||||
def _apply_env_overrides_to_config(cfg: dict) -> dict:
|
||||
"""Apply environment variable overrides to data/config.yaml
|
||||
|
||||
@@ -150,6 +189,10 @@ class LoadConfigStage(stage.BootingStage):
|
||||
|
||||
ap.instance_config = await config.load_yaml_config('data/config.yaml', 'config.yaml', completion=False)
|
||||
|
||||
# Deep-complete only typed execution-policy fields. This keeps native
|
||||
# env coercion reliable for existing data/config.yaml files.
|
||||
ap.instance_config.data = _complete_runtime_policy_defaults(ap.instance_config.data)
|
||||
|
||||
# Apply environment variable overrides to data/config.yaml
|
||||
ap.instance_config.data = _apply_env_overrides_to_config(ap.instance_config.data)
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""enforce PostgreSQL tenant isolation with row-level security
|
||||
|
||||
Revision ID: 0011_postgres_tenant_rls
|
||||
Revises: 0010_scope_resources
|
||||
Create Date: 2026-07-19
|
||||
|
||||
The table list is deliberately duplicated from the runtime contract. Alembic
|
||||
revisions must remain self-contained after application code evolves.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = '0011_postgres_tenant_rls'
|
||||
down_revision = '0010_scope_resources'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
_POLICY_NAME = 'langbot_workspace_isolation'
|
||||
_TENANT_SETTING = 'langbot.workspace_uuid'
|
||||
_OSS_WORKSPACE_METADATA_KEY = 'oss_workspace_uuid'
|
||||
_TENANT_TABLE_COLUMNS: dict[str, str] = {
|
||||
'workspaces': 'uuid',
|
||||
'workspace_memberships': 'workspace_uuid',
|
||||
'workspace_invitations': 'workspace_uuid',
|
||||
'workspace_execution_states': 'workspace_uuid',
|
||||
'workspace_metadata': 'workspace_uuid',
|
||||
'api_keys': 'workspace_uuid',
|
||||
'bots': 'workspace_uuid',
|
||||
'bot_admins': 'workspace_uuid',
|
||||
'binary_storages': 'workspace_uuid',
|
||||
'mcp_servers': 'workspace_uuid',
|
||||
'model_providers': 'workspace_uuid',
|
||||
'llm_models': 'workspace_uuid',
|
||||
'embedding_models': 'workspace_uuid',
|
||||
'rerank_models': 'workspace_uuid',
|
||||
'legacy_pipelines': 'workspace_uuid',
|
||||
'pipeline_run_records': 'workspace_uuid',
|
||||
'plugin_settings': 'workspace_uuid',
|
||||
'knowledge_bases': 'workspace_uuid',
|
||||
'knowledge_base_files': 'workspace_uuid',
|
||||
'knowledge_base_chunks': 'workspace_uuid',
|
||||
'webhooks': 'workspace_uuid',
|
||||
'monitoring_messages': 'workspace_uuid',
|
||||
'monitoring_llm_calls': 'workspace_uuid',
|
||||
'monitoring_tool_calls': 'workspace_uuid',
|
||||
'monitoring_sessions': 'workspace_uuid',
|
||||
'monitoring_errors': 'workspace_uuid',
|
||||
'monitoring_embedding_calls': 'workspace_uuid',
|
||||
'monitoring_feedback': 'workspace_uuid',
|
||||
}
|
||||
|
||||
|
||||
def _quote_identifier(conn: sa.Connection, identifier: str) -> str:
|
||||
return conn.dialect.identifier_preparer.quote(identifier)
|
||||
|
||||
|
||||
def _record_oss_workspace_scope(conn: sa.Connection) -> None:
|
||||
"""Keep PostgreSQL OSS usable after FORCE RLS is enabled.
|
||||
|
||||
The runtime reads this instance-level metadata and installs the singleton
|
||||
Workspace as a transaction-local default. Cloud databases have no local
|
||||
Workspace and therefore do not receive this compatibility value.
|
||||
"""
|
||||
local_workspaces = (
|
||||
conn.execute(sa.text("SELECT uuid FROM workspaces WHERE source = 'local' ORDER BY uuid")).scalars().all()
|
||||
)
|
||||
if len(local_workspaces) != 1:
|
||||
return
|
||||
|
||||
existing = conn.execute(
|
||||
sa.text('SELECT value FROM metadata WHERE key = :key'),
|
||||
{'key': _OSS_WORKSPACE_METADATA_KEY},
|
||||
).scalar_one_or_none()
|
||||
if existing is None:
|
||||
conn.execute(
|
||||
sa.text('INSERT INTO metadata (key, value) VALUES (:key, :value)'),
|
||||
{'key': _OSS_WORKSPACE_METADATA_KEY, 'value': local_workspaces[0]},
|
||||
)
|
||||
elif existing != local_workspaces[0]:
|
||||
raise RuntimeError('Stored OSS Workspace scope does not match the local Workspace')
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if conn.dialect.name != 'postgresql':
|
||||
return
|
||||
|
||||
existing_tables = set(sa.inspect(conn).get_table_names())
|
||||
missing_tables = set(_TENANT_TABLE_COLUMNS) - existing_tables
|
||||
if missing_tables:
|
||||
raise RuntimeError(f'Cannot enable tenant RLS before all tenant-owned tables exist: {sorted(missing_tables)!r}')
|
||||
|
||||
_record_oss_workspace_scope(conn)
|
||||
|
||||
policy_name = _quote_identifier(conn, _POLICY_NAME)
|
||||
for table_name, tenant_column in _TENANT_TABLE_COLUMNS.items():
|
||||
table = _quote_identifier(conn, table_name)
|
||||
column = _quote_identifier(conn, tenant_column)
|
||||
tenant_value = f"CAST(NULLIF(current_setting('{_TENANT_SETTING}', true), '') AS VARCHAR(36))"
|
||||
|
||||
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_name} ON {table}'))
|
||||
op.execute(
|
||||
sa.text(
|
||||
f'CREATE POLICY {policy_name} ON {table} '
|
||||
f'USING ({column} = {tenant_value}) '
|
||||
f'WITH CHECK ({column} = {tenant_value})'
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if conn.dialect.name != 'postgresql':
|
||||
return
|
||||
|
||||
existing_tables = set(sa.inspect(conn).get_table_names())
|
||||
policy_name = _quote_identifier(conn, _POLICY_NAME)
|
||||
for table_name in _TENANT_TABLE_COLUMNS:
|
||||
if table_name not in existing_tables:
|
||||
continue
|
||||
table = _quote_identifier(conn, table_name)
|
||||
op.execute(sa.text(f'DROP POLICY IF EXISTS {policy_name} ON {table}'))
|
||||
op.execute(sa.text(f'ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY'))
|
||||
op.execute(sa.text(f'ALTER TABLE {table} DISABLE ROW LEVEL SECURITY'))
|
||||
@@ -18,6 +18,7 @@ from typing import TYPE_CHECKING
|
||||
from alembic.config import Config
|
||||
from alembic import command
|
||||
from alembic.runtime.migration import MigrationContext
|
||||
from alembic.script import ScriptDirectory
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
@@ -59,6 +60,16 @@ def _do_get_current(connection: Connection) -> str | None:
|
||||
return ctx.get_current_revision()
|
||||
|
||||
|
||||
def get_alembic_head() -> str:
|
||||
"""Resolve the single release head without opening a database connection."""
|
||||
cfg = Config()
|
||||
cfg.set_main_option('script_location', _ALEMBIC_DIR)
|
||||
head = ScriptDirectory.from_config(cfg).get_current_head()
|
||||
if head is None:
|
||||
raise RuntimeError('Alembic has no migration head')
|
||||
return head
|
||||
|
||||
|
||||
def _do_autogenerate(connection: Connection, message: str = 'auto migration') -> None:
|
||||
"""Synchronous autogenerate — runs inside run_sync."""
|
||||
cfg = _build_config(connection)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import enum
|
||||
import sqlite3
|
||||
import typing
|
||||
|
||||
@@ -15,6 +16,7 @@ from ..entity import persistence
|
||||
from ..core import app
|
||||
from ..utils import constants, importutil
|
||||
from . import databases, migrations
|
||||
from .tenant_uow import TENANT_POLICY_NAME, TENANT_SETTING, TENANT_TABLE_COLUMNS, TenantUnitOfWork
|
||||
|
||||
importutil.import_modules_in_pkg(databases)
|
||||
importutil.import_modules_in_pkg(migrations)
|
||||
@@ -64,6 +66,15 @@ _PRE_WORKSPACE_ALEMBIC_REVISIONS = {
|
||||
}
|
||||
_WORKSPACE_ALEMBIC_REVISION = '0009_workspace_tenancy'
|
||||
_RESOURCE_SCOPE_ALEMBIC_REVISION = '0010_scope_resources'
|
||||
_OSS_WORKSPACE_METADATA_KEY = 'oss_workspace_uuid'
|
||||
|
||||
|
||||
class PersistenceMode(enum.StrEnum):
|
||||
"""Trusted persistence startup mode selected by the process entrypoint."""
|
||||
|
||||
OSS_COMPAT = 'oss_compat'
|
||||
CLOUD_RUNTIME = 'cloud_runtime'
|
||||
RELEASE_MIGRATION = 'release_migration'
|
||||
|
||||
|
||||
class PersistenceManager:
|
||||
@@ -76,9 +87,12 @@ class PersistenceManager:
|
||||
|
||||
meta: sqlalchemy.MetaData
|
||||
|
||||
def __init__(self, ap: app.Application):
|
||||
def __init__(self, ap: app.Application, *, mode: PersistenceMode = PersistenceMode.OSS_COMPAT):
|
||||
if not isinstance(mode, PersistenceMode):
|
||||
raise TypeError('PersistenceManager mode must be a trusted PersistenceMode value')
|
||||
self.ap = ap
|
||||
self.meta = base.Base.metadata
|
||||
self.mode = mode
|
||||
|
||||
async def initialize(self):
|
||||
database_type = self.ap.instance_config.data.get('database', {}).get('use', 'sqlite')
|
||||
@@ -89,7 +103,27 @@ class PersistenceManager:
|
||||
await self.db.initialize()
|
||||
break
|
||||
|
||||
engine = self.get_db_engine()
|
||||
if self.mode in {PersistenceMode.CLOUD_RUNTIME, PersistenceMode.RELEASE_MIGRATION}:
|
||||
if engine.dialect.name != 'postgresql':
|
||||
raise RuntimeError(f'{self.mode.value} persistence mode requires PostgreSQL')
|
||||
|
||||
if self.mode == PersistenceMode.CLOUD_RUNTIME:
|
||||
await self._validate_cloud_runtime()
|
||||
return
|
||||
|
||||
self._enable_sqlite_foreign_keys()
|
||||
await self._initialize_managed_schema()
|
||||
|
||||
if self.mode == PersistenceMode.OSS_COMPAT:
|
||||
await self.write_space_model_providers()
|
||||
|
||||
async def _initialize_managed_schema(self) -> None:
|
||||
"""Create or migrate schema only in OSS and release processes."""
|
||||
from . import alembic_runner
|
||||
|
||||
engine = self.get_db_engine()
|
||||
release_bootstrap = self.mode == PersistenceMode.RELEASE_MIGRATION and await self._is_empty_schema()
|
||||
|
||||
await self.create_tables()
|
||||
|
||||
@@ -125,16 +159,36 @@ class PersistenceManager:
|
||||
|
||||
self.ap.logger.info(f'Successfully upgraded database to version {last_migration_number}.')
|
||||
|
||||
# Run Alembic migrations (new migration system)
|
||||
await self._run_alembic_migrations()
|
||||
if engine.dialect.name == 'postgresql':
|
||||
current_revision = await alembic_runner.get_alembic_current(engine)
|
||||
head_revision = alembic_runner.get_alembic_head()
|
||||
|
||||
# A legacy database may not contain tenant tables introduced by a
|
||||
# newer release. They were deliberately deferred before 0009 because
|
||||
# their Workspace/account FK targets did not exist yet; create them
|
||||
# now that the tenancy contract is in place.
|
||||
await self.create_tables()
|
||||
if release_bootstrap:
|
||||
# Base.metadata represents the complete 0010 schema. An empty
|
||||
# Cloud database has no legacy data to transform and must not
|
||||
# run 0009's OSS singleton Workspace bootstrap.
|
||||
if current_revision is not None:
|
||||
raise RuntimeError('Empty PostgreSQL release bootstrap unexpectedly has an Alembic revision')
|
||||
await alembic_runner.run_alembic_stamp(engine, _RESOURCE_SCOPE_ALEMBIC_REVISION)
|
||||
elif current_revision != head_revision:
|
||||
# A legacy database may not contain tenant tables introduced
|
||||
# by a newer release. Upgrade the account/resource contract,
|
||||
# then create deferred tables before the RLS migration runs.
|
||||
await self._run_alembic_migrations(_RESOURCE_SCOPE_ALEMBIC_REVISION)
|
||||
await self.create_tables()
|
||||
|
||||
await self.write_space_model_providers()
|
||||
await self._run_alembic_migrations()
|
||||
await self._validate_postgres_tenant_schema(validate_runtime_role=False)
|
||||
|
||||
if self.mode == PersistenceMode.OSS_COMPAT:
|
||||
await self._install_oss_postgres_tenant_scope()
|
||||
else:
|
||||
await self._run_alembic_migrations()
|
||||
|
||||
# SQLite keeps the historical post-migration create_all pass. New
|
||||
# tenant tables are deferred until the Workspace/account schema is
|
||||
# compatible with their foreign keys.
|
||||
await self.create_tables()
|
||||
|
||||
async def create_tables(self):
|
||||
async with self.get_db_engine().connect() as conn:
|
||||
@@ -173,6 +227,37 @@ class PersistenceManager:
|
||||
|
||||
await self._ensure_instance_uuid_metadata()
|
||||
|
||||
async def _is_empty_schema(self) -> bool:
|
||||
async with self.get_db_engine().connect() as conn:
|
||||
table_names = await conn.run_sync(lambda sync_conn: set(sqlalchemy.inspect(sync_conn).get_table_names()))
|
||||
return not table_names
|
||||
|
||||
async def _install_oss_postgres_tenant_scope(self) -> None:
|
||||
"""Default every OSS PostgreSQL transaction to its singleton Workspace."""
|
||||
if getattr(self, '_oss_tenant_scope_listener_installed', False):
|
||||
return
|
||||
|
||||
async with self.get_db_engine().connect() as conn:
|
||||
workspace_uuid = await conn.scalar(
|
||||
sqlalchemy.select(metadata.Metadata.value).where(
|
||||
metadata.Metadata.key == _OSS_WORKSPACE_METADATA_KEY,
|
||||
)
|
||||
)
|
||||
if not isinstance(workspace_uuid, str) or not workspace_uuid.strip():
|
||||
raise RuntimeError(
|
||||
'PostgreSQL OSS mode requires exactly one local Workspace recorded before tenant RLS is enabled'
|
||||
)
|
||||
workspace_uuid = workspace_uuid.strip()
|
||||
|
||||
def set_oss_tenant_scope(conn: sqlalchemy.Connection) -> None:
|
||||
conn.execute(
|
||||
sqlalchemy.text(f"SELECT set_config('{TENANT_SETTING}', :workspace_uuid, true)"),
|
||||
{'workspace_uuid': workspace_uuid},
|
||||
)
|
||||
|
||||
sqlalchemy.event.listen(self.get_db_engine().sync_engine, 'begin', set_oss_tenant_scope)
|
||||
self._oss_tenant_scope_listener_installed = True
|
||||
|
||||
def _enable_sqlite_foreign_keys(self) -> None:
|
||||
"""Enable SQLite FK enforcement for every pooled runtime connection."""
|
||||
engine = self.get_db_engine()
|
||||
@@ -215,6 +300,122 @@ class PersistenceManager:
|
||||
f'{runtime_instance_uuid!r} != {persisted_instance_uuid!r}'
|
||||
)
|
||||
|
||||
async def _validate_cloud_runtime(self) -> None:
|
||||
"""Validate a release-prepared schema without performing any DDL."""
|
||||
from . import alembic_runner
|
||||
|
||||
engine = self.get_db_engine()
|
||||
current_revision = await alembic_runner.get_alembic_current(engine)
|
||||
head_revision = alembic_runner.get_alembic_head()
|
||||
if current_revision != head_revision:
|
||||
raise RuntimeError(
|
||||
f'Cloud runtime database schema is not at the release head: {current_revision!r} != {head_revision!r}'
|
||||
)
|
||||
|
||||
runtime_instance_uuid = constants.instance_id.strip()
|
||||
if not runtime_instance_uuid:
|
||||
raise RuntimeError('LangBot instance UUID is empty before Cloud persistence validation')
|
||||
async with engine.connect() as conn:
|
||||
persisted_instance_uuid = await conn.scalar(
|
||||
sqlalchemy.select(metadata.Metadata.value).where(metadata.Metadata.key == 'instance_uuid')
|
||||
)
|
||||
if persisted_instance_uuid is None:
|
||||
raise RuntimeError("Cloud runtime database is missing metadata['instance_uuid']")
|
||||
if persisted_instance_uuid != runtime_instance_uuid:
|
||||
raise RuntimeError(
|
||||
'LangBot instance UUID does not match the value bound to this database: '
|
||||
f'{runtime_instance_uuid!r} != {persisted_instance_uuid!r}'
|
||||
)
|
||||
|
||||
await self._validate_postgres_tenant_schema(validate_runtime_role=True)
|
||||
|
||||
async def _validate_postgres_tenant_schema(self, *, validate_runtime_role: bool) -> None:
|
||||
"""Fail closed when PostgreSQL cannot enforce the tenant contract."""
|
||||
engine = self.get_db_engine()
|
||||
if engine.dialect.name != 'postgresql':
|
||||
raise RuntimeError('PostgreSQL tenant schema validation requires PostgreSQL')
|
||||
|
||||
table_query = sqlalchemy.text(
|
||||
"""
|
||||
SELECT
|
||||
c.relname AS table_name,
|
||||
c.relrowsecurity AS rls_enabled,
|
||||
c.relforcerowsecurity AS rls_forced,
|
||||
pg_get_userbyid(c.relowner) = current_user AS owned_by_runtime,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_policy p
|
||||
WHERE p.polrelid = c.oid
|
||||
AND p.polname = :policy_name
|
||||
AND p.polcmd = '*'
|
||||
AND p.polpermissive
|
||||
AND p.polqual IS NOT NULL
|
||||
AND p.polwithcheck IS NOT NULL
|
||||
) AS policy_valid
|
||||
FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = current_schema()
|
||||
AND c.relkind IN ('r', 'p')
|
||||
AND c.relname IN :table_names
|
||||
"""
|
||||
).bindparams(sqlalchemy.bindparam('table_names', expanding=True))
|
||||
|
||||
async with engine.connect() as conn:
|
||||
if validate_runtime_role:
|
||||
role = (
|
||||
(
|
||||
await conn.execute(
|
||||
sqlalchemy.text(
|
||||
"""
|
||||
SELECT rolsuper, rolbypassrls
|
||||
FROM pg_roles
|
||||
WHERE rolname = current_user
|
||||
"""
|
||||
)
|
||||
)
|
||||
)
|
||||
.mappings()
|
||||
.one_or_none()
|
||||
)
|
||||
if role is None:
|
||||
raise RuntimeError('Cloud runtime PostgreSQL role could not be inspected')
|
||||
if role['rolsuper']:
|
||||
raise RuntimeError('Cloud runtime PostgreSQL role must not be a superuser')
|
||||
if role['rolbypassrls']:
|
||||
raise RuntimeError('Cloud runtime PostgreSQL role must not have BYPASSRLS')
|
||||
|
||||
rows = (
|
||||
(
|
||||
await conn.execute(
|
||||
table_query,
|
||||
{
|
||||
'policy_name': TENANT_POLICY_NAME,
|
||||
'table_names': tuple(TENANT_TABLE_COLUMNS),
|
||||
},
|
||||
)
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
|
||||
by_table = {row['table_name']: row for row in rows}
|
||||
missing_tables = set(TENANT_TABLE_COLUMNS) - set(by_table)
|
||||
if missing_tables:
|
||||
raise RuntimeError(f'PostgreSQL tenant tables are missing: {sorted(missing_tables)!r}')
|
||||
|
||||
invalid_rls = sorted(
|
||||
table_name
|
||||
for table_name, row in by_table.items()
|
||||
if not row['rls_enabled'] or not row['rls_forced'] or not row['policy_valid']
|
||||
)
|
||||
if invalid_rls:
|
||||
raise RuntimeError(f'PostgreSQL tenant RLS contract is incomplete for tables: {invalid_rls!r}')
|
||||
|
||||
if validate_runtime_role:
|
||||
owned_tables = sorted(table_name for table_name, row in by_table.items() if row['owned_by_runtime'])
|
||||
if owned_tables:
|
||||
raise RuntimeError(f'Cloud runtime PostgreSQL role must not own tenant tables: {owned_tables!r}')
|
||||
|
||||
async def write_space_model_providers(self):
|
||||
if constants.edition != 'community':
|
||||
# SaaS Workspace/provider linkage is explicit control-plane state;
|
||||
@@ -277,7 +478,7 @@ class PersistenceManager:
|
||||
|
||||
# =================================
|
||||
|
||||
async def _run_alembic_migrations(self):
|
||||
async def _run_alembic_migrations(self, target_revision: str = 'head'):
|
||||
"""Run Alembic-based migrations after legacy migrations complete."""
|
||||
from . import alembic_runner
|
||||
|
||||
@@ -310,8 +511,8 @@ class PersistenceManager:
|
||||
# PostgreSQL has transactional DDL. SQLite has already crossed the
|
||||
# two destructive tenancy boundaries under verified backups; this
|
||||
# final call is a no-op today and applies future migrations.
|
||||
await alembic_runner.run_alembic_upgrade(engine, 'head')
|
||||
self.ap.logger.info('Alembic migrations completed.')
|
||||
await alembic_runner.run_alembic_upgrade(engine, target_revision)
|
||||
self.ap.logger.info(f'Alembic migrations completed at {target_revision}.')
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'Alembic migration failed: {e}', exc_info=True)
|
||||
raise
|
||||
@@ -359,6 +560,9 @@ class PersistenceManager:
|
||||
await conn.commit()
|
||||
return result
|
||||
|
||||
def tenant_uow(self, workspace_uuid: str) -> TenantUnitOfWork:
|
||||
return TenantUnitOfWork(self.get_db_engine(), workspace_uuid)
|
||||
|
||||
def get_db_engine(self) -> sqlalchemy_asyncio.AsyncEngine:
|
||||
return self.db.get_engine()
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import types
|
||||
import typing
|
||||
|
||||
import sqlalchemy
|
||||
import sqlalchemy.ext.asyncio as sqlalchemy_asyncio
|
||||
|
||||
|
||||
TENANT_SETTING = 'langbot.workspace_uuid'
|
||||
TENANT_POLICY_NAME = 'langbot_workspace_isolation'
|
||||
|
||||
# Keep this contract explicit. A new tenant-owned table must be added to both
|
||||
# this runtime list and the corresponding Alembic migration before release.
|
||||
TENANT_TABLE_COLUMNS: dict[str, str] = {
|
||||
'workspaces': 'uuid',
|
||||
'workspace_memberships': 'workspace_uuid',
|
||||
'workspace_invitations': 'workspace_uuid',
|
||||
'workspace_execution_states': 'workspace_uuid',
|
||||
'workspace_metadata': 'workspace_uuid',
|
||||
'api_keys': 'workspace_uuid',
|
||||
'bots': 'workspace_uuid',
|
||||
'bot_admins': 'workspace_uuid',
|
||||
'binary_storages': 'workspace_uuid',
|
||||
'mcp_servers': 'workspace_uuid',
|
||||
'model_providers': 'workspace_uuid',
|
||||
'llm_models': 'workspace_uuid',
|
||||
'embedding_models': 'workspace_uuid',
|
||||
'rerank_models': 'workspace_uuid',
|
||||
'legacy_pipelines': 'workspace_uuid',
|
||||
'pipeline_run_records': 'workspace_uuid',
|
||||
'plugin_settings': 'workspace_uuid',
|
||||
'knowledge_bases': 'workspace_uuid',
|
||||
'knowledge_base_files': 'workspace_uuid',
|
||||
'knowledge_base_chunks': 'workspace_uuid',
|
||||
'webhooks': 'workspace_uuid',
|
||||
'monitoring_messages': 'workspace_uuid',
|
||||
'monitoring_llm_calls': 'workspace_uuid',
|
||||
'monitoring_tool_calls': 'workspace_uuid',
|
||||
'monitoring_sessions': 'workspace_uuid',
|
||||
'monitoring_errors': 'workspace_uuid',
|
||||
'monitoring_embedding_calls': 'workspace_uuid',
|
||||
'monitoring_feedback': 'workspace_uuid',
|
||||
}
|
||||
|
||||
|
||||
class TenantUnitOfWork:
|
||||
"""Bind one Workspace scope to one database transaction.
|
||||
|
||||
PostgreSQL receives a transaction-local setting used by RLS policies.
|
||||
SQLite keeps the same transaction boundary without a database setting so
|
||||
OSS callers can adopt this API without changing database engines.
|
||||
"""
|
||||
|
||||
def __init__(self, engine: sqlalchemy_asyncio.AsyncEngine, workspace_uuid: str) -> None:
|
||||
workspace_uuid = workspace_uuid.strip()
|
||||
if not workspace_uuid:
|
||||
raise ValueError('TenantUnitOfWork requires a non-empty workspace_uuid')
|
||||
|
||||
self._engine = engine
|
||||
self.workspace_uuid = workspace_uuid
|
||||
self._session: sqlalchemy_asyncio.AsyncSession | None = None
|
||||
self._used = False
|
||||
|
||||
@property
|
||||
def session(self) -> sqlalchemy_asyncio.AsyncSession:
|
||||
if self._session is None:
|
||||
raise RuntimeError('TenantUnitOfWork is not active')
|
||||
return self._session
|
||||
|
||||
async def __aenter__(self) -> TenantUnitOfWork:
|
||||
if self._used:
|
||||
raise RuntimeError('TenantUnitOfWork instances cannot be reused')
|
||||
self._used = True
|
||||
|
||||
session = sqlalchemy_asyncio.AsyncSession(self._engine, expire_on_commit=False)
|
||||
self._session = session
|
||||
try:
|
||||
await session.begin()
|
||||
if self._engine.dialect.name == 'postgresql':
|
||||
await session.execute(
|
||||
sqlalchemy.text(f"SELECT set_config('{TENANT_SETTING}', :workspace_uuid, true)"),
|
||||
{'workspace_uuid': self.workspace_uuid},
|
||||
)
|
||||
except BaseException:
|
||||
await session.rollback()
|
||||
await session.close()
|
||||
self._session = None
|
||||
raise
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_value: BaseException | None,
|
||||
traceback: types.TracebackType | None,
|
||||
) -> bool:
|
||||
session = self.session
|
||||
try:
|
||||
if exc_type is None:
|
||||
await session.commit()
|
||||
else:
|
||||
await session.rollback()
|
||||
finally:
|
||||
await session.close()
|
||||
self._session = None
|
||||
return False
|
||||
|
||||
async def execute(self, *args: typing.Any, **kwargs: typing.Any) -> sqlalchemy.engine.Result[typing.Any]:
|
||||
return await self.session.execute(*args, **kwargs)
|
||||
|
||||
async def flush(self) -> None:
|
||||
await self.session.flush()
|
||||
@@ -30,6 +30,7 @@ import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
from ....entity.persistence import mcp as persistence_mcp
|
||||
from .mcp_stdio import BoxStdioSessionRuntime, MCPServerBoxConfig, MCPSessionErrorPhase, _ColdStartRetry # noqa: F401
|
||||
from .mcp_policy import require_stdio_mcp_enabled, stdio_mcp_enabled
|
||||
|
||||
# Synthesized LLM tools for MCP resources (not from server tools/list).
|
||||
# Dispatched in MCPLoader.invoke_tool; placeholder func on LLMTool is never used.
|
||||
@@ -350,6 +351,11 @@ class RuntimeMCPSession:
|
||||
)
|
||||
|
||||
async def _init_stdio_python_server(self):
|
||||
# Final transport gate: this must run before both the Box branch and
|
||||
# the backwards-compatible host-stdio branch. Service/UI checks are
|
||||
# usability guards; this is the execution security boundary.
|
||||
require_stdio_mcp_enabled(self.ap, self.server_config)
|
||||
|
||||
if self._uses_box_stdio():
|
||||
await self._box_stdio_runtime.initialize()
|
||||
return
|
||||
@@ -1436,6 +1442,12 @@ class MCPLoader(loader.ToolLoader):
|
||||
|
||||
for server in servers:
|
||||
config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server)
|
||||
if config.get('mode') == 'stdio' and not stdio_mcp_enabled(self.ap):
|
||||
self.ap.logger.info(
|
||||
f'Skipping disabled stdio MCP server {server.uuid}; '
|
||||
'the persisted configuration is retained but no process is launched'
|
||||
)
|
||||
continue
|
||||
try:
|
||||
binding = await self.ap.workspace_service.get_execution_binding(server.workspace_uuid)
|
||||
execution_context = ExecutionContext(
|
||||
@@ -1511,6 +1523,7 @@ class MCPLoader(loader.ToolLoader):
|
||||
"""
|
||||
execution_context = await self._assert_execution_active(context)
|
||||
server_config = dict(server_config)
|
||||
require_stdio_mcp_enabled(self.ap, server_config)
|
||||
configured_workspace = str(server_config.get('workspace_uuid') or '').strip()
|
||||
if configured_workspace and configured_workspace != execution_context.workspace_uuid:
|
||||
raise ValueError('MCP server configuration belongs to another Workspace')
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
MCP_STDIO_DISABLED_CODE = 'mcp_stdio_disabled'
|
||||
MCP_STDIO_DISABLED_MESSAGE = 'Stdio MCP is disabled by instance policy'
|
||||
|
||||
|
||||
class MCPStdioDisabledError(RuntimeError):
|
||||
"""Raised when an instance-level policy refuses stdio MCP execution."""
|
||||
|
||||
code = MCP_STDIO_DISABLED_CODE
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(MCP_STDIO_DISABLED_MESSAGE)
|
||||
|
||||
|
||||
def stdio_mcp_enabled(ap: Any) -> bool:
|
||||
"""Return the independent instance-level stdio MCP feature gate.
|
||||
|
||||
The open-source default remains enabled for backwards compatibility. A
|
||||
deployment can disable it with ``mcp.stdio.enabled: false`` (or
|
||||
``MCP__STDIO__ENABLED=false``). Invalid values fail closed instead of
|
||||
accidentally enabling local process execution.
|
||||
"""
|
||||
|
||||
instance_config = getattr(ap, 'instance_config', None)
|
||||
config = getattr(instance_config, 'data', None)
|
||||
if not isinstance(config, dict):
|
||||
return False
|
||||
mcp_config = config.get('mcp', {})
|
||||
if not isinstance(mcp_config, dict):
|
||||
return False
|
||||
stdio_config = mcp_config.get('stdio', {})
|
||||
if not isinstance(stdio_config, dict):
|
||||
return False
|
||||
enabled = stdio_config.get('enabled', True)
|
||||
return enabled if isinstance(enabled, bool) else False
|
||||
|
||||
|
||||
def is_stdio_server(server_config: dict[str, Any] | None) -> bool:
|
||||
return isinstance(server_config, dict) and str(server_config.get('mode') or '').strip().lower() == 'stdio'
|
||||
|
||||
|
||||
def require_stdio_mcp_enabled(ap: Any, server_config: dict[str, Any] | None) -> None:
|
||||
"""Fail closed for a stdio server before choosing Box or host transport."""
|
||||
|
||||
if is_stdio_server(server_config) and not stdio_mcp_enabled(ap):
|
||||
raise MCPStdioDisabledError()
|
||||
@@ -125,9 +125,22 @@ plugin:
|
||||
runtime_ws_url: 'ws://langbot_plugin_runtime:5400/control/ws'
|
||||
enable_marketplace: true
|
||||
display_plugin_debug_url: 'ws://localhost:5401/plugin/debug/ws'
|
||||
worker:
|
||||
# Instance-wide maximum for every plugin installation. Plugin
|
||||
# manifests cannot raise or override these limits.
|
||||
max_cpus: 1.0
|
||||
max_memory_mb: 512
|
||||
max_pids: 128
|
||||
max_open_files: 256
|
||||
max_file_size_mb: 512
|
||||
binary_storage:
|
||||
# Max bytes for a single plugin binary storage value
|
||||
max_value_bytes: 10485760
|
||||
mcp:
|
||||
stdio:
|
||||
# Independent gate for local stdio MCP transports. Cloud v2 sets
|
||||
# MCP__STDIO__ENABLED=false even when Box Runtime is available.
|
||||
enabled: true
|
||||
monitoring:
|
||||
auto_cleanup:
|
||||
# Enable automatic cleanup of expired monitoring records
|
||||
|
||||
@@ -15,15 +15,20 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
||||
from sqlalchemy import text
|
||||
|
||||
from langbot.pkg.entity.persistence.base import Base
|
||||
from langbot.pkg.entity.persistence.user import User
|
||||
from langbot.pkg.persistence.mgr import PersistenceManager
|
||||
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
|
||||
from langbot.pkg.persistence.tenant_uow import TENANT_POLICY_NAME, TENANT_TABLE_COLUMNS, TenantUnitOfWork
|
||||
from langbot.pkg.persistence.alembic_runner import (
|
||||
run_alembic_upgrade,
|
||||
run_alembic_stamp,
|
||||
@@ -49,6 +54,44 @@ def _get_script_head() -> str:
|
||||
return ScriptDirectory.from_config(cfg).get_current_head()
|
||||
|
||||
|
||||
def _application_for_postgres_url(postgres_url: str, logger_name: str) -> SimpleNamespace:
|
||||
url = sa.engine.make_url(postgres_url)
|
||||
return SimpleNamespace(
|
||||
instance_config=SimpleNamespace(
|
||||
data={
|
||||
'database': {
|
||||
'use': 'postgresql',
|
||||
'postgresql': {
|
||||
'host': url.host,
|
||||
'port': url.port,
|
||||
'user': url.username,
|
||||
'password': url.password,
|
||||
'database': url.database,
|
||||
},
|
||||
}
|
||||
}
|
||||
),
|
||||
logger=logging.getLogger(logger_name),
|
||||
)
|
||||
|
||||
|
||||
async def _dispose_manager(manager: PersistenceManager | None) -> None:
|
||||
if manager is not None and getattr(manager, 'db', None) is not None:
|
||||
await manager.get_db_engine().dispose()
|
||||
|
||||
|
||||
def _restore_postgres_manager_registry(monkeypatch) -> None:
|
||||
"""Undo the registry isolation used by test_database_decorator.py."""
|
||||
from langbot.pkg.persistence import mgr as persistence_mgr_module
|
||||
from langbot.pkg.persistence.databases.postgresql import PostgreSQLDatabaseManager
|
||||
|
||||
monkeypatch.setattr(
|
||||
persistence_mgr_module.database,
|
||||
'preregistered_managers',
|
||||
[PostgreSQLDatabaseManager],
|
||||
)
|
||||
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.slow]
|
||||
|
||||
|
||||
@@ -309,7 +352,7 @@ class TestPostgreSQLWorkspaceMigration:
|
||||
)
|
||||
assert 'workspaces' not in tables_before_migration
|
||||
|
||||
await manager._run_alembic_migrations()
|
||||
await manager._initialize_managed_schema()
|
||||
|
||||
async with postgres_engine.connect() as conn:
|
||||
account = (await conn.execute(text('SELECT uuid, status, source FROM users'))).mappings().one()
|
||||
@@ -425,3 +468,278 @@ class TestPostgreSQLResourceTenancyMigration:
|
||||
),
|
||||
{'workspace_uuid': second_workspace_uuid},
|
||||
)
|
||||
|
||||
|
||||
class TestPostgreSQLTenantRuntime:
|
||||
"""Release bootstrap, RLS enforcement, and runtime-role safety."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oss_postgres_defaults_to_the_singleton_workspace(
|
||||
self,
|
||||
postgres_url,
|
||||
clean_tables,
|
||||
clean_alembic_version,
|
||||
monkeypatch,
|
||||
):
|
||||
instance_uuid = 'oss-postgres-rls-compatibility-test'
|
||||
_restore_postgres_manager_registry(monkeypatch)
|
||||
monkeypatch.setattr(constants, 'instance_id', instance_uuid)
|
||||
manager = PersistenceManager(
|
||||
_application_for_postgres_url(postgres_url, 'postgres-oss-rls-compatibility-test'),
|
||||
mode=PersistenceMode.OSS_COMPAT,
|
||||
)
|
||||
try:
|
||||
await manager.initialize()
|
||||
async with manager.get_db_engine().connect() as conn:
|
||||
workspace_uuid = await conn.scalar(text("SELECT uuid FROM workspaces WHERE source = 'local'"))
|
||||
tenant_setting = await conn.scalar(text("SELECT current_setting('langbot.workspace_uuid', true)"))
|
||||
visible_workspaces = await conn.scalar(text('SELECT COUNT(*) FROM workspaces'))
|
||||
assert workspace_uuid == tenant_setting
|
||||
assert visible_workspaces == 1
|
||||
finally:
|
||||
await _dispose_manager(manager)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_release_bootstrap_and_runtime_isolation(
|
||||
self,
|
||||
postgres_url,
|
||||
postgres_engine,
|
||||
clean_tables,
|
||||
clean_alembic_version,
|
||||
monkeypatch,
|
||||
):
|
||||
instance_uuid = 'cloud-runtime-persistence-test'
|
||||
workspace_a = '10000000-0000-0000-0000-000000000001'
|
||||
workspace_b = '20000000-0000-0000-0000-000000000002'
|
||||
role_suffix = uuid.uuid4().hex[:12]
|
||||
runtime_role = f'lb_runtime_{role_suffix}'
|
||||
bypass_role = f'lb_bypass_{role_suffix}'
|
||||
owner_role = f'lb_owner_{role_suffix}'
|
||||
role_password = f'Lb{uuid.uuid4().hex}'
|
||||
created_roles: list[str] = []
|
||||
managers: list[PersistenceManager] = []
|
||||
runtime_engine: AsyncEngine | None = None
|
||||
owner_changed = False
|
||||
|
||||
_restore_postgres_manager_registry(monkeypatch)
|
||||
monkeypatch.setattr(constants, 'instance_id', instance_uuid)
|
||||
release_manager = PersistenceManager(
|
||||
_application_for_postgres_url(postgres_url, 'postgres-release-bootstrap-test'),
|
||||
mode=PersistenceMode.RELEASE_MIGRATION,
|
||||
)
|
||||
managers.append(release_manager)
|
||||
|
||||
async with postgres_engine.connect() as conn:
|
||||
admin_user = await conn.scalar(text('SELECT current_user'))
|
||||
quote = postgres_engine.dialect.identifier_preparer.quote
|
||||
|
||||
def role_url(role_name: str) -> str:
|
||||
return (
|
||||
sa.engine.make_url(postgres_url)
|
||||
.set(
|
||||
username=role_name,
|
||||
password=role_password,
|
||||
)
|
||||
.render_as_string(hide_password=False)
|
||||
)
|
||||
|
||||
async def create_role(role_name: str, *, bypass_rls: bool = False) -> None:
|
||||
bypass_clause = ' BYPASSRLS' if bypass_rls else ''
|
||||
async with postgres_engine.connect() as conn:
|
||||
await conn.execute(
|
||||
text(f"CREATE ROLE {quote(role_name)} LOGIN{bypass_clause} PASSWORD '{role_password}'")
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
f'GRANT CONNECT ON DATABASE {quote(sa.engine.make_url(postgres_url).database)} '
|
||||
f'TO {quote(role_name)}'
|
||||
)
|
||||
)
|
||||
await conn.execute(text(f'GRANT USAGE ON SCHEMA public TO {quote(role_name)}'))
|
||||
await conn.execute(
|
||||
text(f'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO {quote(role_name)}')
|
||||
)
|
||||
await conn.execute(text(f'GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO {quote(role_name)}'))
|
||||
created_roles.append(role_name)
|
||||
|
||||
try:
|
||||
await release_manager.initialize()
|
||||
release_engine = release_manager.get_db_engine()
|
||||
|
||||
assert await get_alembic_current(release_engine) == _get_script_head()
|
||||
async with release_engine.connect() as conn:
|
||||
assert await conn.scalar(text('SELECT COUNT(*) FROM workspaces')) == 0
|
||||
rls_rows = (
|
||||
(
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT c.relname, c.relrowsecurity, c.relforcerowsecurity,
|
||||
EXISTS (
|
||||
SELECT 1 FROM pg_policy p
|
||||
WHERE p.polrelid = c.oid AND p.polname = :policy_name
|
||||
) AS has_policy
|
||||
FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = current_schema() AND c.relname IN :table_names
|
||||
"""
|
||||
).bindparams(sa.bindparam('table_names', expanding=True)),
|
||||
{
|
||||
'policy_name': TENANT_POLICY_NAME,
|
||||
'table_names': tuple(TENANT_TABLE_COLUMNS),
|
||||
},
|
||||
)
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
assert {row['relname'] for row in rls_rows} == set(TENANT_TABLE_COLUMNS)
|
||||
assert all(row['relrowsecurity'] and row['relforcerowsecurity'] and row['has_policy'] for row in rls_rows)
|
||||
|
||||
for workspace_uuid, slug in ((workspace_a, 'workspace-a'), (workspace_b, 'workspace-b')):
|
||||
async with TenantUnitOfWork(release_engine, workspace_uuid) as uow:
|
||||
await uow.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO workspaces
|
||||
(uuid, instance_uuid, name, slug, type, status, source, projection_revision)
|
||||
VALUES
|
||||
(:uuid, :instance_uuid, :name, :slug, 'team', 'active', 'cloud_projection', 0)
|
||||
"""
|
||||
),
|
||||
{
|
||||
'uuid': workspace_uuid,
|
||||
'instance_uuid': instance_uuid,
|
||||
'name': slug,
|
||||
'slug': slug,
|
||||
},
|
||||
)
|
||||
await uow.execute(
|
||||
text(
|
||||
'INSERT INTO workspace_metadata (workspace_uuid, key, value) '
|
||||
"VALUES (:workspace_uuid, 'seed', :value)"
|
||||
),
|
||||
{'workspace_uuid': workspace_uuid, 'value': slug},
|
||||
)
|
||||
|
||||
await create_role(runtime_role)
|
||||
runtime_engine = create_async_engine(role_url(runtime_role), pool_size=1, max_overflow=0)
|
||||
|
||||
async with runtime_engine.connect() as conn:
|
||||
assert (await conn.execute(text('SELECT uuid FROM workspaces'))).all() == []
|
||||
assert (await conn.execute(text('SELECT * FROM workspace_metadata'))).all() == []
|
||||
|
||||
with pytest.raises(sa.exc.DBAPIError):
|
||||
async with runtime_engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text(
|
||||
'INSERT INTO workspace_metadata (workspace_uuid, key, value) '
|
||||
"VALUES (:workspace_uuid, 'no-scope', 'rejected')"
|
||||
),
|
||||
{'workspace_uuid': workspace_a},
|
||||
)
|
||||
|
||||
async with TenantUnitOfWork(runtime_engine, workspace_a) as uow:
|
||||
assert (await uow.execute(text('SELECT uuid FROM workspaces'))).scalars().all() == [workspace_a]
|
||||
assert (
|
||||
await uow.execute(
|
||||
text('SELECT uuid FROM workspaces WHERE uuid = :workspace_uuid'),
|
||||
{'workspace_uuid': workspace_b},
|
||||
)
|
||||
).all() == []
|
||||
|
||||
with pytest.raises(sa.exc.DBAPIError):
|
||||
async with TenantUnitOfWork(runtime_engine, workspace_a) as uow:
|
||||
await uow.execute(
|
||||
text(
|
||||
'INSERT INTO workspace_metadata (workspace_uuid, key, value) '
|
||||
"VALUES (:workspace_uuid, 'cross-scope', 'rejected')"
|
||||
),
|
||||
{'workspace_uuid': workspace_b},
|
||||
)
|
||||
|
||||
async with TenantUnitOfWork(runtime_engine, workspace_a) as uow:
|
||||
assert (await uow.execute(text('SELECT value FROM workspace_metadata'))).scalars().all() == [
|
||||
'workspace-a'
|
||||
]
|
||||
async with TenantUnitOfWork(runtime_engine, workspace_b) as uow:
|
||||
assert (await uow.execute(text('SELECT value FROM workspace_metadata'))).scalars().all() == [
|
||||
'workspace-b'
|
||||
]
|
||||
async with runtime_engine.connect() as conn:
|
||||
setting = await conn.scalar(text("SELECT current_setting('langbot.workspace_uuid', true)"))
|
||||
assert setting in (None, '')
|
||||
assert await conn.scalar(text('SELECT COUNT(*) FROM workspace_metadata')) == 0
|
||||
|
||||
with pytest.raises(RuntimeError, match='force rollback'):
|
||||
async with TenantUnitOfWork(runtime_engine, workspace_a) as uow:
|
||||
await uow.execute(
|
||||
text(
|
||||
'INSERT INTO workspace_metadata (workspace_uuid, key, value) '
|
||||
"VALUES (:workspace_uuid, 'rolled-back', 'no')"
|
||||
),
|
||||
{'workspace_uuid': workspace_a},
|
||||
)
|
||||
raise RuntimeError('force rollback')
|
||||
async with TenantUnitOfWork(runtime_engine, workspace_a) as uow:
|
||||
assert (
|
||||
await uow.session.scalar(text("SELECT COUNT(*) FROM workspace_metadata WHERE key = 'rolled-back'"))
|
||||
== 0
|
||||
)
|
||||
async with runtime_engine.connect() as conn:
|
||||
setting = await conn.scalar(text("SELECT current_setting('langbot.workspace_uuid', true)"))
|
||||
assert setting in (None, '')
|
||||
|
||||
cloud_manager = PersistenceManager(
|
||||
_application_for_postgres_url(role_url(runtime_role), 'postgres-cloud-runtime-test'),
|
||||
mode=PersistenceMode.CLOUD_RUNTIME,
|
||||
)
|
||||
cloud_manager.create_tables = AsyncMock(side_effect=AssertionError('Cloud runtime attempted create_all'))
|
||||
cloud_manager._run_alembic_migrations = AsyncMock(
|
||||
side_effect=AssertionError('Cloud runtime attempted an Alembic upgrade')
|
||||
)
|
||||
managers.append(cloud_manager)
|
||||
await cloud_manager.initialize()
|
||||
cloud_manager.create_tables.assert_not_awaited()
|
||||
cloud_manager._run_alembic_migrations.assert_not_awaited()
|
||||
|
||||
superuser_manager = PersistenceManager(
|
||||
_application_for_postgres_url(postgres_url, 'postgres-superuser-runtime-test'),
|
||||
mode=PersistenceMode.CLOUD_RUNTIME,
|
||||
)
|
||||
managers.append(superuser_manager)
|
||||
with pytest.raises(RuntimeError, match='must not be a superuser'):
|
||||
await superuser_manager.initialize()
|
||||
|
||||
await create_role(bypass_role, bypass_rls=True)
|
||||
bypass_manager = PersistenceManager(
|
||||
_application_for_postgres_url(role_url(bypass_role), 'postgres-bypass-runtime-test'),
|
||||
mode=PersistenceMode.CLOUD_RUNTIME,
|
||||
)
|
||||
managers.append(bypass_manager)
|
||||
with pytest.raises(RuntimeError, match='must not have BYPASSRLS'):
|
||||
await bypass_manager.initialize()
|
||||
|
||||
await create_role(owner_role)
|
||||
async with postgres_engine.connect() as conn:
|
||||
await conn.execute(text(f'ALTER TABLE workspace_metadata OWNER TO {quote(owner_role)}'))
|
||||
owner_changed = True
|
||||
owner_manager = PersistenceManager(
|
||||
_application_for_postgres_url(role_url(owner_role), 'postgres-owner-runtime-test'),
|
||||
mode=PersistenceMode.CLOUD_RUNTIME,
|
||||
)
|
||||
managers.append(owner_manager)
|
||||
with pytest.raises(RuntimeError, match='must not own tenant tables'):
|
||||
await owner_manager.initialize()
|
||||
finally:
|
||||
if runtime_engine is not None:
|
||||
await runtime_engine.dispose()
|
||||
for manager in reversed(managers):
|
||||
await _dispose_manager(manager)
|
||||
|
||||
async with postgres_engine.connect() as conn:
|
||||
if owner_changed:
|
||||
await conn.execute(text(f'ALTER TABLE workspace_metadata OWNER TO {quote(admin_user)}'))
|
||||
for role_name in reversed(created_roles):
|
||||
await conn.execute(text(f'DROP OWNED BY {quote(role_name)}'))
|
||||
await conn.execute(text(f'DROP ROLE {quote(role_name)}'))
|
||||
|
||||
@@ -47,7 +47,7 @@ async def test_tenancy_migrations_retain_verified_boundary_backups(tmp_path):
|
||||
|
||||
await _manager(engine)._run_alembic_migrations()
|
||||
|
||||
assert await alembic_runner.get_alembic_current(engine) == '0010_scope_resources'
|
||||
assert await alembic_runner.get_alembic_current(engine) == alembic_runner.get_alembic_head()
|
||||
payloads = _manifest_payloads(tmp_path / 'migration-backups')
|
||||
assert len(payloads) == 2
|
||||
assert {
|
||||
@@ -102,6 +102,6 @@ async def test_failed_tenancy_migration_restores_backup_and_revision(
|
||||
|
||||
monkeypatch.setattr(alembic_runner, 'run_alembic_upgrade', real_upgrade)
|
||||
await _manager(engine)._run_alembic_migrations()
|
||||
assert await alembic_runner.get_alembic_current(engine) == '0010_scope_resources'
|
||||
assert await alembic_runner.get_alembic_current(engine) == alembic_runner.get_alembic_head()
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
@@ -13,6 +13,7 @@ from langbot.pkg.entity.persistence.base import Base
|
||||
from langbot.pkg.entity.persistence.user import User
|
||||
from langbot.pkg.persistence.mgr import PersistenceManager
|
||||
from langbot.pkg.persistence.alembic_runner import (
|
||||
get_alembic_head,
|
||||
get_alembic_current,
|
||||
run_alembic_downgrade,
|
||||
run_alembic_stamp,
|
||||
@@ -126,7 +127,7 @@ async def test_legacy_instance_gets_stable_accounts_and_default_workspace(legacy
|
||||
assert execution_state['state'] == 'active'
|
||||
assert execution_state['write_fenced'] in (False, 0)
|
||||
|
||||
assert await get_alembic_current(legacy_engine) == '0010_scope_resources'
|
||||
assert await get_alembic_current(legacy_engine) == get_alembic_head()
|
||||
|
||||
|
||||
async def test_workspace_upgrade_is_idempotent_and_preserves_identifiers(legacy_engine):
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
import quart
|
||||
|
||||
from langbot.pkg.api.http.context import (
|
||||
ExecutionContext,
|
||||
PrincipalContext,
|
||||
PrincipalType,
|
||||
RequestContext,
|
||||
WorkspaceContext,
|
||||
)
|
||||
from langbot.pkg.api.http.controller.group import RouterGroup
|
||||
from langbot.pkg.cloud.entitlements import EntitlementSnapshot, EntitlementUnavailableError
|
||||
from langbot.pkg.cloud.entitlements import EntitlementResolver
|
||||
|
||||
|
||||
class _Group(RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _router(deployment) -> _Group:
|
||||
provider = getattr(deployment, 'entitlement_provider', None)
|
||||
resolver = EntitlementResolver('instance-a', provider) if provider is not None else None
|
||||
ap = SimpleNamespace(deployment=deployment, entitlement_resolver=resolver)
|
||||
return _Group(ap, quart.Quart(__name__))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_request_resolves_verified_entitlement_revision():
|
||||
snapshot = EntitlementSnapshot(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
entitlement_revision=9,
|
||||
status='active',
|
||||
not_before=1,
|
||||
expires_at=4_000_000_000,
|
||||
features={},
|
||||
limits={},
|
||||
)
|
||||
provider = SimpleNamespace(get_workspace_entitlement=AsyncMock(return_value=snapshot))
|
||||
router = _router(SimpleNamespace(multi_workspace_enabled=True, entitlement_provider=provider))
|
||||
|
||||
revision = await router._resolve_entitlement_revision('instance-a', 'workspace-a')
|
||||
|
||||
assert revision == 9
|
||||
provider.get_workspace_entitlement.assert_awaited_once_with('workspace-a')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_request_fails_closed_without_entitlement_provider():
|
||||
router = _router(SimpleNamespace(multi_workspace_enabled=True, entitlement_provider=None))
|
||||
|
||||
with pytest.raises(EntitlementUnavailableError):
|
||||
await router._resolve_entitlement_revision('instance-a', 'workspace-a')
|
||||
|
||||
|
||||
def test_execution_context_preserves_entitlement_revision():
|
||||
request = RequestContext(
|
||||
instance_uuid='instance-a',
|
||||
placement_generation=1,
|
||||
request_id='request-a',
|
||||
auth_type='user-token',
|
||||
principal=PrincipalContext(PrincipalType.ACCOUNT, account_uuid='account-a'),
|
||||
workspace=WorkspaceContext(
|
||||
workspace_uuid='workspace-a',
|
||||
membership_uuid='membership-a',
|
||||
role='owner',
|
||||
permissions=frozenset(),
|
||||
),
|
||||
entitlement_revision=11,
|
||||
)
|
||||
|
||||
assert ExecutionContext.from_request(request).entitlement_revision == 11
|
||||
@@ -29,6 +29,7 @@ from langbot.pkg.api.http.context import (
|
||||
)
|
||||
from langbot.pkg.api.http.service.mcp import MCPService, redact_mcp_secrets, restore_mcp_secret_placeholders
|
||||
from langbot.pkg.entity.persistence.mcp import MCPServer
|
||||
from langbot.pkg.provider.tools.loaders.mcp_policy import MCPStdioDisabledError
|
||||
from langbot.pkg.workspace.errors import WorkspaceNotFoundError
|
||||
|
||||
|
||||
@@ -351,6 +352,27 @@ class TestMCPServiceGetMCPServers:
|
||||
class TestMCPServiceCreateMCPServer:
|
||||
"""Tests for create_mcp_server method."""
|
||||
|
||||
async def test_create_stdio_rejected_by_independent_instance_gate(self):
|
||||
ap = SimpleNamespace(
|
||||
instance_config=SimpleNamespace(
|
||||
data={
|
||||
'mcp': {'stdio': {'enabled': False}},
|
||||
'system': {'limitation': {'max_extensions': -1}},
|
||||
}
|
||||
),
|
||||
persistence_mgr=SimpleNamespace(execute_async=AsyncMock()),
|
||||
tool_mgr=None,
|
||||
)
|
||||
service = _service(ap)
|
||||
|
||||
with pytest.raises(MCPStdioDisabledError, match='disabled by instance policy'):
|
||||
await service.create_mcp_server(
|
||||
_CONTEXT,
|
||||
{'name': 'local', 'mode': 'stdio', 'enable': True, 'extra_args': {}},
|
||||
)
|
||||
|
||||
ap.persistence_mgr.execute_async.assert_not_awaited()
|
||||
|
||||
async def test_create_mcp_server_max_extensions_reached_raises(self):
|
||||
"""Raises ValueError when max_extensions limit reached."""
|
||||
# Setup
|
||||
@@ -887,6 +909,24 @@ class TestMCPServiceDeleteMCPServer:
|
||||
class TestMCPServiceTestMCPServer:
|
||||
"""Tests for test_mcp_server method."""
|
||||
|
||||
async def test_transient_stdio_test_rejected_by_instance_gate(self):
|
||||
ap = SimpleNamespace(
|
||||
instance_config=SimpleNamespace(data={'mcp': {'stdio': {'enabled': False}}}),
|
||||
tool_mgr=SimpleNamespace(mcp_tool_loader=SimpleNamespace(load_mcp_server=AsyncMock())),
|
||||
task_mgr=SimpleNamespace(create_user_task=Mock()),
|
||||
)
|
||||
service = _service(ap)
|
||||
|
||||
with pytest.raises(MCPStdioDisabledError, match='disabled by instance policy'):
|
||||
await service.test_mcp_server(
|
||||
_CONTEXT,
|
||||
'_',
|
||||
{'name': 'local', 'mode': 'stdio', 'enable': True, 'extra_args': {}},
|
||||
)
|
||||
|
||||
ap.tool_mgr.mcp_tool_loader.load_mcp_server.assert_not_awaited()
|
||||
ap.task_mgr.create_user_task.assert_not_called()
|
||||
|
||||
async def test_test_mcp_server_existing_server(self):
|
||||
"""Tests existing MCP server connection."""
|
||||
# Setup
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.cloud.bootstrap import (
|
||||
CloudBootstrapError,
|
||||
OpenSourceDeployment,
|
||||
VerifiedCloudDeployment,
|
||||
resolve_deployment,
|
||||
)
|
||||
from langbot.pkg.cloud.entitlements import EntitlementSnapshot
|
||||
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
class _Entitlements:
|
||||
async def get_workspace_entitlement(self, workspace_uuid: str) -> EntitlementSnapshot:
|
||||
return EntitlementSnapshot(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid=workspace_uuid,
|
||||
entitlement_revision=1,
|
||||
status='active',
|
||||
not_before=1,
|
||||
expires_at=4_000_000_000,
|
||||
features={'managed_sandbox': True},
|
||||
limits={'managed_sandbox_sessions': 1},
|
||||
)
|
||||
|
||||
|
||||
class _Provider:
|
||||
def bootstrap(self, *, instance_uuid: str, instance_config: dict):
|
||||
del instance_config
|
||||
return VerifiedCloudDeployment(
|
||||
instance_uuid=instance_uuid,
|
||||
manifest_jti='manifest-a',
|
||||
manifest_generation=3,
|
||||
expires_at=4_000_000_000,
|
||||
release='cloud-v2',
|
||||
capabilities=frozenset({'multi_workspace_v2'}),
|
||||
tenant_isolation_version=2,
|
||||
entitlement_provider=_Entitlements(),
|
||||
verification_key_id='root-2026',
|
||||
)
|
||||
|
||||
|
||||
class _EntryPoint:
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
|
||||
def load(self):
|
||||
return self.value
|
||||
|
||||
|
||||
class _EntryPoints(list):
|
||||
def select(self, *, group: str):
|
||||
return self if group == 'langbot.cloud_bootstrap' else []
|
||||
|
||||
|
||||
def _cloud_config() -> dict:
|
||||
return {
|
||||
'database': {'use': 'postgresql'},
|
||||
'vdb': {'use': 'pgvector'},
|
||||
'mcp': {'stdio': {'enabled': False}},
|
||||
# Proves mutable product metadata does not participate in selection.
|
||||
'system': {'edition': 'community'},
|
||||
}
|
||||
|
||||
|
||||
async def test_no_closed_entry_point_selects_oss_singleton_even_if_edition_says_cloud():
|
||||
deployment = await resolve_deployment(
|
||||
instance_uuid='instance-a',
|
||||
instance_config={'system': {'edition': 'cloud'}},
|
||||
entry_points=lambda: _EntryPoints(),
|
||||
)
|
||||
|
||||
assert isinstance(deployment, OpenSourceDeployment)
|
||||
assert deployment.multi_workspace_enabled is False
|
||||
|
||||
|
||||
async def test_verified_closed_entry_point_activates_cloud_policy():
|
||||
deployment = await resolve_deployment(
|
||||
instance_uuid='instance-a',
|
||||
instance_config=_cloud_config(),
|
||||
entry_points=lambda: _EntryPoints([_EntryPoint(_Provider)]),
|
||||
now=1_000,
|
||||
)
|
||||
|
||||
assert isinstance(deployment, VerifiedCloudDeployment)
|
||||
assert deployment.multi_workspace_enabled is True
|
||||
assert deployment.persistence_mode == 'cloud_runtime'
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('field', 'value', 'message'),
|
||||
[
|
||||
('database', {'use': 'sqlite'}, 'database.use=postgresql'),
|
||||
('vdb', {'use': 'chroma'}, 'vdb.use=pgvector'),
|
||||
('mcp', {'stdio': {'enabled': True}}, 'mcp.stdio.enabled=false'),
|
||||
],
|
||||
)
|
||||
async def test_cloud_runtime_config_is_fail_closed(field, value, message):
|
||||
config = _cloud_config()
|
||||
config[field] = value
|
||||
|
||||
with pytest.raises(CloudBootstrapError, match=message):
|
||||
await resolve_deployment(
|
||||
instance_uuid='instance-a',
|
||||
instance_config=config,
|
||||
entry_points=lambda: _EntryPoints([_EntryPoint(_Provider())]),
|
||||
now=1_000,
|
||||
)
|
||||
|
||||
|
||||
async def test_invalid_provider_never_falls_back_to_oss():
|
||||
provider = SimpleNamespace(bootstrap=lambda **_: object())
|
||||
|
||||
with pytest.raises(CloudBootstrapError, match='must return VerifiedCloudDeployment'):
|
||||
await resolve_deployment(
|
||||
instance_uuid='instance-a',
|
||||
instance_config=_cloud_config(),
|
||||
entry_points=lambda: _EntryPoints([_EntryPoint(provider)]),
|
||||
now=1_000,
|
||||
)
|
||||
|
||||
|
||||
async def test_duplicate_closed_providers_fail_closed():
|
||||
with pytest.raises(CloudBootstrapError, match='Exactly one'):
|
||||
await resolve_deployment(
|
||||
instance_uuid='instance-a',
|
||||
instance_config=_cloud_config(),
|
||||
entry_points=lambda: _EntryPoints([_EntryPoint(_Provider()), _EntryPoint(_Provider())]),
|
||||
now=1_000,
|
||||
)
|
||||
@@ -0,0 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from langbot.pkg.cloud.entitlements import EntitlementResolver, EntitlementSnapshot, EntitlementUnavailableError
|
||||
|
||||
|
||||
def _snapshot(**overrides) -> EntitlementSnapshot:
|
||||
values = {
|
||||
'instance_uuid': 'instance-a',
|
||||
'workspace_uuid': 'workspace-a',
|
||||
'entitlement_revision': 7,
|
||||
'status': 'active',
|
||||
'not_before': 100,
|
||||
'expires_at': 200,
|
||||
'features': {'managed_sandbox': True, 'mcp_stdio': False},
|
||||
'limits': {'managed_sandbox_sessions': 1},
|
||||
}
|
||||
values.update(overrides)
|
||||
return EntitlementSnapshot(**values)
|
||||
|
||||
|
||||
def test_active_snapshot_exposes_only_generic_features_and_limits():
|
||||
snapshot = _snapshot().require_active(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
now=150,
|
||||
)
|
||||
|
||||
snapshot.require_feature('managed_sandbox')
|
||||
assert snapshot.limit('managed_sandbox_sessions') == 1
|
||||
assert 'plan' not in snapshot.model_fields
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'snapshot,now',
|
||||
[
|
||||
(_snapshot(status='suspended'), 150),
|
||||
(_snapshot(), 99),
|
||||
(_snapshot(), 200),
|
||||
],
|
||||
)
|
||||
def test_inactive_or_expired_snapshot_fails_closed(snapshot, now):
|
||||
with pytest.raises(EntitlementUnavailableError):
|
||||
snapshot.require_active(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
now=now,
|
||||
)
|
||||
|
||||
|
||||
def test_scope_mismatch_fails_closed():
|
||||
with pytest.raises(EntitlementUnavailableError, match='scope'):
|
||||
_snapshot().require_active(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-b',
|
||||
now=150,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolver_rejects_revision_rollback():
|
||||
provider = AsyncMock()
|
||||
provider.get_workspace_entitlement = AsyncMock(side_effect=[_snapshot(), _snapshot(entitlement_revision=6)])
|
||||
resolver = EntitlementResolver('instance-a', provider)
|
||||
|
||||
await resolver.resolve('workspace-a', now=150)
|
||||
with pytest.raises(EntitlementUnavailableError, match='rolled back'):
|
||||
await resolver.resolve('workspace-a', now=150)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolver_rejects_same_revision_with_different_contents():
|
||||
provider = AsyncMock()
|
||||
provider.get_workspace_entitlement = AsyncMock(
|
||||
side_effect=[
|
||||
_snapshot(),
|
||||
_snapshot(features={'managed_sandbox': False}),
|
||||
]
|
||||
)
|
||||
resolver = EntitlementResolver('instance-a', provider)
|
||||
|
||||
await resolver.resolve('workspace-a', now=150)
|
||||
with pytest.raises(EntitlementUnavailableError, match='conflicting contents'):
|
||||
await resolver.resolve('workspace-a', now=150)
|
||||
@@ -259,6 +259,60 @@ class TestApplyEnvOverridesToConfig:
|
||||
assert result['system']['enable'] is False
|
||||
assert result['concurrency']['pipeline'] == 10
|
||||
|
||||
def test_plugin_worker_and_stdio_policy_native_env_overrides(self):
|
||||
load_config = get_load_config_module()
|
||||
cfg = {
|
||||
'plugin': {
|
||||
'worker': {
|
||||
'max_cpus': 1.0,
|
||||
'max_memory_mb': 512,
|
||||
'max_pids': 128,
|
||||
'max_open_files': 256,
|
||||
'max_file_size_mb': 512,
|
||||
}
|
||||
},
|
||||
'mcp': {'stdio': {'enabled': True}},
|
||||
}
|
||||
env = {
|
||||
'PLUGIN__WORKER__MAX_CPUS': '2.5',
|
||||
'PLUGIN__WORKER__MAX_MEMORY_MB': '1024',
|
||||
'PLUGIN__WORKER__MAX_PIDS': '64',
|
||||
'PLUGIN__WORKER__MAX_OPEN_FILES': '128',
|
||||
'PLUGIN__WORKER__MAX_FILE_SIZE_MB': '256',
|
||||
'MCP__STDIO__ENABLED': 'false',
|
||||
}
|
||||
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
result = load_config._apply_env_overrides_to_config(cfg)
|
||||
|
||||
assert result['plugin']['worker'] == {
|
||||
'max_cpus': 2.5,
|
||||
'max_memory_mb': 1024,
|
||||
'max_pids': 64,
|
||||
'max_open_files': 128,
|
||||
'max_file_size_mb': 256,
|
||||
}
|
||||
assert result['mcp']['stdio']['enabled'] is False
|
||||
|
||||
def test_runtime_policy_defaults_preserve_env_types_for_upgraded_config(self):
|
||||
load_config = get_load_config_module()
|
||||
cfg = {'plugin': {'enable': True}}
|
||||
|
||||
completed = load_config._complete_runtime_policy_defaults(cfg)
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
'PLUGIN__WORKER__MAX_MEMORY_MB': '768',
|
||||
'MCP__STDIO__ENABLED': 'false',
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
result = load_config._apply_env_overrides_to_config(completed)
|
||||
|
||||
assert result['plugin']['worker']['max_memory_mb'] == 768
|
||||
assert isinstance(result['plugin']['worker']['max_memory_mb'], int)
|
||||
assert result['mcp']['stdio']['enabled'] is False
|
||||
|
||||
def test_webhook_prefix_override(self):
|
||||
"""Test overriding webhook_prefix via environment variable."""
|
||||
load_config = get_load_config_module()
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
|
||||
from langbot.pkg.persistence.tenant_uow import TenantUnitOfWork
|
||||
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
async def test_sqlite_tenant_uow_commits_and_rolls_back() -> None:
|
||||
engine = create_async_engine('sqlite+aiosqlite:///:memory:')
|
||||
table = sa.Table(
|
||||
'uow_rows',
|
||||
sa.MetaData(),
|
||||
sa.Column('id', sa.Integer, primary_key=True),
|
||||
sa.Column('workspace_uuid', sa.String(36), nullable=False),
|
||||
)
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(table.metadata.create_all)
|
||||
|
||||
async with TenantUnitOfWork(engine, 'workspace-a') as uow:
|
||||
await uow.execute(sa.insert(table).values(id=1, workspace_uuid='workspace-a'))
|
||||
|
||||
with pytest.raises(RuntimeError, match='roll back'):
|
||||
async with TenantUnitOfWork(engine, 'workspace-a') as uow:
|
||||
await uow.execute(sa.insert(table).values(id=2, workspace_uuid='workspace-a'))
|
||||
raise RuntimeError('roll back this transaction')
|
||||
|
||||
async with engine.connect() as conn:
|
||||
rows = (await conn.execute(sa.select(table.c.id).order_by(table.c.id))).scalars().all()
|
||||
assert rows == [1]
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_tenant_uow_is_single_use_and_requires_an_active_scope() -> None:
|
||||
engine = create_async_engine('sqlite+aiosqlite:///:memory:')
|
||||
uow = TenantUnitOfWork(engine, 'workspace-a')
|
||||
try:
|
||||
with pytest.raises(RuntimeError, match='not active'):
|
||||
_ = uow.session
|
||||
|
||||
async with uow:
|
||||
assert uow.session.in_transaction()
|
||||
|
||||
with pytest.raises(RuntimeError, match='cannot be reused'):
|
||||
async with uow:
|
||||
pass
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def test_persistence_mode_must_be_a_trusted_enum() -> None:
|
||||
with pytest.raises(TypeError, match='trusted PersistenceMode'):
|
||||
PersistenceManager(object(), mode='cloud_runtime') # type: ignore[arg-type]
|
||||
|
||||
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
|
||||
assert manager.mode is PersistenceMode.CLOUD_RUNTIME
|
||||
@@ -154,6 +154,7 @@ def mcp_module():
|
||||
def _make_ap():
|
||||
ap = Mock()
|
||||
ap.logger = Mock()
|
||||
ap.instance_config = SimpleNamespace(data={'mcp': {'stdio': {'enabled': True}}})
|
||||
ap.workspace_service = Mock()
|
||||
ap.workspace_service.get_execution_binding = AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
@@ -791,6 +792,31 @@ class TestBoxConfigParsing:
|
||||
assert s.box_config.host_path_mode == 'ro'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stdio_instance_gate_runs_before_box_transport(mcp_module):
|
||||
ap = _make_ap()
|
||||
ap.instance_config.data['mcp']['stdio']['enabled'] = False
|
||||
ap.box_service.available = True
|
||||
session = _make_session(
|
||||
mcp_module,
|
||||
{
|
||||
'name': 'blocked',
|
||||
'uuid': 'blocked-uuid',
|
||||
'mode': 'stdio',
|
||||
'command': 'python',
|
||||
'args': [],
|
||||
'env': {},
|
||||
},
|
||||
ap=ap,
|
||||
)
|
||||
session._box_stdio_runtime.initialize = AsyncMock()
|
||||
|
||||
with pytest.raises(RuntimeError, match='disabled by instance policy'):
|
||||
await session._init_stdio_python_server()
|
||||
|
||||
session._box_stdio_runtime.initialize.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_init_box_stdio_server_stages_host_path_in_shared_workspace(mcp_module, tmp_path):
|
||||
mcp_stdio_module = sys.modules['langbot.pkg.provider.tools.loaders.mcp_stdio']
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.provider.tools.loaders.mcp_policy import (
|
||||
MCPStdioDisabledError,
|
||||
require_stdio_mcp_enabled,
|
||||
stdio_mcp_enabled,
|
||||
)
|
||||
from langbot.pkg.provider.tools.loaders.mcp import MCPLoader
|
||||
|
||||
|
||||
def _app(config: dict) -> SimpleNamespace:
|
||||
return SimpleNamespace(instance_config=SimpleNamespace(data=config))
|
||||
|
||||
|
||||
def test_oss_default_remains_enabled_when_key_is_absent():
|
||||
assert stdio_mcp_enabled(_app({})) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'value',
|
||||
[False, 'false', 0, None, {}, []],
|
||||
)
|
||||
def test_disabled_or_invalid_values_fail_closed(value):
|
||||
ap = _app({'mcp': {'stdio': {'enabled': value}}})
|
||||
|
||||
assert stdio_mcp_enabled(ap) is False
|
||||
with pytest.raises(MCPStdioDisabledError, match='disabled by instance policy'):
|
||||
require_stdio_mcp_enabled(ap, {'mode': 'stdio'})
|
||||
|
||||
|
||||
def test_remote_transport_is_independent_of_stdio_gate():
|
||||
ap = _app({'mcp': {'stdio': {'enabled': False}}})
|
||||
|
||||
require_stdio_mcp_enabled(ap, {'mode': 'remote'})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bootstrap_retains_but_does_not_launch_disabled_stdio_rows():
|
||||
server = SimpleNamespace(uuid='server-a', workspace_uuid='workspace-a')
|
||||
result = Mock()
|
||||
result.all.return_value = [server]
|
||||
ap = _app({'mcp': {'stdio': {'enabled': False}}})
|
||||
ap.logger = Mock()
|
||||
ap.persistence_mgr = SimpleNamespace(
|
||||
execute_async=AsyncMock(return_value=result),
|
||||
serialize_model=Mock(
|
||||
return_value={
|
||||
'uuid': 'server-a',
|
||||
'workspace_uuid': 'workspace-a',
|
||||
'name': 'local',
|
||||
'mode': 'stdio',
|
||||
'enable': True,
|
||||
'extra_args': {},
|
||||
}
|
||||
),
|
||||
)
|
||||
ap.workspace_service = SimpleNamespace(get_execution_binding=AsyncMock())
|
||||
loader = MCPLoader(ap)
|
||||
loader.host_mcp_server = AsyncMock()
|
||||
|
||||
await loader.load_mcp_servers_from_db()
|
||||
|
||||
loader.host_mcp_server.assert_not_awaited()
|
||||
ap.workspace_service.get_execution_binding.assert_not_awaited()
|
||||
assert loader.sessions == {}
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
import { CustomApiError } from '@/app/infra/entities/common';
|
||||
import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice';
|
||||
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
|
||||
import { useMCPStdioPolicy } from '@/app/infra/hooks/useMCPStdioPolicy';
|
||||
|
||||
function StatusDisplay({
|
||||
testing,
|
||||
@@ -560,11 +561,15 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
hint: boxHint,
|
||||
reason: boxReason,
|
||||
} = useBoxStatus();
|
||||
const { enabled: mcpStdioEnabled } = useMCPStdioPolicy();
|
||||
// stdio mode requires the Box sandbox at runtime. If the user picks
|
||||
// stdio while Box is disabled / unreachable, the server would refuse
|
||||
// to start anyway — block creation upfront so they aren't surprised
|
||||
// by an immediate "Connection failed" on the detail page.
|
||||
const stdioBlockedByBox = watchMode === 'stdio' && !boxAvailable;
|
||||
const stdioBlockedByPolicy = watchMode === 'stdio' && !mcpStdioEnabled;
|
||||
const stdioBlockedByBox =
|
||||
watchMode === 'stdio' && mcpStdioEnabled && !boxAvailable;
|
||||
const stdioBlocked = stdioBlockedByPolicy || stdioBlockedByBox;
|
||||
|
||||
const { isDirty } = form.formState;
|
||||
useEffect(() => {
|
||||
@@ -572,8 +577,8 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
}, [isDirty, onDirtyChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onSaveBlockedChange?.(stdioBlockedByBox);
|
||||
}, [stdioBlockedByBox, onSaveBlockedChange]);
|
||||
onSaveBlockedChange?.(stdioBlocked);
|
||||
}, [stdioBlocked, onSaveBlockedChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onTestingChange?.(mcpTesting);
|
||||
@@ -589,10 +594,9 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
testMcp: () => testMcp(),
|
||||
isTesting: mcpTesting,
|
||||
}),
|
||||
// testMcp now reads everything via form.getValues(), so it does not need
|
||||
// the latest stdioArgs/extraArgs closure — but keep mcpTesting so the
|
||||
// exposed isTesting flag stays accurate.
|
||||
[mcpTesting],
|
||||
// Form values are read through form.getValues(); policy and runtime health
|
||||
// remain closure values and must refresh the imperative handler.
|
||||
[mcpTesting, mcpStdioEnabled, boxAvailable],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -747,6 +751,10 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
async function handleFormSubmit(value: z.infer<typeof formSchema>) {
|
||||
// Belt-and-suspenders: even though the Save button is disabled when
|
||||
// stdio is unselectable, intercept programmatic submits too.
|
||||
if (value.mode === 'stdio' && !mcpStdioEnabled) {
|
||||
toast.error(t('mcp.stdioDisabledByPolicy'));
|
||||
return;
|
||||
}
|
||||
if (value.mode === 'stdio' && !boxAvailable) {
|
||||
toast.error(t('mcp.stdioBlockedByBoxToast'));
|
||||
return;
|
||||
@@ -814,6 +822,16 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
|
||||
try {
|
||||
const mode = form.getValues('mode');
|
||||
if (mode === 'stdio' && !mcpStdioEnabled) {
|
||||
toast.error(t('mcp.stdioDisabledByPolicy'));
|
||||
setMcpTesting(false);
|
||||
return;
|
||||
}
|
||||
if (mode === 'stdio' && !boxAvailable) {
|
||||
toast.error(t('mcp.stdioBlockedByBoxToast'));
|
||||
setMcpTesting(false);
|
||||
return;
|
||||
}
|
||||
// Read every field via form.getValues() rather than the captured
|
||||
// `stdioArgs` / `extraArgs` state. testMcp() is invoked through an
|
||||
// imperative handle (formRef.current.testMcp()) whose closure is only
|
||||
@@ -1020,13 +1038,20 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="remote">{t('mcp.remote')}</SelectItem>
|
||||
<SelectItem value="stdio" disabled={!boxAvailable}>
|
||||
<SelectItem
|
||||
value="stdio"
|
||||
disabled={!mcpStdioEnabled || !boxAvailable}
|
||||
>
|
||||
{t('mcp.local')}
|
||||
{!boxAvailable && (
|
||||
{!mcpStdioEnabled ? (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
({t('mcp.disabledByPolicy')})
|
||||
</span>
|
||||
) : !boxAvailable ? (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
({t('mcp.boxRequired')})
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -1035,6 +1060,14 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
? t('mcp.localModeDescription')
|
||||
: t('mcp.remoteModeDescription')}
|
||||
</FormDescription>
|
||||
{stdioBlockedByPolicy && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-2 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm text-amber-800 dark:text-amber-200"
|
||||
>
|
||||
{t('mcp.stdioDisabledByPolicy')}
|
||||
</div>
|
||||
)}
|
||||
{stdioBlockedByBox && (
|
||||
<BoxUnavailableNotice
|
||||
hint={boxHint}
|
||||
|
||||
@@ -346,6 +346,8 @@ export interface ApiRespSystemInfo {
|
||||
debug: boolean;
|
||||
version: string;
|
||||
edition: string;
|
||||
/** Independent instance-level gate for local stdio MCP transports. */
|
||||
mcp_stdio_enabled: boolean;
|
||||
cloud_service_url: string;
|
||||
enable_marketplace: boolean;
|
||||
allow_modify_login_info: boolean;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
|
||||
/**
|
||||
* Load the instance-level stdio MCP gate independently of Box health.
|
||||
*
|
||||
* The hook fails closed while loading or when System Info is unavailable.
|
||||
* This is only a WebUI guard; the backend loader enforces the same gate at
|
||||
* the final transport boundary.
|
||||
*/
|
||||
export function useMCPStdioPolicy() {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const info = await httpClient.getSystemInfo();
|
||||
setEnabled(info.mcp_stdio_enabled === true);
|
||||
} catch {
|
||||
setEnabled(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return { enabled, loading, refresh };
|
||||
}
|
||||
@@ -25,6 +25,7 @@ export const systemInfo: ApiRespSystemInfo = {
|
||||
debug: false,
|
||||
version: '',
|
||||
edition: 'community',
|
||||
mcp_stdio_enabled: false,
|
||||
enable_marketplace: true,
|
||||
cloud_service_url: '',
|
||||
allow_modify_login_info: true,
|
||||
|
||||
@@ -820,6 +820,9 @@ const enUS = {
|
||||
boxStdioRefusedSuggestion:
|
||||
'Enable Box (box.enabled = true) and ensure the runtime is healthy, or switch this server to http/sse mode.',
|
||||
boxRequired: 'requires Box',
|
||||
disabledByPolicy: 'disabled by policy',
|
||||
stdioDisabledByPolicy:
|
||||
'Stdio MCP is disabled for this deployment. Use a remote MCP server instead.',
|
||||
stdioBlockedByBoxToast:
|
||||
'Stdio MCP cannot be saved while the Box sandbox is disabled or unreachable. Enable Box or pick http/sse.',
|
||||
toolsFound: 'tools',
|
||||
|
||||
@@ -833,6 +833,9 @@ const esES = {
|
||||
boxStdioRefusedSuggestion:
|
||||
'Active Box (box.enabled = true) y asegúrese de que el runtime está conectado, o cambie este servidor a modo http/sse.',
|
||||
boxRequired: 'requiere Box',
|
||||
disabledByPolicy: 'desactivado por la política',
|
||||
stdioDisabledByPolicy:
|
||||
'Stdio MCP está deshabilitado en este despliegue. Use un servidor MCP remoto.',
|
||||
stdioBlockedByBoxToast:
|
||||
'No se puede guardar el MCP en modo stdio mientras el sandbox de Box está desactivado o no disponible. Active Box o seleccione modo http/sse.',
|
||||
toolsFound: 'herramientas',
|
||||
|
||||
@@ -826,6 +826,9 @@ const jaJP = {
|
||||
boxStdioRefusedSuggestion:
|
||||
'Box を有効化(box.enabled = true)してランタイムの接続を確認するか、このサーバーを http/sse モードに切り替えてください。',
|
||||
boxRequired: 'Box が必要',
|
||||
disabledByPolicy: 'ポリシーにより無効',
|
||||
stdioDisabledByPolicy:
|
||||
'このデプロイでは Stdio MCP が無効です。リモート MCP サーバーを使用してください。',
|
||||
stdioBlockedByBoxToast:
|
||||
'Box サンドボックスが無効または利用できないため、stdio モードの MCP は保存できません。Box を有効化するか、http/sse モードに切り替えてください。',
|
||||
toolsFound: '個のツール',
|
||||
|
||||
@@ -830,6 +830,9 @@ const ruRU = {
|
||||
boxStdioRefusedSuggestion:
|
||||
'Включите Box (box.enabled = true) и убедитесь, что среда работает, либо переключите этот сервер в режим http/sse.',
|
||||
boxRequired: 'требуется Box',
|
||||
disabledByPolicy: 'отключено политикой',
|
||||
stdioDisabledByPolicy:
|
||||
'Stdio MCP отключён в этом развёртывании. Используйте удалённый MCP-сервер.',
|
||||
stdioBlockedByBoxToast:
|
||||
'Сохранить MCP в режиме stdio нельзя: песочница Box отключена или недоступна. Включите Box либо выберите режим http/sse.',
|
||||
toolsFound: 'инструментов',
|
||||
|
||||
@@ -808,6 +808,9 @@ const thTH = {
|
||||
boxStdioRefusedSuggestion:
|
||||
'กรุณาเปิดใช้งาน Box (box.enabled = true) และตรวจสอบว่ารันไทม์ทำงานปกติ หรือเปลี่ยน MCP server เป็นโหมด http/sse',
|
||||
boxRequired: 'ต้องใช้ Box',
|
||||
disabledByPolicy: 'ถูกปิดใช้งานโดยนโยบาย',
|
||||
stdioDisabledByPolicy:
|
||||
'การติดตั้งใช้งานนี้ปิด Stdio MCP อยู่ โปรดใช้เซิร์ฟเวอร์ MCP แบบระยะไกล',
|
||||
stdioBlockedByBoxToast:
|
||||
'ไม่สามารถบันทึก MCP โหมด stdio เนื่องจาก Sandbox Box ถูกปิดใช้งานหรือไม่พร้อมใช้งาน กรุณาเปิดใช้งาน Box หรือเลือกโหมด http/sse',
|
||||
toolsFound: 'เครื่องมือ',
|
||||
|
||||
@@ -823,6 +823,9 @@ const viVN = {
|
||||
boxStdioRefusedSuggestion:
|
||||
'Hãy bật Box (box.enabled = true) và đảm bảo runtime hoạt động, hoặc chuyển server này sang chế độ http/sse.',
|
||||
boxRequired: 'cần Box',
|
||||
disabledByPolicy: 'bị tắt theo chính sách',
|
||||
stdioDisabledByPolicy:
|
||||
'Stdio MCP đã bị tắt trong bản triển khai này. Hãy dùng máy chủ MCP từ xa.',
|
||||
stdioBlockedByBoxToast:
|
||||
'Không thể lưu MCP ở chế độ stdio khi Sandbox Box bị tắt hoặc không khả dụng. Hãy bật Box hoặc chọn chế độ http/sse.',
|
||||
toolsFound: 'công cụ',
|
||||
|
||||
@@ -786,6 +786,8 @@ const zhHans = {
|
||||
boxStdioRefusedSuggestion:
|
||||
'请启用 Box(box.enabled = true)并确认运行时连接正常,或将此服务器切换到 http/sse 模式。',
|
||||
boxRequired: '需要 Box',
|
||||
disabledByPolicy: '已被策略禁用',
|
||||
stdioDisabledByPolicy: '此部署已禁用 Stdio MCP,请改用远程 MCP 服务器。',
|
||||
stdioBlockedByBoxToast:
|
||||
'Box 沙箱已禁用或不可用,无法保存 stdio 模式的 MCP。请启用 Box 或改为 http/sse 模式。',
|
||||
toolsFound: '个工具',
|
||||
|
||||
@@ -784,6 +784,8 @@ const zhHant = {
|
||||
boxStdioRefusedSuggestion:
|
||||
'請啟用 Box(box.enabled = true)並確認執行時連線正常,或將此伺服器切換到 http/sse 模式。',
|
||||
boxRequired: '需要 Box',
|
||||
disabledByPolicy: '已被策略停用',
|
||||
stdioDisabledByPolicy: '此部署已停用 Stdio MCP,請改用遠端 MCP 伺服器。',
|
||||
stdioBlockedByBoxToast:
|
||||
'Box 沙箱已停用或無法使用,無法儲存 stdio 模式的 MCP。請啟用 Box 或改為 http/sse 模式。',
|
||||
toolsFound: '個工具',
|
||||
|
||||
Reference in New Issue
Block a user