feat(tenancy): establish cloud isolation foundations

This commit is contained in:
Junyan Qin
2026-07-19 22:39:58 +08:00
parent 59b1570ead
commit 41772920ef
41 changed files with 1984 additions and 43 deletions
+2
View File
@@ -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,
+15 -1
View File
@@ -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,
+27
View File
@@ -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',
]
+161
View File
@@ -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
+142
View File
@@ -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)
+6
View File
@@ -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
+22 -9
View File
@@ -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)
+216 -12
View File
@@ -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()
+113
View File
@@ -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()
+13
View File
@@ -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
+136
View File
@@ -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)
+54
View File
@@ -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}
+2
View File
@@ -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 };
}
+1
View File
@@ -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,
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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: '個のツール',
+3
View File
@@ -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: 'инструментов',
+3
View File
@@ -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: 'เครื่องมือ',
+3
View File
@@ -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ụ',
+2
View File
@@ -786,6 +786,8 @@ const zhHans = {
boxStdioRefusedSuggestion:
'请启用 Boxbox.enabled = true)并确认运行时连接正常,或将此服务器切换到 http/sse 模式。',
boxRequired: '需要 Box',
disabledByPolicy: '已被策略禁用',
stdioDisabledByPolicy: '此部署已禁用 Stdio MCP,请改用远程 MCP 服务器。',
stdioBlockedByBoxToast:
'Box 沙箱已禁用或不可用,无法保存 stdio 模式的 MCP。请启用 Box 或改为 http/sse 模式。',
toolsFound: '个工具',
+2
View File
@@ -784,6 +784,8 @@ const zhHant = {
boxStdioRefusedSuggestion:
'請啟用 Boxbox.enabled = true)並確認執行時連線正常,或將此伺服器切換到 http/sse 模式。',
boxRequired: '需要 Box',
disabledByPolicy: '已被策略停用',
stdioDisabledByPolicy: '此部署已停用 Stdio MCP,請改用遠端 MCP 伺服器。',
stdioBlockedByBoxToast:
'Box 沙箱已停用或無法使用,無法儲存 stdio 模式的 MCP。請啟用 Box 或改為 http/sse 模式。',
toolsFound: '個工具',