feat(tenancy): harden shared cloud runtime boundaries

This commit is contained in:
Junyan Qin
2026-07-20 01:47:42 +08:00
parent 41772920ef
commit a47bfe8167
121 changed files with 18152 additions and 5588 deletions
+3
View File
@@ -82,6 +82,9 @@ class FakeApp:
def _create_mock_persistence_manager(self):
persistence_mgr = AsyncMock()
persistence_mgr.execute_async = AsyncMock()
# AsyncMock invents arbitrary callable attributes on access. Keep the
# optional production UoW hook explicitly absent unless a test opts in.
persistence_mgr.tenant_uow = None
return persistence_mgr
def _create_mock_query_pool(self):
@@ -9,6 +9,7 @@ import pytest
import quart
from langbot.pkg.api.http.controller.groups.box import BoxRouterGroup
from langbot.pkg.cloud.entitlements import EntitlementUnavailableError
pytestmark = pytest.mark.integration
@@ -34,6 +35,8 @@ async def box_security_api():
'owner-token': SimpleNamespace(uuid='owner-account', user='owner@example.com'),
}
application = Mock()
application.deployment = SimpleNamespace(multi_workspace_enabled=False)
application.persistence_mgr = SimpleNamespace(tenant_uow=None)
application.user_service.get_authenticated_account = AsyncMock(side_effect=lambda token: accounts[token])
application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(
side_effect=lambda account_uuid, _workspace_uuid: _access(account_uuid)
@@ -41,6 +44,7 @@ async def box_security_api():
application.box_service.get_status = AsyncMock(return_value={'enabled': True})
application.box_service.get_sessions = AsyncMock(return_value=[{'session_id': 'private-session'}])
application.box_service.get_recent_errors = Mock(return_value=[{'error': 'private error'}])
application.box_service.managed_admission_required = False
quart_app = quart.Quart(__name__)
router = BoxRouterGroup(application, quart_app)
@@ -81,3 +85,17 @@ async def test_owner_can_audit_box_sessions_and_errors(box_security_api):
assert errors.status_code == 200
application.box_service.get_sessions.assert_awaited_once()
application.box_service.get_recent_errors.assert_called_once()
@pytest.mark.asyncio
async def test_box_status_returns_explicit_403_when_workspace_has_no_managed_sandbox(box_security_api):
application, client = box_security_api
application.box_service.get_status.side_effect = EntitlementUnavailableError(
'Workspace entitlement does not grant managed_sandbox'
)
response = await client.get('/api/v1/box/status', headers=_headers('viewer-token'))
assert response.status_code == 403
payload = await response.get_json()
assert payload['code'] == 'managed_sandbox_unavailable'
@@ -0,0 +1,214 @@
from __future__ import annotations
import json
import logging
from types import SimpleNamespace
import pytest
import sqlalchemy as sa
from quart import Quart
from langbot.pkg.api.http.controller.groups.system import SystemRouterGroup
from langbot.pkg.api.http.controller.groups.user import UserRouterGroup
from langbot.pkg.api.http.controller.groups.workspaces import WorkspacesRouterGroup
from langbot.pkg.api.http.service.user import UserService
from langbot.pkg.entity.persistence.metadata import WorkspaceMetadata
from langbot.pkg.persistence.mgr import PersistenceManager
from langbot.pkg.utils import constants
from langbot.pkg.workspace.collaboration import WorkspaceCollaborationService
from langbot.pkg.workspace.service import WorkspaceService
pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
def _authorization(token: str, workspace_uuid: str | None = None) -> dict[str, str]:
headers = {'Authorization': f'Bearer {token}'}
if workspace_uuid is not None:
headers['X-Workspace-Id'] = workspace_uuid
return headers
async def test_fresh_oss_workspace_http_journey_uses_real_sqlite_persistence(
tmp_path,
monkeypatch,
):
"""Exercise the first-run Workspace journey through real HTTP handlers."""
instance_uuid = 'fresh-oss-workspace-journey'
monkeypatch.setattr(constants, 'instance_id', instance_uuid)
application = SimpleNamespace(
logger=logging.getLogger('fresh-oss-workspace-journey-test'),
instance_config=SimpleNamespace(
data={
'database': {
'use': 'sqlite',
'sqlite': {'path': str(tmp_path / 'langbot.db')},
},
'system': {
'jwt': {'secret': 'fresh-oss-workspace-secret', 'expire': 3600},
'allow_modify_login_info': True,
'limitation': {},
'outbound_ips': [],
},
'api': {'global_api_key': ''},
'plugin': {'enable_marketplace': True},
'space': {
'url': 'https://space.langbot.app',
'models_gateway_api_url': 'https://api.langbot.cloud/v1',
'disable_models_service': False,
},
'mcp': {'stdio': {'enabled': True}},
}
),
)
persistence = PersistenceManager(application)
application.persistence_mgr = persistence
await persistence.initialize()
try:
application.workspace_service = WorkspaceService(
application,
instance_uuid=instance_uuid,
)
application.workspace_collaboration_service = WorkspaceCollaborationService(
application,
application.workspace_service,
)
application.user_service = UserService(application)
quart_app = Quart(__name__)
await UserRouterGroup(application, quart_app).initialize()
await WorkspacesRouterGroup(application, quart_app).initialize()
await SystemRouterGroup(application, quart_app).initialize()
client = quart_app.test_client()
initialization = await client.get('/api/v1/user/init')
assert initialization.status_code == 200
assert (await initialization.get_json())['data'] == {'initialized': False}
initialized = await client.post(
'/api/v1/user/init',
json={'user': 'owner@example.com', 'password': 'owner-password'},
)
assert initialized.status_code == 200
authenticated = await client.post(
'/api/v1/user/auth',
json={'user': 'owner@example.com', 'password': 'owner-password'},
)
assert authenticated.status_code == 200
token = (await authenticated.get_json())['data']['token']
bootstrap = await client.get(
'/api/v1/workspaces/bootstrap',
headers=_authorization(token),
)
assert bootstrap.status_code == 200
bootstrap_workspaces = (await bootstrap.get_json())['data']['workspaces']
assert len(bootstrap_workspaces) == 1
bootstrap_access = bootstrap_workspaces[0]
workspace_uuid = bootstrap_access['workspace']['uuid']
assert bootstrap_access['workspace'] == {
'uuid': workspace_uuid,
'instance_uuid': instance_uuid,
'name': 'Default Workspace',
'slug': 'default',
'type': 'team',
'status': 'active',
'source': 'local',
}
assert bootstrap_access['membership']['email'] == 'owner@example.com'
assert bootstrap_access['membership']['role'] == 'owner'
assert 'workspace.update' in bootstrap_access['permissions']
current = await client.get(
'/api/v1/workspaces/current',
headers=_authorization(token, workspace_uuid),
)
assert current.status_code == 200
current_data = (await current.get_json())['data']
assert current_data['workspace']['uuid'] == workspace_uuid
assert current_data['membership']['account_uuid'] == bootstrap_access['membership']['account_uuid']
assert current_data['membership']['role'] == 'owner'
user_info = await client.get(
'/api/v1/user/info',
headers=_authorization(token, workspace_uuid),
)
assert user_info.status_code == 200
assert (await user_info.get_json())['data'] == {
'account_uuid': bootstrap_access['membership']['account_uuid'],
'user': 'owner@example.com',
'account_type': 'local',
'has_password': True,
}
initial_system_info = await client.get(
'/api/v1/system/info',
headers=_authorization(token, workspace_uuid),
)
assert initial_system_info.status_code == 200
assert (await initial_system_info.get_json())['data']['wizard_status'] == 'none'
assert (await initial_system_info.get_json())['data']['wizard_progress'] is None
progress = {'step': 2, 'selected_adapter': 'telegram', 'bot_saved': False}
updated_progress = await client.put(
'/api/v1/system/wizard/progress',
headers=_authorization(token, workspace_uuid),
json=progress,
)
assert updated_progress.status_code == 200
persisted_progress = await persistence.execute_async(
sa.select(WorkspaceMetadata.value).where(
WorkspaceMetadata.workspace_uuid == workspace_uuid,
WorkspaceMetadata.key == 'wizard_progress',
)
)
assert json.loads(persisted_progress.scalar_one()) == progress
system_info_with_progress = await client.get(
'/api/v1/system/info',
headers=_authorization(token, workspace_uuid),
)
assert system_info_with_progress.status_code == 200
progress_data = (await system_info_with_progress.get_json())['data']
assert progress_data['wizard_status'] == 'none'
assert progress_data['wizard_progress'] == progress
completed = await client.post(
'/api/v1/system/wizard/completed',
headers=_authorization(token, workspace_uuid),
json={'status': 'completed'},
)
assert completed.status_code == 200
completed_system_info = await client.get(
'/api/v1/system/info',
headers=_authorization(token, workspace_uuid),
)
assert completed_system_info.status_code == 200
completed_data = (await completed_system_info.get_json())['data']
assert completed_data['wizard_status'] == 'completed'
assert completed_data['wizard_progress'] is None
rejected_workspace = await client.post(
'/api/v1/workspaces',
headers=_authorization(token, workspace_uuid),
json={'name': 'Second Workspace'},
)
assert rejected_workspace.status_code == 403
rejected_data = await rejected_workspace.get_json()
assert rejected_data['code'] == 'edition_limit'
persisted_wizard_status = await persistence.execute_async(
sa.select(WorkspaceMetadata.value).where(
WorkspaceMetadata.workspace_uuid == workspace_uuid,
WorkspaceMetadata.key == 'wizard_status',
)
)
assert persisted_wizard_status.scalar_one() == 'completed'
finally:
await persistence.get_db_engine().dispose()
@@ -86,6 +86,7 @@ async def plugin_security_api(plugin_module):
}
application = Mock()
application.deployment = SimpleNamespace(multi_workspace_enabled=False)
application.user_service.get_authenticated_account = AsyncMock(side_effect=lambda token: accounts[token])
application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(
side_effect=lambda account_uuid, _workspace_uuid: _access(account_uuid)
@@ -112,6 +113,7 @@ async def plugin_security_api(plugin_module):
persistence_result = Mock()
persistence_result.scalar_one_or_none.return_value = RAW_CONFIG
application.persistence_mgr.execute_async = AsyncMock(return_value=persistence_result)
application.persistence_mgr.tenant_uow = None
quart_app = quart.Quart(__name__)
router = plugin_module.PluginsRouterGroup(application, quart_app)
@@ -246,3 +248,25 @@ async def test_viewer_cannot_read_plugin_runtime_logs(plugin_security_api):
assert response.status_code == 403
assert (await response.get_json())['code'] == 'permission_denied'
application.plugin_connector.get_plugin_logs.assert_not_awaited()
@pytest.mark.asyncio
async def test_github_install_rejects_internal_asset_url_before_task_creation(
plugin_security_api,
):
application, client, _ = plugin_security_api
response = await client.post(
'/api/v1/plugins/install/github',
headers=_headers('manager-token'),
json={
'asset_url': 'http://169.254.169.254/latest/meta-data',
'owner': 'langbot-app',
'repo': 'demo-plugin',
'release_tag': 'v1.0.0',
},
)
assert response.status_code == 400
assert 'HTTPS GitHub release asset URL' in (await response.get_json())['msg']
application.task_mgr.create_user_task.assert_not_called()
@@ -25,6 +25,8 @@ async def space_oauth_api():
execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=1),
)
application = Mock()
application.deployment = SimpleNamespace(multi_workspace_enabled=False)
application.persistence_mgr = None
application.user_service.get_authenticated_account = AsyncMock(return_value=account)
application.user_service.issue_space_oauth_state = AsyncMock(
side_effect=lambda purpose, **_: f'opaque-{purpose}-state'
+71 -30
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import json
import logging
from types import SimpleNamespace
@@ -13,11 +14,13 @@ from langbot.pkg.api.http.controller.groups.workspaces import (
InvitationsRouterGroup,
WorkspacesRouterGroup,
)
from langbot.pkg.api.http.controller.groups.system import SystemRouterGroup
from langbot.pkg.api.http.controller.groups.apikeys import ApiKeysRouterGroup
from langbot.pkg.api.http.controller.groups.user import UserRouterGroup
from langbot.pkg.api.http.service.apikey import ApiKeyService
from langbot.pkg.api.http.service.user import ControlPlaneDirectoryRequiredError, UserService
from langbot.pkg.entity.persistence.base import Base
from langbot.pkg.entity.persistence.metadata import WorkspaceMetadata
from langbot.pkg.entity.persistence.user import User
from langbot.pkg.entity.persistence.workspace import (
Workspace,
@@ -25,6 +28,7 @@ from langbot.pkg.entity.persistence.workspace import (
WorkspaceInvitation,
WorkspaceMembership,
)
from langbot.pkg.persistence.mgr import PersistenceManager
from langbot.pkg.workspace.collaboration import WorkspaceCollaborationService
from langbot.pkg.workspace.service import WorkspaceService
from langbot.pkg.workspace.policy import CloudWorkspacePolicy
@@ -33,28 +37,6 @@ from langbot.pkg.workspace.policy import CloudWorkspacePolicy
pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
class _PersistenceManager:
def __init__(self, engine):
self.engine = engine
def get_db_engine(self):
return self.engine
async def execute_async(self, *args, **kwargs):
async with self.engine.connect() as connection:
result = await connection.execute(*args, **kwargs)
await connection.commit()
return result
@staticmethod
def serialize_model(model, row, masked_columns=()):
return {
column.name: getattr(row, column.name)
for column in model.__table__.columns
if column.name not in masked_columns
}
@pytest.fixture
async def workspace_api(tmp_path):
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-api.db"}')
@@ -62,7 +44,8 @@ async def workspace_api(tmp_path):
await connection.run_sync(Base.metadata.create_all)
application = SimpleNamespace()
application.persistence_mgr = _PersistenceManager(engine)
application.persistence_mgr = PersistenceManager(application)
application.persistence_mgr.db = SimpleNamespace(get_engine=lambda: engine)
application.instance_config = SimpleNamespace(
data={
'system': {
@@ -85,19 +68,27 @@ async def workspace_api(tmp_path):
application.user_service = UserService(application)
application.apikey_service = ApiKeyService(application)
owner = await application.user_service.create_initial_account(
'owner@example.com',
'owner-password',
)
owner_token = await application.user_service.generate_jwt_token(owner)
quart_app = Quart(__name__)
await WorkspacesRouterGroup(application, quart_app).initialize()
await InvitationsRouterGroup(application, quart_app).initialize()
await ApiKeysRouterGroup(application, quart_app).initialize()
await UserRouterGroup(application, quart_app).initialize()
await SystemRouterGroup(application, quart_app).initialize()
yield application, quart_app.test_client(), engine, owner_token
client = quart_app.test_client()
init_response = await client.post(
'/api/v1/user/init',
json={'user': 'owner@example.com', 'password': 'owner-password'},
)
assert init_response.status_code == 200
auth_response = await client.post(
'/api/v1/user/auth',
json={'user': 'owner@example.com', 'password': 'owner-password'},
)
assert auth_response.status_code == 200
owner_token = (await auth_response.get_json())['data']['token']
yield application, client, engine, owner_token
await engine.dispose()
@@ -108,6 +99,56 @@ def _auth(token: str, workspace_uuid: str | None = None) -> dict[str, str]:
return headers
async def test_fresh_sqlite_login_returns_current_workspace_and_user_info(workspace_api):
_, client, _, owner_token = workspace_api
current_response = await client.get('/api/v1/workspaces/current', headers=_auth(owner_token))
assert current_response.status_code == 200
current = (await current_response.get_json())['data']
assert current['workspace']['uuid']
assert current['membership']['email'] == 'owner@example.com'
assert current['membership']['role'] == 'owner'
info_response = await client.get('/api/v1/user/info', headers=_auth(owner_token))
assert info_response.status_code == 200
info = (await info_response.get_json())['data']
assert info['account_uuid'] == current['membership']['account_uuid']
assert info['user'] == 'owner@example.com'
async def test_authenticated_system_info_reads_workspace_wizard_metadata(workspace_api):
application, client, _, owner_token = workspace_api
current_response = await client.get('/api/v1/workspaces/current', headers=_auth(owner_token))
workspace_uuid = (await current_response.get_json())['data']['workspace']['uuid']
progress = {'step': 3, 'selected_adapter': 'telegram'}
await application.persistence_mgr.execute_async(
sqlalchemy.insert(WorkspaceMetadata),
[
{
'workspace_uuid': workspace_uuid,
'key': 'wizard_status',
'value': 'completed',
},
{
'workspace_uuid': workspace_uuid,
'key': 'wizard_progress',
'value': json.dumps(progress),
},
],
)
response = await client.get(
'/api/v1/system/info',
headers=_auth(owner_token, workspace_uuid),
)
assert response.status_code == 200
data = (await response.get_json())['data']
assert data['wizard_status'] == 'completed'
assert data['wizard_progress'] == progress
async def test_owner_invites_second_account_and_secret_is_not_persisted(workspace_api):
application, client, engine, owner_token = workspace_api
@@ -10,6 +10,7 @@ Run: uv run pytest tests/integration/persistence/test_migrations.py -q
from __future__ import annotations
import pytest
import sqlalchemy
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.entity.persistence.base import Base
@@ -149,6 +150,27 @@ class TestSQLiteMigrationUpgrade:
rev2 = await get_alembic_current(sqlite_engine)
assert rev2 == rev1, f'Expected {rev1}, got {rev2}'
@pytest.mark.asyncio
async def test_upgrade_from_0012_adds_knowledge_base_embedding_dimension(self, sqlite_engine):
"""The PostgreSQL pgvector revision also evolves the OSS ORM schema."""
async with sqlite_engine.begin() as conn:
await conn.exec_driver_sql(
'CREATE TABLE knowledge_bases ('
'uuid VARCHAR(255) PRIMARY KEY, workspace_uuid VARCHAR(36) NOT NULL, name VARCHAR(255) NOT NULL)'
)
await run_alembic_stamp(sqlite_engine, '0012_plugin_identity')
await run_alembic_upgrade(sqlite_engine, 'head')
async with sqlite_engine.connect() as conn:
columns = await conn.run_sync(
lambda sync_conn: {
item['name'] for item in sqlalchemy.inspect(sync_conn).get_columns('knowledge_bases')
}
)
assert 'embedding_dimension' in columns
class TestSQLiteMigrationFreshDatabase:
"""Tests for fresh database workflow."""
@@ -16,19 +16,30 @@ from __future__ import annotations
import logging
import os
import uuid
import asyncio
import contextlib
import datetime
import hashlib
import typing
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
import sqlalchemy as sa
from quart import Quart
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from sqlalchemy.ext.asyncio import AsyncConnection, 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, PersistenceMode
from langbot.pkg.persistence.tenant_uow import TENANT_POLICY_NAME, TENANT_TABLE_COLUMNS, TenantUnitOfWork
from langbot.pkg.persistence.tenant_uow import (
TENANT_POLICY_NAME,
TENANT_TABLE_COLUMNS,
TenantUnitOfWork,
TransactionRollbackOnlyError,
)
from langbot.pkg.persistence.alembic_runner import (
run_alembic_upgrade,
run_alembic_stamp,
@@ -39,6 +50,32 @@ from alembic.config import Config
from alembic.script import ScriptDirectory
from langbot.pkg.utils import constants
from langbot.pkg.workspace.collaboration import normalize_email
from langbot.pkg.workspace.collaboration import WorkspaceCollaborationService
from langbot.pkg.workspace.policy import CloudWorkspacePolicy
from langbot.pkg.workspace.service import WorkspaceService
from langbot.pkg.api.http.authz import Permission
from langbot.pkg.api.http.controller import group as http_group
from langbot.pkg.api.http.controller.groups.system import SystemRouterGroup
from langbot.pkg.api.http.controller.groups.webhooks import WebhookRouterGroup
from langbot.pkg.api.http.context import ExecutionContext, RequestContext
from langbot.pkg.api.http.service.apikey import ApiKeyService
from langbot.pkg.api.http.service.monitoring import MonitoringService
from langbot.pkg.api.http.service.user import UserService
from langbot.pkg.api.mcp.context import get_request_context as get_mcp_request_context
from langbot.pkg.api.mcp.mount import MCPMount
from langbot.pkg.entity.persistence.apikey import ApiKey
from langbot.pkg.entity.persistence.metadata import WorkspaceMetadata
from langbot.pkg.entity.persistence.monitoring import MonitoringFeedback
from langbot.pkg.entity.persistence.workspace import (
Workspace,
WorkspaceExecutionState,
WorkspaceMembership,
)
from langbot.pkg.platform.botmgr import PlatformManager
from langbot.pkg.pipeline.pipelinemgr import PipelineManager
from langbot.pkg.provider.modelmgr.modelmgr import ModelManager
from langbot.pkg.provider.tools.loaders.mcp import MCPLoader
from langbot.pkg.rag.knowledge.kbmgr import RAGManager
from .resource_migration_support import TENANT_TABLES, create_legacy_resource_schema
@@ -54,6 +91,42 @@ def _get_script_head() -> str:
return ScriptDirectory.from_config(cfg).get_current_head()
async def _grant_runtime_role_business_objects(
conn: AsyncConnection,
role_name: str,
quote: typing.Callable[[str], str],
) -> None:
"""Mirror the release job's object ACLs without overgranting Alembic."""
business_tables = tuple(sorted({table.name for table in Base.metadata.tables.values()} | {'langbot_vectors'}))
quoted_tables = ', '.join(f'public.{quote(table_name)}' for table_name in business_tables)
await conn.execute(text(f'GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE {quoted_tables} TO {quote(role_name)}'))
await conn.execute(text(f'GRANT SELECT ON TABLE public.alembic_version TO {quote(role_name)}'))
sequence_query = text(
"""
SELECT DISTINCT sequence.relname
FROM pg_class sequence
JOIN pg_namespace sequence_namespace ON sequence_namespace.oid = sequence.relnamespace
JOIN pg_depend dependency
ON dependency.classid = 'pg_class'::regclass
AND dependency.objid = sequence.oid
AND dependency.refclassid = 'pg_class'::regclass
AND dependency.deptype IN ('a', 'i')
JOIN pg_class business_table ON business_table.oid = dependency.refobjid
JOIN pg_namespace table_namespace ON table_namespace.oid = business_table.relnamespace
WHERE sequence.relkind = 'S'
AND sequence_namespace.nspname = 'public'
AND table_namespace.nspname = 'public'
AND business_table.relname IN :table_names
ORDER BY sequence.relname
"""
).bindparams(sa.bindparam('table_names', expanding=True))
sequence_names = tuple((await conn.execute(sequence_query, {'table_names': business_tables})).scalars().all())
if sequence_names:
quoted_sequences = ', '.join(f'public.{quote(sequence_name)}' for sequence_name in sequence_names)
await conn.execute(text(f'GRANT USAGE, SELECT ON SEQUENCE {quoted_sequences} TO {quote(role_name)}'))
def _application_for_postgres_url(postgres_url: str, logger_name: str) -> SimpleNamespace:
url = sa.engine.make_url(postgres_url)
return SimpleNamespace(
@@ -115,15 +188,20 @@ async def postgres_engine(postgres_url):
@pytest.fixture
async def clean_tables(postgres_engine):
"""Drop all tables before and after each test for isolation."""
# Drop all tables before test
async with postgres_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
async def drop_all_tables() -> None:
# Alembic can create tables (notably langbot_vectors) outside the ORM
# metadata, and legacy migration tests intentionally alter constraints.
# Reflect the dedicated test schema instead of relying on stale ORM DDL.
async with postgres_engine.begin() as conn:
table_names = await conn.run_sync(lambda sync_conn: sa.inspect(sync_conn).get_table_names())
quote = postgres_engine.dialect.identifier_preparer.quote
for table_name in table_names:
await conn.execute(text(f'DROP TABLE {quote(table_name)} CASCADE'))
await drop_all_tables()
yield
# Drop all tables after test
async with postgres_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await drop_all_tables()
@pytest.fixture
@@ -442,10 +520,16 @@ class TestPostgreSQLResourceTenancyMigration:
await conn.execute(
text(
'INSERT INTO plugin_settings '
'(workspace_uuid, plugin_author, plugin_name, enabled) '
"VALUES (:workspace_uuid, 'author', 'plugin', true)"
'(workspace_uuid, plugin_author, plugin_name, enabled, '
'installation_uuid, artifact_digest, runtime_revision) '
"VALUES (:workspace_uuid, 'author', 'plugin', true, "
':installation_uuid, :artifact_digest, 1)'
),
{'workspace_uuid': second_workspace_uuid},
{
'workspace_uuid': second_workspace_uuid,
'installation_uuid': str(uuid.uuid4()),
'artifact_digest': hashlib.sha256(b'test-plugin-artifact').hexdigest(),
},
)
with pytest.raises(IntegrityError):
@@ -556,10 +640,7 @@ class TestPostgreSQLTenantRuntime:
)
)
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)}'))
await _grant_runtime_role_business_objects(conn, role_name, quote)
created_roles.append(role_name)
try:
@@ -703,6 +784,31 @@ class TestPostgreSQLTenantRuntime:
cloud_manager.create_tables.assert_not_awaited()
cloud_manager._run_alembic_migrations.assert_not_awaited()
with pytest.raises(TransactionRollbackOnlyError, match='after-commit work was cancelled'):
async with cloud_manager.tenant_uow(workspace_a):
duplicate_statement = sa.insert(WorkspaceMetadata).values(
workspace_uuid=workspace_a,
key='rollback-only-unique',
value='must-not-commit',
)
await cloud_manager.execute_async(duplicate_statement)
after_commit_gate = cloud_manager.create_after_commit_gate()
assert after_commit_gate is not None
try:
await cloud_manager.execute_async(duplicate_statement)
except IntegrityError:
pass
assert after_commit_gate.cancelled()
async with cloud_manager.tenant_uow(workspace_a):
assert (
await cloud_manager.execute_async(
sa.select(sa.func.count())
.select_from(WorkspaceMetadata)
.where(WorkspaceMetadata.key == 'rollback-only-unique')
)
).scalar_one() == 0
superuser_manager = PersistenceManager(
_application_for_postgres_url(postgres_url, 'postgres-superuser-runtime-test'),
mode=PersistenceMode.CLOUD_RUNTIME,
@@ -743,3 +849,644 @@ class TestPostgreSQLTenantRuntime:
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)}'))
@pytest.mark.asyncio
async def test_cloud_discovery_http_and_transaction_contract(
self,
postgres_url,
postgres_engine,
clean_tables,
clean_alembic_version,
monkeypatch,
):
"""Exercise the complete request/discovery path as a non-owner role."""
instance_uuid = 'cloud-request-rls-test'
other_instance_uuid = 'other-cloud-instance'
workspace_a = '31000000-0000-0000-0000-000000000001'
workspace_b = '32000000-0000-0000-0000-000000000002'
workspace_other = '33000000-0000-0000-0000-000000000003'
workspace_fenced = '34000000-0000-0000-0000-000000000004'
account_a_uuid = '41000000-0000-0000-0000-000000000001'
shared_account_uuid = '42000000-0000-0000-0000-000000000002'
active_secret = 'lbk_active-request-key'
revoked_secret = 'lbk_revoked-request-key'
expired_secret = 'lbk_expired-request-key'
role_name = f'lb_request_{uuid.uuid4().hex[:12]}'
role_password = f'Lb{uuid.uuid4().hex}'
quote = postgres_engine.dialect.identifier_preparer.quote
managers: list[PersistenceManager] = []
role_created = False
_restore_postgres_manager_registry(monkeypatch)
monkeypatch.setattr(constants, 'instance_id', instance_uuid)
release_manager = PersistenceManager(
_application_for_postgres_url(postgres_url, 'postgres-request-release-test'),
mode=PersistenceMode.RELEASE_MIGRATION,
)
managers.append(release_manager)
def role_url() -> str:
return (
sa.engine.make_url(postgres_url)
.set(username=role_name, password=role_password)
.render_as_string(hide_password=False)
)
async def seed_workspace(
workspace_uuid: str,
*,
target_instance: str,
state: str = 'active',
write_fenced: bool = False,
) -> None:
async with release_manager.tenant_uow(workspace_uuid) as uow:
uow.session.add(
Workspace(
uuid=workspace_uuid,
instance_uuid=target_instance,
name=workspace_uuid[-4:],
slug=f'workspace-{workspace_uuid[-4:]}',
type='team',
status='active',
source='cloud_projection',
projection_revision=1,
)
)
await uow.session.flush()
uow.session.add(
WorkspaceExecutionState(
workspace_uuid=workspace_uuid,
instance_uuid=target_instance,
active_generation=1,
state=state,
write_fenced=write_fenced,
source='cloud',
desired_state_revision=1,
)
)
uow.session.add(
WorkspaceMetadata(
workspace_uuid=workspace_uuid,
key='tenant-marker',
value=workspace_uuid,
)
)
try:
await release_manager.initialize()
async with release_manager.get_db_engine().begin() as conn:
await conn.execute(
sa.insert(User),
[
{
'uuid': account_a_uuid,
'user': 'account-a@example.com',
'normalized_email': 'account-a@example.com',
'password': 'closed-directory',
'account_type': 'local',
'status': 'active',
'source': 'cloud_projection',
'projection_revision': 1,
},
{
'uuid': shared_account_uuid,
'user': 'shared@example.com',
'normalized_email': 'shared@example.com',
'password': 'closed-directory',
'account_type': 'local',
'status': 'active',
'source': 'cloud_projection',
'projection_revision': 1,
},
],
)
await seed_workspace(workspace_a, target_instance=instance_uuid)
await seed_workspace(workspace_b, target_instance=instance_uuid)
await seed_workspace(workspace_other, target_instance=other_instance_uuid)
await seed_workspace(
workspace_fenced,
target_instance=instance_uuid,
write_fenced=True,
)
async with release_manager.tenant_uow(workspace_a) as uow:
uow.session.add_all(
[
WorkspaceMembership(
uuid=str(uuid.uuid4()),
workspace_uuid=workspace_a,
account_uuid=account_a_uuid,
role='owner',
status='active',
projection_revision=1,
),
WorkspaceMembership(
uuid=str(uuid.uuid4()),
workspace_uuid=workspace_a,
account_uuid=shared_account_uuid,
role='viewer',
status='active',
projection_revision=1,
),
ApiKey(
uuid=str(uuid.uuid4()),
workspace_uuid=workspace_a,
name='active-key',
key_hash=hashlib.sha256(active_secret.encode()).hexdigest(),
scopes=[Permission.WORKSPACE_VIEW.value],
status='active',
),
]
)
async with release_manager.tenant_uow(workspace_b) as uow:
uow.session.add_all(
[
WorkspaceMembership(
uuid=str(uuid.uuid4()),
workspace_uuid=workspace_b,
account_uuid=shared_account_uuid,
role='viewer',
status='active',
projection_revision=1,
),
ApiKey(
uuid=str(uuid.uuid4()),
workspace_uuid=workspace_b,
name='revoked-key',
key_hash=hashlib.sha256(revoked_secret.encode()).hexdigest(),
scopes=[Permission.WORKSPACE_VIEW.value],
status='revoked',
),
ApiKey(
uuid=str(uuid.uuid4()),
workspace_uuid=workspace_b,
name='expired-key',
key_hash=hashlib.sha256(expired_secret.encode()).hexdigest(),
scopes=[Permission.WORKSPACE_VIEW.value],
status='active',
expires_at=datetime.datetime.now(datetime.UTC).replace(tzinfo=None)
- datetime.timedelta(minutes=1),
),
]
)
async with postgres_engine.connect() as conn:
await conn.execute(text(f"CREATE ROLE {quote(role_name)} LOGIN PASSWORD '{role_password}'"))
role_created = True
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 _grant_runtime_role_business_objects(conn, role_name, quote)
runtime_application = _application_for_postgres_url(role_url(), 'postgres-request-runtime-test')
runtime_application.instance_config.data.update(
{
'system': {
'jwt': {'secret': 'postgres-request-jwt', 'expire': 3600},
},
'api': {'global_api_key': ''},
}
)
runtime_application.logger = logging.getLogger('postgres-request-runtime-test')
cloud_manager = PersistenceManager(runtime_application, mode=PersistenceMode.CLOUD_RUNTIME)
managers.append(cloud_manager)
await cloud_manager.initialize()
runtime_application.persistence_mgr = cloud_manager
cloud_manager.ap = runtime_application
runtime_application.workspace_service = WorkspaceService(
runtime_application,
policy=CloudWorkspacePolicy(),
instance_uuid=instance_uuid,
)
runtime_application.workspace_collaboration_service = WorkspaceCollaborationService(
runtime_application,
runtime_application.workspace_service,
policy=CloudWorkspacePolicy(),
)
runtime_application.user_service = UserService(runtime_application)
runtime_application.apikey_service = ApiKeyService(runtime_application)
runtime_application.monitoring_service = MonitoringService(runtime_application)
bindings = await runtime_application.workspace_service.list_active_execution_bindings()
assert {binding.workspace_uuid for binding in bindings} == {workspace_a, workspace_b}
# No scope is an application error before SQL reaches PostgreSQL.
with pytest.raises(RuntimeError, match='explicit Workspace or discovery'):
await cloud_manager.execute_async(sa.select(WorkspaceMetadata))
# Discovery exposes only its index rows and cannot write.
with pytest.raises(TransactionRollbackOnlyError, match='transaction was rolled back'):
async with cloud_manager.account_discovery_uow(shared_account_uuid) as discovery:
assert set(
(
await discovery.session.scalars(
sa.select(WorkspaceMembership.workspace_uuid).order_by(
WorkspaceMembership.workspace_uuid
)
)
).all()
) == {workspace_a, workspace_b}
with pytest.raises(sa.exc.DBAPIError):
await discovery.session.execute(
sa.insert(WorkspaceMembership).values(
uuid=str(uuid.uuid4()),
workspace_uuid=workspace_a,
account_uuid=shared_account_uuid,
role='viewer',
status='active',
projection_revision=1,
)
)
async with cloud_manager.api_key_discovery_uow(
hashlib.sha256(active_secret.encode()).hexdigest()
) as discovery:
assert await discovery.session.scalar(sa.select(ApiKey.workspace_uuid)) == workspace_a
update_result = await discovery.session.execute(
sa.update(ApiKey)
.where(ApiKey.key_hash == hashlib.sha256(active_secret.encode()).hexdigest())
.values(name='discovery-must-not-write')
)
assert update_result.rowcount == 0
async with cloud_manager.api_key_discovery_uow(
hashlib.sha256(revoked_secret.encode()).hexdigest()
) as discovery:
assert await discovery.session.scalar(sa.select(ApiKey.workspace_uuid)) is None
async with cloud_manager.api_key_discovery_uow(
hashlib.sha256(expired_secret.encode()).hexdigest()
) as discovery:
assert await discovery.session.scalar(sa.select(ApiKey.workspace_uuid)) is None
async with cloud_manager.instance_discovery_uow(instance_uuid) as discovery:
assert set(
(await discovery.session.scalars(sa.select(WorkspaceExecutionState.workspace_uuid))).all()
) == {workspace_a, workspace_b}
update_result = await discovery.session.execute(
sa.update(WorkspaceExecutionState)
.where(WorkspaceExecutionState.workspace_uuid == workspace_a)
.values(write_fenced=True)
)
assert update_result.rowcount == 0
# Instance discovery deliberately cannot see business rows.
assert (await discovery.session.execute(sa.select(WorkspaceMetadata))).all() == []
platform_manager = PlatformManager(runtime_application)
platform_manager._load_workspace_bots = AsyncMock()
await platform_manager.load_bots_from_db()
assert {call.args[0] for call in platform_manager._load_workspace_bots.await_args_list} == {
workspace_a,
workspace_b,
}
# Every startup cache loader traverses the instance index first,
# then reads business resources in one tenant transaction at a time.
await ModelManager(runtime_application).load_models_from_db()
await PipelineManager(runtime_application).load_pipelines_from_db()
await MCPLoader(runtime_application).load_mcp_servers_from_db()
await RAGManager(runtime_application).load_knowledge_bases_from_db()
# An omitted Workspace predicate remains isolated by RLS.
async with cloud_manager.tenant_uow(workspace_a):
values = (await cloud_manager.execute_async(sa.select(WorkspaceMetadata.value))).scalars().all()
assert values == [workspace_a]
async def read_tenant_repeatedly(workspace_uuid: str) -> list[str]:
observed: list[str] = []
for _ in range(5):
async with cloud_manager.tenant_uow(workspace_uuid):
observed.extend(
(await cloud_manager.execute_async(sa.select(WorkspaceMetadata.value))).scalars().all()
)
await asyncio.sleep(0)
return observed
observed_a, observed_b = await asyncio.gather(
read_tenant_repeatedly(workspace_a),
read_tenant_repeatedly(workspace_b),
)
assert observed_a == [workspace_a] * 5
assert observed_b == [workspace_b] * 5
accesses = await runtime_application.workspace_collaboration_service.list_account_workspaces(
shared_account_uuid
)
assert {access.workspace.uuid for access in accesses} == {workspace_a, workspace_b}
assert await runtime_application.apikey_service.authenticate_api_key(revoked_secret) is None
assert await runtime_application.apikey_service.authenticate_api_key(expired_secret) is None
active_identity = await runtime_application.apikey_service.authenticate_api_key(active_secret)
assert active_identity is not None
assert active_identity.workspace_uuid == workspace_a
async def record_feedback(feedback_type: int) -> str | None:
async with cloud_manager.tenant_scope(workspace_a):
return await runtime_application.monitoring_service.record_feedback(
ExecutionContext(
instance_uuid=instance_uuid,
workspace_uuid=workspace_a,
placement_generation=1,
),
feedback_id='concurrent-feedback',
feedback_type=feedback_type,
)
feedback_ids = await asyncio.gather(record_feedback(1), record_feedback(2))
assert feedback_ids[0] == feedback_ids[1]
async with cloud_manager.tenant_uow(workspace_a) as uow:
feedback_rows = (
(
await uow.execute(
sa.select(MonitoringFeedback).where(MonitoringFeedback.feedback_id == 'concurrent-feedback')
)
)
.scalars()
.all()
)
assert len(feedback_rows) == 1
assert feedback_rows[0].feedback_type in {1, 2}
await uow.execute(
sa.delete(MonitoringFeedback).where(MonitoringFeedback.feedback_id == 'concurrent-feedback')
)
class TenantRuntimeRouter(http_group.RouterGroup):
name = 'postgres-tenant-runtime'
path = '/tenant-runtime'
async def initialize(self) -> None:
@self.route('/account', permission=Permission.WORKSPACE_VIEW)
async def account_route(request_context: RequestContext):
assert self.ap.persistence_mgr.current_session() is None
if self.quart_app.config.get('FORCE_HANDLER_FAILURE'):
async with self.ap.persistence_mgr.tenant_uow(request_context.workspace_uuid):
await self.ap.persistence_mgr.execute_async(
sa.insert(WorkspaceMetadata).values(
workspace_uuid=request_context.workspace_uuid,
key='rolled-back-handler',
value='must-not-commit',
)
)
raise RuntimeError('forced handler failure')
values = (
(await self.ap.persistence_mgr.execute_async(sa.select(WorkspaceMetadata.value)))
.scalars()
.all()
)
assert self.ap.persistence_mgr.current_session() is None
return self.success(data={'workspace_uuid': request_context.workspace_uuid, 'values': values})
@self.route('/key', auth_type=http_group.AuthType.API_KEY)
async def key_route(request_context: RequestContext):
values = (
(await self.ap.persistence_mgr.execute_async(sa.select(WorkspaceMetadata.value)))
.scalars()
.all()
)
return self.success(data={'workspace_uuid': request_context.workspace_uuid, 'values': values})
@self.route('/bootstrap', auth_type=http_group.AuthType.ACCOUNT_TOKEN)
async def bootstrap_route(user_email: str):
account = await self.ap.user_service.get_user_by_email(user_email)
accesses = await self.ap.workspace_collaboration_service.list_account_workspaces(account.uuid)
return self.success(data=sorted(access.workspace.uuid for access in accesses))
quart_app = Quart(__name__)
await TenantRuntimeRouter(runtime_application, quart_app).initialize()
webhook_bot_uuid = str(uuid.uuid4())
class TenantAwareWebhookAdapter:
async def handle_unified_webhook(self, **_kwargs):
assert cloud_manager.current_session() is None
await asyncio.sleep(0)
assert cloud_manager.current_session() is None
values = (
(
await cloud_manager.execute_async(
sa.select(WorkspaceMetadata.value).where(WorkspaceMetadata.key == 'tenant-marker')
)
)
.scalars()
.all()
)
assert cloud_manager.current_session() is None
return {'values': values}
runtime_application.platform_mgr = SimpleNamespace(
resolve_public_bot=AsyncMock(
return_value=SimpleNamespace(
workspace_uuid=workspace_a,
placement_generation=1,
enable=True,
adapter=TenantAwareWebhookAdapter(),
)
)
)
await WebhookRouterGroup(runtime_application, quart_app).initialize()
await SystemRouterGroup(runtime_application, quart_app).initialize()
client = quart_app.test_client()
account_a = await runtime_application.user_service.get_user_by_uuid(account_a_uuid)
shared_account = await runtime_application.user_service.get_user_by_uuid(shared_account_uuid)
assert account_a is not None and shared_account is not None
account_token = await runtime_application.user_service.generate_jwt_token(account_a)
shared_token = await runtime_application.user_service.generate_jwt_token(shared_account)
async with cloud_manager.tenant_uow(workspace_a):
await cloud_manager.execute_async(
sa.insert(WorkspaceMetadata).values(
workspace_uuid=workspace_a,
key='wizard_status',
value='completed',
)
)
response = await client.get(
'/api/v1/system/info',
headers={
'Authorization': f'Bearer {account_token}',
'X-Workspace-Id': workspace_a,
},
)
assert response.status_code == 200
assert (await response.get_json())['data']['wizard_status'] == 'completed'
async with cloud_manager.tenant_uow(workspace_a):
await cloud_manager.execute_async(
sa.delete(WorkspaceMetadata).where(
WorkspaceMetadata.workspace_uuid == workspace_a,
WorkspaceMetadata.key == 'wizard_status',
)
)
response = await client.post(f'/bots/{webhook_bot_uuid}')
assert response.status_code == 200
assert await response.get_json() == {'values': [workspace_a]}
response = await client.get(
'/tenant-runtime/account',
headers={
'Authorization': f'Bearer {account_token}',
'X-Workspace-Id': workspace_a,
},
)
assert response.status_code == 200
assert (await response.get_json())['data'] == {
'workspace_uuid': workspace_a,
'values': [workspace_a],
}
response = await client.get(
'/tenant-runtime/account',
headers={
'Authorization': f'Bearer {account_token}',
'X-Workspace-Id': workspace_b,
},
)
assert response.status_code == 404
response = await client.get(
'/tenant-runtime/account',
headers={'Authorization': f'Bearer {shared_token}'},
)
assert response.status_code == 404
response = await client.get(
'/tenant-runtime/bootstrap',
headers={'Authorization': f'Bearer {shared_token}'},
)
assert response.status_code == 200
assert (await response.get_json())['data'] == [workspace_a, workspace_b]
response = await client.get(
'/tenant-runtime/key',
headers={'X-API-Key': active_secret, 'X-Workspace-Id': workspace_b},
)
assert response.status_code == 200
assert (await response.get_json())['data'] == {
'workspace_uuid': workspace_a,
'values': [workspace_a],
}
# The parallel MCP ASGI entrypoint authenticates the same key and
# retains only a trusted Workspace scope for the full tool request.
# Each DB call gets its own short RLS transaction.
mcp_observation: dict[str, typing.Any] = {}
async def fake_mcp_asgi(scope, receive, send):
del scope, receive
context = get_mcp_request_context()
assert cloud_manager.current_session() is None
values = (await cloud_manager.execute_async(sa.select(WorkspaceMetadata.value))).scalars().all()
assert cloud_manager.current_session() is None
await asyncio.sleep(0)
assert cloud_manager.current_session() is None
repeated_values = (
(await cloud_manager.execute_async(sa.select(WorkspaceMetadata.value))).scalars().all()
)
assert repeated_values == values
assert cloud_manager.current_session() is None
mcp_observation.update(
workspace_uuid=context.workspace_uuid,
values=values,
)
await send({'type': 'http.response.start', 'status': 200, 'headers': []})
await send({'type': 'http.response.body', 'body': b'{}'})
async def unused_quart_asgi(scope, receive, send): # pragma: no cover - routing assertion
del scope, receive, send
raise AssertionError('MCP request was routed to Quart')
mount = MCPMount.__new__(MCPMount)
mount.ap = runtime_application
mount._mcp_asgi = fake_mcp_asgi
sent_messages: list[dict[str, typing.Any]] = []
async def receive():
return {'type': 'http.request', 'body': b'', 'more_body': False}
async def send(message):
sent_messages.append(message)
await mount.wrap(unused_quart_asgi)(
{
'type': 'http',
'path': '/mcp',
'headers': [
(b'x-api-key', active_secret.encode()),
(b'x-workspace-id', workspace_b.encode()),
],
},
receive,
send,
)
assert sent_messages[0]['status'] == 200
assert mcp_observation == {'workspace_uuid': workspace_a, 'values': [workspace_a]}
original_api_key_discovery = cloud_manager.api_key_discovery_uow
@contextlib.asynccontextmanager
async def revoke_after_discovery(key_hash: str):
async with original_api_key_discovery(key_hash) as discovery:
yield discovery
async with cloud_manager.tenant_uow(workspace_a):
await cloud_manager.execute_async(
sa.update(ApiKey).where(ApiKey.key_hash == key_hash).values(status='revoked')
)
monkeypatch.setattr(cloud_manager, 'api_key_discovery_uow', revoke_after_discovery)
assert await runtime_application.apikey_service.authenticate_api_key(active_secret) is None
monkeypatch.setattr(cloud_manager, 'api_key_discovery_uow', original_api_key_discovery)
quart_app.config['FORCE_HANDLER_FAILURE'] = True
response = await client.get(
'/tenant-runtime/account',
headers={
'Authorization': f'Bearer {account_token}',
'X-Workspace-Id': workspace_a,
},
)
assert response.status_code == 500
quart_app.config['FORCE_HANDLER_FAILURE'] = False
async with cloud_manager.tenant_uow(workspace_a):
assert (
await cloud_manager.execute_async(
sa.select(sa.func.count())
.select_from(WorkspaceMetadata)
.where(WorkspaceMetadata.key == 'rolled-back-handler')
)
).scalar_one() == 0
# Transaction-local settings are gone when pooled connections are reused.
async with cloud_manager.get_db_engine().connect() as conn:
assert await conn.scalar(text("SELECT current_setting('langbot.workspace_uuid', true)")) in (
None,
'',
)
assert await conn.scalar(text('SELECT COUNT(*) FROM workspace_metadata')) == 0
# Runtime validation rejects both extra permissive policies and a
# modified expression even if the expected policy name remains.
async with postgres_engine.connect() as conn:
await conn.execute(text('CREATE POLICY injected_policy ON bots FOR SELECT USING (true)'))
with pytest.raises(RuntimeError, match='policy set does not match'):
await cloud_manager._validate_postgres_tenant_schema(validate_runtime_role=True)
async with postgres_engine.connect() as conn:
await conn.execute(text('DROP POLICY injected_policy ON bots'))
await conn.execute(text('DROP POLICY langbot_workspace_isolation ON workspace_metadata'))
await conn.execute(
text(
'CREATE POLICY langbot_workspace_isolation ON workspace_metadata '
'FOR ALL TO PUBLIC USING (true) WITH CHECK (true)'
)
)
with pytest.raises(RuntimeError, match='policy definitions are invalid'):
await cloud_manager._validate_postgres_tenant_schema(validate_runtime_role=True)
finally:
for manager in reversed(managers):
await _dispose_manager(manager)
if role_created:
async with postgres_engine.connect() as conn:
await conn.execute(text(f'DROP OWNED BY {quote(role_name)}'))
await conn.execute(text(f'DROP ROLE IF EXISTS {quote(role_name)}'))
@@ -0,0 +1,546 @@
"""Real PostgreSQL/pgvector tenant isolation and CRUD verification."""
from __future__ import annotations
import logging
import os
import uuid
from types import SimpleNamespace
import pytest
import sqlalchemy as sa
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from langbot.pkg.entity.persistence.base import Base
from langbot.pkg.persistence.alembic_runner import get_alembic_current, run_alembic_stamp, run_alembic_upgrade
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
from langbot.pkg.utils import constants
from langbot.pkg.vector.vdbs.pgvector_db import PgVectorDatabase, PgVectorScope
pytestmark = [pytest.mark.integration, pytest.mark.slow, pytest.mark.asyncio]
def _application(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),
)
def _restore_postgres_registry(monkeypatch) -> None:
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],
)
@pytest.fixture
def postgres_url() -> str:
url = os.environ.get('TEST_POSTGRES_URL')
if not url:
pytest.skip('TEST_POSTGRES_URL not set')
return url
@pytest.fixture
async def postgres_engine(postgres_url: str):
engine = create_async_engine(postgres_url, isolation_level='AUTOCOMMIT')
yield engine
await engine.dispose()
@pytest.fixture
async def clean_database(postgres_engine: AsyncEngine):
async def clean() -> None:
async with postgres_engine.begin() as conn:
await conn.execute(text('DROP TABLE IF EXISTS langbot_vectors_legacy_0013 CASCADE'))
await conn.execute(text('DROP TABLE IF EXISTS langbot_vectors CASCADE'))
await conn.run_sync(Base.metadata.drop_all)
await conn.execute(text('DROP TABLE IF EXISTS alembic_version'))
await clean()
yield
await clean()
async def test_legacy_upgrade_temporarily_suspends_and_restores_source_rls_for_unprivileged_owner(
postgres_url: str,
postgres_engine: AsyncEngine,
clean_database,
) -> None:
workspace_uuid = '30000000-0000-0000-0000-000000000303'
knowledge_base_uuid = 'legacy-knowledge-base'
migrator_role = f'lb_vector_migrator_{uuid.uuid4().hex[:12]}'
migrator_password = f'Lb{uuid.uuid4().hex}'
quote = postgres_engine.dialect.identifier_preparer.quote
database_name = sa.engine.make_url(postgres_url).database
migrator_url = (
sa.engine.make_url(postgres_url)
.set(username=migrator_role, password=migrator_password)
.render_as_string(hide_password=False)
)
migrator_engine: AsyncEngine | None = None
role_created = False
admin_role: str | None = None
source_tables = ('knowledge_bases', 'knowledge_base_files', 'knowledge_base_chunks')
async def read_rls_states(conn, table_names: tuple[str, ...]) -> dict[str, tuple[bool, bool]]:
rows = (
(
await conn.execute(
text(
"""
SELECT c.relname, c.relrowsecurity, c.relforcerowsecurity
FROM pg_class AS c
JOIN pg_namespace AS n ON n.oid = c.relnamespace
WHERE n.nspname = current_schema()
AND c.relname IN :table_names
"""
).bindparams(sa.bindparam('table_names', expanding=True)),
{'table_names': table_names},
)
)
.mappings()
.all()
)
return {str(row['relname']): (bool(row['relrowsecurity']), bool(row['relforcerowsecurity'])) for row in rows}
try:
async with postgres_engine.begin() as conn:
admin_role = await conn.scalar(text('SELECT current_user'))
await conn.execute(text('CREATE EXTENSION IF NOT EXISTS vector'))
await conn.run_sync(Base.metadata.create_all)
await conn.execute(
text(
'ALTER TABLE knowledge_bases DROP CONSTRAINT IF EXISTS '
'ck_knowledge_bases_embedding_dimension_positive'
)
)
await conn.execute(text('ALTER TABLE knowledge_bases DROP COLUMN IF EXISTS embedding_dimension'))
await run_alembic_stamp(postgres_engine, '0010_scope_resources')
await run_alembic_upgrade(postgres_engine, '0012_plugin_identity')
assert await get_alembic_current(postgres_engine) == '0012_plugin_identity'
embedding_a = '[' + ','.join(['0.125'] * 384) + ']'
embedding_b = '[' + ','.join(['0.25'] * 384) + ']'
async with postgres_engine.begin() as conn:
await conn.execute(
text(
"""
INSERT INTO workspaces
(uuid, instance_uuid, name, slug, type, status, source, projection_revision)
VALUES
(:uuid, 'legacy-vector-instance', 'legacy', 'legacy-vector',
'team', 'active', 'cloud_projection', 0)
"""
),
{'uuid': workspace_uuid},
)
await conn.execute(
text(
"""
INSERT INTO knowledge_bases
(uuid, workspace_uuid, name, collection_id, legacy_vector_collection)
VALUES
(:uuid, :workspace_uuid, 'legacy', 'legacy-collection', true)
"""
),
{'uuid': knowledge_base_uuid, 'workspace_uuid': workspace_uuid},
)
await conn.execute(
text(
"""
INSERT INTO knowledge_base_files
(uuid, workspace_uuid, kb_id, file_name, extension, status)
VALUES
('legacy-file', :workspace_uuid, :kb_uuid, 'legacy.txt', 'txt', 'completed')
"""
),
{'workspace_uuid': workspace_uuid, 'kb_uuid': knowledge_base_uuid},
)
await conn.execute(
text(
"""
INSERT INTO knowledge_base_chunks (uuid, workspace_uuid, file_id, text)
VALUES ('legacy-chunk', :workspace_uuid, 'legacy-file', 'chunk text')
"""
),
{'workspace_uuid': workspace_uuid},
)
await conn.execute(
text(
"""
CREATE TABLE langbot_vectors (
id VARCHAR(255) PRIMARY KEY,
collection VARCHAR(255),
embedding vector NOT NULL,
text TEXT,
file_id VARCHAR(255),
chunk_uuid VARCHAR(255)
)
"""
)
)
await conn.execute(
text(
"""
INSERT INTO langbot_vectors
(id, collection, embedding, text, file_id, chunk_uuid)
VALUES
('legacy-by-collection', 'legacy-collection', CAST(:embedding_a AS vector),
'collection row', NULL, NULL),
('legacy-by-chunk', 'unmatched-collection', CAST(:embedding_b AS vector),
'chunk row', NULL, 'legacy-chunk')
"""
),
{'embedding_a': embedding_a, 'embedding_b': embedding_b},
)
# Exercise exact restoration rather than assuming all source tables
# arrived with identical flags.
await conn.execute(text('ALTER TABLE knowledge_base_files NO FORCE ROW LEVEL SECURITY'))
await conn.execute(text('ALTER TABLE knowledge_base_chunks NO FORCE ROW LEVEL SECURITY'))
await conn.execute(text('ALTER TABLE knowledge_base_chunks DISABLE ROW LEVEL SECURITY'))
expected_source_rls = await read_rls_states(conn, source_tables)
assert expected_source_rls == {
'knowledge_bases': (True, True),
'knowledge_base_files': (True, False),
'knowledge_base_chunks': (False, False),
}
await conn.execute(
text(f"CREATE ROLE {quote(migrator_role)} LOGIN PASSWORD '{migrator_password}' NOSUPERUSER NOBYPASSRLS")
)
role_created = True
await conn.execute(text(f'GRANT CONNECT ON DATABASE {quote(database_name)} TO {quote(migrator_role)}'))
await conn.execute(text(f'GRANT USAGE, CREATE ON SCHEMA public TO {quote(migrator_role)}'))
await conn.execute(text(f'GRANT SELECT, UPDATE ON alembic_version TO {quote(migrator_role)}'))
for table_name in (*source_tables, 'langbot_vectors'):
await conn.execute(text(f'ALTER TABLE {quote(table_name)} OWNER TO {quote(migrator_role)}'))
role = (
await conn.execute(
text('SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = :role'),
{'role': migrator_role},
)
).one()
assert role == (False, False)
owned_tables = set(
(
await conn.execute(
text(
"""
SELECT c.relname
FROM pg_class AS c
JOIN pg_namespace AS n ON n.oid = c.relnamespace
WHERE n.nspname = current_schema()
AND c.relname IN :table_names
AND pg_get_userbyid(c.relowner) = :role
"""
).bindparams(sa.bindparam('table_names', expanding=True)),
{'table_names': (*source_tables, 'langbot_vectors'), 'role': migrator_role},
)
).scalars()
)
assert owned_tables == {*source_tables, 'langbot_vectors'}
migrator_engine = create_async_engine(migrator_url)
await run_alembic_upgrade(migrator_engine, '0013_tenant_pgvector')
assert await get_alembic_current(migrator_engine) == '0013_tenant_pgvector'
async with postgres_engine.connect() as conn:
migrated_rows = (
(
await conn.execute(
text(
"""
SELECT workspace_uuid, knowledge_base_uuid, vector_id,
embedding_dimension, text, file_id, chunk_uuid
FROM langbot_vectors
ORDER BY vector_id
"""
)
)
)
.mappings()
.all()
)
assert [dict(row) for row in migrated_rows] == [
{
'workspace_uuid': workspace_uuid,
'knowledge_base_uuid': knowledge_base_uuid,
'vector_id': 'legacy-by-chunk',
'embedding_dimension': 384,
'text': 'chunk row',
'file_id': None,
'chunk_uuid': 'legacy-chunk',
},
{
'workspace_uuid': workspace_uuid,
'knowledge_base_uuid': knowledge_base_uuid,
'vector_id': 'legacy-by-collection',
'embedding_dimension': 384,
'text': 'collection row',
'file_id': None,
'chunk_uuid': None,
},
]
assert (
await conn.scalar(
text('SELECT embedding_dimension FROM knowledge_bases WHERE uuid = :uuid'),
{'uuid': knowledge_base_uuid},
)
== 384
)
assert await conn.scalar(text("SELECT to_regclass('langbot_vectors_legacy_0013') IS NULL")) is True
assert await read_rls_states(conn, source_tables) == expected_source_rls
assert await read_rls_states(conn, ('langbot_vectors',)) == {'langbot_vectors': (True, True)}
assert (
await conn.scalar(
text(
"""
SELECT COUNT(*)
FROM pg_policy AS p
JOIN pg_class AS c ON c.oid = p.polrelid
JOIN pg_namespace AS n ON n.oid = c.relnamespace
WHERE n.nspname = current_schema()
AND c.relname = 'langbot_vectors'
AND p.polname = 'langbot_workspace_isolation'
"""
)
)
== 1
)
async with migrator_engine.connect() as conn:
assert await conn.scalar(text('SELECT COUNT(*) FROM langbot_vectors')) == 0
async with migrator_engine.begin() as conn:
await conn.execute(
text("SELECT set_config('langbot.workspace_uuid', :workspace_uuid, true)"),
{'workspace_uuid': workspace_uuid},
)
assert await conn.scalar(text('SELECT COUNT(*) FROM langbot_vectors')) == 2
finally:
if migrator_engine is not None:
await migrator_engine.dispose()
if role_created:
async with postgres_engine.connect() as conn:
if admin_role is None: # pragma: no cover - setup cannot create the role without an admin
admin_role = await conn.scalar(text('SELECT current_user'))
await conn.execute(text(f'REASSIGN OWNED BY {quote(migrator_role)} TO {quote(admin_role)}'))
await conn.execute(text(f'DROP OWNED BY {quote(migrator_role)}'))
await conn.execute(text(f'DROP ROLE IF EXISTS {quote(migrator_role)}'))
async def test_pgvector_shared_database_is_scoped_indexed_and_ddl_free_at_runtime(
postgres_url: str,
postgres_engine: AsyncEngine,
clean_database,
monkeypatch,
) -> None:
instance_uuid = 'pgvector-tenant-integration'
workspace_a = '10000000-0000-0000-0000-000000000101'
workspace_b = '20000000-0000-0000-0000-000000000202'
kb_a = 'knowledge-base-a'
kb_b = 'knowledge-base-b'
role_suffix = uuid.uuid4().hex[:12]
runtime_role = f'lb_vector_runtime_{role_suffix}'
role_password = f'Lb{uuid.uuid4().hex}'
release_manager: PersistenceManager | None = None
runtime_manager: PersistenceManager | None = None
role_created = False
_restore_postgres_registry(monkeypatch)
monkeypatch.setattr(constants, 'instance_id', instance_uuid)
quote = postgres_engine.dialect.identifier_preparer.quote
database_name = sa.engine.make_url(postgres_url).database
runtime_url = (
sa.engine.make_url(postgres_url)
.set(username=runtime_role, password=role_password)
.render_as_string(hide_password=False)
)
try:
release_app = _application(postgres_url, 'pgvector-release-migration-test')
release_manager = PersistenceManager(release_app, mode=PersistenceMode.RELEASE_MIGRATION)
release_app.persistence_mgr = release_manager
await release_manager.initialize()
for workspace_uuid, kb_uuid, slug in (
(workspace_a, kb_a, 'vector-a'),
(workspace_b, kb_b, 'vector-b'),
):
async with release_manager.tenant_uow(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 knowledge_bases
(uuid, workspace_uuid, name, embedding_dimension)
VALUES (:uuid, :workspace_uuid, :name, 384)
"""
),
{'uuid': kb_uuid, 'workspace_uuid': workspace_uuid, 'name': slug},
)
async with postgres_engine.connect() as conn:
await conn.execute(text(f"CREATE ROLE {quote(runtime_role)} LOGIN PASSWORD '{role_password}'"))
await conn.execute(text(f'GRANT CONNECT ON DATABASE {quote(database_name)} TO {quote(runtime_role)}'))
await conn.execute(text(f'GRANT USAGE ON SCHEMA public TO {quote(runtime_role)}'))
business_tables = release_manager._runtime_business_table_names()
quoted_tables = ', '.join(f'public.{quote(table_name)}' for table_name in business_tables)
await conn.execute(
text(f'GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE {quoted_tables} TO {quote(runtime_role)}')
)
await conn.execute(text(f'GRANT SELECT ON TABLE public.alembic_version TO {quote(runtime_role)}'))
sequence_names = await release_manager._runtime_business_sequence_names(conn, business_tables)
if sequence_names:
quoted_sequences = ', '.join(f'public.{quote(sequence_name)}' for sequence_name in sequence_names)
await conn.execute(text(f'GRANT USAGE, SELECT ON SEQUENCE {quoted_sequences} TO {quote(runtime_role)}'))
role_created = True
runtime_app = _application(runtime_url, 'pgvector-runtime-test')
runtime_manager = PersistenceManager(runtime_app, mode=PersistenceMode.CLOUD_RUNTIME)
runtime_app.persistence_mgr = runtime_manager
await runtime_manager.initialize()
adapter = PgVectorDatabase(
runtime_app,
use_business_database=True,
allowed_dimensions=[384],
)
scope_a = PgVectorScope(workspace_a, kb_a, 384)
scope_b = PgVectorScope(workspace_b, kb_b, 384)
# The same vector ID is valid in two Workspaces because the relational
# primary key includes Workspace and knowledge base.
await adapter.add_embeddings(
'opaque-a',
['same-vector'],
[[0.1] * 384],
[{'text': 'workspace-a', 'file_id': 'file-a', 'uuid': 'chunk-a'}],
scope=scope_a,
)
await adapter.add_embeddings(
'opaque-b',
['same-vector'],
[[0.2] * 384],
[{'text': 'workspace-b', 'file_id': 'file-b', 'uuid': 'chunk-b'}],
scope=scope_b,
)
result_a = await adapter.search('opaque-a', [0.1] * 384, scope=scope_a)
result_b = await adapter.search('opaque-b', [0.2] * 384, scope=scope_b)
assert result_a['metadatas'][0][0]['text'] == 'workspace-a'
assert result_b['metadatas'][0][0]['text'] == 'workspace-b'
# Guessing another knowledge-base UUID while retaining A's Workspace
# cannot escape either the explicit conditions or PostgreSQL RLS.
guessed = await adapter.search(
'attacker-controlled-name',
[0.2] * 384,
scope=PgVectorScope(workspace_a, kb_b, 384),
)
assert guessed['ids'] == [[]]
with pytest.raises(ValueError, match='trusted PgVectorScope'):
await adapter.search('opaque-a', [0.1] * 384)
with pytest.raises(ValueError, match='selected dimension'):
await adapter.add_embeddings(
'opaque-a',
['bad-dimension'],
[[0.1] * 383],
[{}],
scope=scope_a,
)
# Deliberately omit the application Workspace predicate. FORCE RLS is
# still the second isolation boundary and returns only A.
async with runtime_manager.tenant_uow(workspace_a) as uow:
rows = (
await uow.execute(
text("SELECT workspace_uuid, vector_id FROM langbot_vectors WHERE vector_id = 'same-vector'")
)
).all()
assert rows == [(workspace_a, 'same-vector')]
await uow.execute(text('SET LOCAL enable_seqscan = off'))
plan = '\n'.join(
(
await uow.execute(
text(
"""
EXPLAIN SELECT vector_id
FROM langbot_vectors
WHERE workspace_uuid = :workspace_uuid
AND knowledge_base_uuid = :knowledge_base_uuid
AND embedding_dimension = 384
ORDER BY (embedding::vector(384)) <=> CAST(:query AS vector(384))
LIMIT 5
"""
),
{
'workspace_uuid': workspace_a,
'knowledge_base_uuid': kb_a,
'query': '[' + ','.join(['0.1'] * 384) + ']',
},
)
).scalars()
)
assert 'ix_langbot_vectors_hnsw_cosine_384' in plan
runtime_engine = runtime_manager.get_db_engine()
async with runtime_engine.connect() as conn:
assert await conn.scalar(text('SELECT COUNT(*) FROM langbot_vectors')) == 0
assert await conn.scalar(text("SELECT current_setting('langbot.workspace_uuid', true)")) in (None, '')
items_a, total_a = await adapter.list_by_filter('opaque-a', scope=scope_a)
assert total_a == 1
assert items_a[0]['metadata']['file_id'] == 'file-a'
await adapter.delete_by_file_id('opaque-a', 'file-a', scope=scope_a)
assert (await adapter.list_by_filter('opaque-a', scope=scope_a))[1] == 0
assert (await adapter.list_by_filter('opaque-b', scope=scope_b))[1] == 1
finally:
if runtime_manager is not None and getattr(runtime_manager, 'db', None) is not None:
await runtime_manager.get_db_engine().dispose()
if release_manager is not None and getattr(release_manager, 'db', None) is not None:
await release_manager.get_db_engine().dispose()
if role_created:
async with postgres_engine.connect() as conn:
await conn.execute(text(f'DROP OWNED BY {quote(runtime_role)}'))
await conn.execute(text(f'DROP ROLE IF EXISTS {quote(runtime_role)}'))
@@ -0,0 +1,94 @@
from __future__ import annotations
import hashlib
import uuid
import pytest
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.persistence.alembic_runner import (
get_alembic_current,
run_alembic_stamp,
run_alembic_upgrade,
)
pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
async def test_legacy_plugin_settings_receive_stable_random_installation_identities(tmp_path):
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "plugin-identity.db"}')
metadata = sa.MetaData()
plugin_settings = sa.Table(
'plugin_settings',
metadata,
sa.Column('workspace_uuid', sa.String(36), primary_key=True),
sa.Column('plugin_author', sa.String(255), primary_key=True),
sa.Column('plugin_name', sa.String(255), primary_key=True),
sa.Column('enabled', sa.Boolean, nullable=False, server_default=sa.true()),
sa.Column('priority', sa.Integer, nullable=False, server_default='0'),
sa.Column('config', sa.JSON, nullable=False, server_default='{}'),
sa.Column('install_source', sa.String(255), nullable=False, server_default='local'),
sa.Column('install_info', sa.JSON, nullable=False, server_default='{}'),
)
try:
async with engine.begin() as connection:
await connection.run_sync(metadata.create_all)
await connection.execute(
plugin_settings.insert(),
[
{
'workspace_uuid': '11111111-1111-4111-8111-111111111111',
'plugin_author': 'author',
'plugin_name': 'one',
},
{
'workspace_uuid': '22222222-2222-4222-8222-222222222222',
'plugin_author': 'author',
'plugin_name': 'two',
},
],
)
await run_alembic_stamp(engine, '0011_postgres_tenant_rls')
await run_alembic_upgrade(engine, '0012_plugin_identity')
async with engine.connect() as connection:
rows = (
(
await connection.execute(
sa.text(
'SELECT installation_uuid, artifact_digest, runtime_revision '
'FROM plugin_settings ORDER BY workspace_uuid'
)
)
)
.mappings()
.all()
)
columns = await connection.run_sync(
lambda sync_connection: {
column['name']: column for column in sa.inspect(sync_connection).get_columns('plugin_settings')
}
)
indexes = await connection.run_sync(
lambda sync_connection: {
index['name']: index for index in sa.inspect(sync_connection).get_indexes('plugin_settings')
}
)
assert await get_alembic_current(engine) == '0012_plugin_identity'
assert columns['installation_uuid']['nullable'] is False
assert columns['artifact_digest']['nullable'] is False
assert columns['runtime_revision']['nullable'] is False
assert indexes['ix_plugin_settings_workspace_installation']['unique'] == 1
assert len({row['installation_uuid'] for row in rows}) == 2
for row in rows:
uuid.UUID(row['installation_uuid'])
assert row['runtime_revision'] == 1
assert (
row['artifact_digest']
== hashlib.sha256(f'legacy-installation:{row["installation_uuid"]}'.encode()).hexdigest()
)
finally:
await engine.dispose()
@@ -0,0 +1,723 @@
"""Real PostgreSQL coverage for the one-shot Cloud release migration job."""
from __future__ import annotations
import logging
import os
import uuid
from types import SimpleNamespace
import pytest
import sqlalchemy as sa
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from langbot.pkg.persistence import release_migration
from langbot.pkg.persistence.alembic_runner import (
get_alembic_current,
get_alembic_head,
run_alembic_stamp,
run_alembic_upgrade,
)
from langbot.pkg.persistence.mgr import (
PersistenceManager,
PersistenceMode,
_RELEASE_MIGRATION_ADVISORY_LOCK_ID,
)
from langbot.pkg.utils import constants
pytestmark = [pytest.mark.integration, pytest.mark.slow, pytest.mark.asyncio]
_RUNTIME_PASSWORD = 'runtime-secret-not-used-by-migration'
@pytest.fixture
def postgres_url() -> str:
url = os.environ.get('TEST_POSTGRES_URL')
if not url:
pytest.skip('TEST_POSTGRES_URL not set')
return url
@pytest.fixture
async def postgres_engine(postgres_url: str):
engine = create_async_engine(postgres_url, isolation_level='AUTOCOMMIT')
yield engine
await engine.dispose()
@pytest.fixture
async def clean_database(postgres_engine: AsyncEngine):
async def clean() -> None:
async with postgres_engine.begin() as conn:
table_names = await conn.run_sync(lambda sync_conn: sa.inspect(sync_conn).get_table_names())
quote = postgres_engine.dialect.identifier_preparer.quote
for table_name in table_names:
await conn.execute(text(f'DROP TABLE {quote(table_name)} CASCADE'))
await clean()
yield
await clean()
def _restore_postgres_registry(monkeypatch) -> None:
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],
)
def _application(postgres_url: str, *, runtime_role: str = 'langbot_runtime_not_used_by_migration') -> SimpleNamespace:
url = sa.engine.make_url(postgres_url)
return SimpleNamespace(
instance_config=SimpleNamespace(
data={
'database': {
'use': 'postgresql',
'postgresql': {
'host': url.host,
'port': url.port,
# This is deliberately not the operator role in the DSN.
'user': runtime_role,
'password': _RUNTIME_PASSWORD,
'database': url.database,
},
'cloud_migration': {'operator_dsn_env': 'TEST_RELEASE_OPERATOR_DSN'},
},
'vdb': {
'use': 'pgvector',
'pgvector': {
'use_business_database': True,
'allowed_dimensions': [384, 512, 768, 1024, 1536],
},
},
}
),
logger=logging.getLogger('cloud-release-migration-entrypoint-test'),
persistence_mgr=None,
)
async def test_release_entrypoint_holds_lock_migrates_validates_and_disposes(
postgres_url: str,
postgres_engine: AsyncEngine,
clean_database,
monkeypatch,
) -> None:
_restore_postgres_registry(monkeypatch)
monkeypatch.setattr(constants, 'instance_id', 'release-migration-entrypoint-test')
original_validate = PersistenceManager._validate_release_schema
validation_observed_lock = False
runtime_role = f'lb_release_runtime_{uuid.uuid4().hex[:12]}'
quote = postgres_engine.dialect.identifier_preparer.quote
async def validate_while_asserting_lock(self: PersistenceManager) -> None:
nonlocal validation_observed_lock
async with postgres_engine.connect() as conn:
acquired = await conn.scalar(
text('SELECT pg_try_advisory_lock(:lock_id)'),
{'lock_id': _RELEASE_MIGRATION_ADVISORY_LOCK_ID},
)
if acquired:
await conn.scalar(
text('SELECT pg_advisory_unlock(:lock_id)'),
{'lock_id': _RELEASE_MIGRATION_ADVISORY_LOCK_ID},
)
assert acquired is False
validation_observed_lock = True
await original_validate(self)
monkeypatch.setattr(PersistenceManager, '_validate_release_schema', validate_while_asserting_lock)
async with postgres_engine.connect() as conn:
await conn.execute(text(f"CREATE ROLE {quote(runtime_role)} LOGIN PASSWORD '{_RUNTIME_PASSWORD}'"))
# The release job must make this otherwise bare LOGIN usable without
# relying on pre-provisioned object ACLs.
assert (
await conn.scalar(
text(
"""
SELECT NOT EXISTS (
SELECT 1
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
CROSS JOIN LATERAL aclexplode(c.relacl) acl
JOIN pg_roles grantee ON grantee.oid = acl.grantee
WHERE n.nspname = current_schema()
AND grantee.rolname = :runtime_role
)
"""
),
{'runtime_role': runtime_role},
)
is True
)
ap = _application(postgres_url, runtime_role=runtime_role)
try:
await release_migration.run_cloud_release_migration(
ap,
environ={'TEST_RELEASE_OPERATOR_DSN': postgres_url},
)
assert validation_observed_lock is True
assert await get_alembic_current(postgres_engine) == get_alembic_head()
manager = ap.persistence_mgr
assert isinstance(manager, PersistenceManager)
business_tables = set(manager._runtime_business_table_names())
async with postgres_engine.connect() as conn:
assert await conn.scalar(text("SELECT to_regclass('langbot_vectors') IS NOT NULL")) is True
assert (
await conn.scalar(text("SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'vector')")) is True
)
runtime_role_state = (
(
await conn.execute(
text(
"""
SELECT
rolcanlogin,
rolsuper,
rolbypassrls,
rolcreatedb,
rolcreaterole,
rolreplication
FROM pg_roles
WHERE rolname = :runtime_role
"""
),
{'runtime_role': runtime_role},
)
)
.mappings()
.one()
)
assert dict(runtime_role_state) == {
'rolcanlogin': True,
'rolsuper': False,
'rolbypassrls': False,
'rolcreatedb': False,
'rolcreaterole': False,
'rolreplication': False,
}
database_privileges = set(
(
await conn.execute(
text(
"""
SELECT acl.privilege_type
FROM pg_database database
CROSS JOIN LATERAL aclexplode(database.datacl) acl
JOIN pg_roles grantee ON grantee.oid = acl.grantee
WHERE database.datname = current_database()
AND grantee.rolname = :runtime_role
AND acl.is_grantable IS FALSE
"""
),
{'runtime_role': runtime_role},
)
)
.scalars()
.all()
)
schema_privileges = set(
(
await conn.execute(
text(
"""
SELECT acl.privilege_type
FROM pg_namespace namespace
CROSS JOIN LATERAL aclexplode(namespace.nspacl) acl
JOIN pg_roles grantee ON grantee.oid = acl.grantee
WHERE namespace.nspname = current_schema()
AND grantee.rolname = :runtime_role
AND acl.is_grantable IS FALSE
"""
),
{'runtime_role': runtime_role},
)
)
.scalars()
.all()
)
assert database_privileges == {'CONNECT'}
assert schema_privileges == {'USAGE'}
assert (
await conn.scalar(
text("SELECT has_database_privilege(:runtime_role, current_database(), 'CREATE')"),
{'runtime_role': runtime_role},
)
is False
)
assert (
await conn.scalar(
text("SELECT has_schema_privilege(:runtime_role, current_schema(), 'CREATE')"),
{'runtime_role': runtime_role},
)
is False
)
# PostgreSQL grants TEMP to PUBLIC by default. The first Cloud
# release deliberately tolerates that inherited compatibility
# privilege while granting no direct TEMP ACL to the runtime role.
assert (
await conn.scalar(
text("SELECT has_database_privilege(:runtime_role, current_database(), 'TEMP')"),
{'runtime_role': runtime_role},
)
is True
)
object_grants = (
(
await conn.execute(
text(
"""
SELECT
c.relname,
c.relkind::text AS relkind,
acl.privilege_type,
acl.is_grantable
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
CROSS JOIN LATERAL aclexplode(c.relacl) acl
JOIN pg_roles grantee ON grantee.oid = acl.grantee
WHERE n.nspname = current_schema()
AND c.relkind IN ('r', 'p', 'S')
AND grantee.rolname = :runtime_role
ORDER BY c.relname, acl.privilege_type
"""
),
{'runtime_role': runtime_role},
)
)
.mappings()
.all()
)
direct_table_grants: dict[str, set[str]] = {}
direct_sequence_grants: dict[str, set[str]] = {}
for grant in object_grants:
assert grant['is_grantable'] is False
target = direct_sequence_grants if grant['relkind'] == 'S' else direct_table_grants
target.setdefault(grant['relname'], set()).add(grant['privilege_type'])
assert set(direct_table_grants) == business_tables | {'alembic_version'}
assert all(
privileges == {'SELECT', 'INSERT', 'UPDATE', 'DELETE'}
for table_name, privileges in direct_table_grants.items()
if table_name != 'alembic_version'
)
assert direct_table_grants['alembic_version'] == {'SELECT'}
assert direct_sequence_grants
assert all(privileges == {'USAGE', 'SELECT'} for privileges in direct_sequence_grants.values())
# The session-level lock must be released before the one-shot job exits.
assert (
await conn.scalar(
text('SELECT pg_try_advisory_lock(:lock_id)'),
{'lock_id': _RELEASE_MIGRATION_ADVISORY_LOCK_ID},
)
is True
)
assert (
await conn.scalar(
text('SELECT pg_advisory_unlock(:lock_id)'),
{'lock_id': _RELEASE_MIGRATION_ADVISORY_LOCK_ID},
)
is True
)
runtime_url = (
sa.engine.make_url(postgres_url)
.set(username=runtime_role, password=_RUNTIME_PASSWORD)
.render_as_string(hide_password=False)
)
runtime_engine = create_async_engine(runtime_url)
try:
async with runtime_engine.begin() as conn:
assert await conn.scalar(text('SELECT current_user')) == runtime_role
await conn.execute(text("INSERT INTO metadata (key, value) VALUES ('runtime-grant-smoke', 'created')"))
await conn.execute(text("UPDATE metadata SET value = 'updated' WHERE key = 'runtime-grant-smoke'"))
assert (
await conn.scalar(text("SELECT value FROM metadata WHERE key = 'runtime-grant-smoke'")) == 'updated'
)
await conn.execute(text("DELETE FROM metadata WHERE key = 'runtime-grant-smoke'"))
await conn.scalar(
text('SELECT nextval(CAST(:sequence_name AS regclass))'),
{'sequence_name': f'public.{sorted(direct_sequence_grants)[0]}'},
)
finally:
await runtime_engine.dispose()
runtime_application = _application(postgres_url, runtime_role=runtime_role)
runtime_manager = PersistenceManager(runtime_application, mode=PersistenceMode.CLOUD_RUNTIME)
runtime_application.persistence_mgr = runtime_manager
try:
# This reads alembic_version as the actual runtime role and then
# reruns the complete grant/catalog validator before startup.
await runtime_manager.initialize()
finally:
await runtime_manager.get_db_engine().dispose()
# The catalog validator must fail closed if the role is no longer
# deployable, even though the remaining grants still look plausible.
async with postgres_engine.connect() as conn:
await conn.execute(text(f'REVOKE DELETE ON TABLE public.metadata FROM {quote(runtime_role)}'))
with pytest.raises(RuntimeError, match="table 'metadata' grants are incomplete"):
await manager._validate_configured_runtime_postgres_role(require_grants=True)
finally:
async with postgres_engine.connect() as conn:
await conn.execute(text(f'DROP OWNED BY {quote(runtime_role)}'))
await conn.execute(text(f'DROP ROLE IF EXISTS {quote(runtime_role)}'))
async def test_release_entrypoint_rejects_privileged_or_table_owning_runtime_role(
postgres_url: str,
postgres_engine: AsyncEngine,
clean_database,
monkeypatch,
) -> None:
_restore_postgres_registry(monkeypatch)
monkeypatch.setattr(constants, 'instance_id', 'release-runtime-role-validation-test')
runtime_role = f'lb_release_runtime_{uuid.uuid4().hex[:12]}'
quote = postgres_engine.dialect.identifier_preparer.quote
async with postgres_engine.connect() as conn:
operator_role = await conn.scalar(text('SELECT current_user'))
await conn.execute(text(f'CREATE ROLE {quote(runtime_role)} LOGIN'))
ap = _application(postgres_url, runtime_role=runtime_role)
try:
await release_migration.run_cloud_release_migration(
ap,
environ={'TEST_RELEASE_OPERATOR_DSN': postgres_url},
)
async with postgres_engine.connect() as conn:
await conn.execute(text(f'ALTER ROLE {quote(runtime_role)} SUPERUSER'))
with pytest.raises(RuntimeError, match='must not be superuser or BYPASSRLS'):
await release_migration.run_cloud_release_migration(
ap,
environ={'TEST_RELEASE_OPERATOR_DSN': postgres_url},
)
async with postgres_engine.connect() as conn:
await conn.execute(text(f'ALTER ROLE {quote(runtime_role)} NOSUPERUSER BYPASSRLS'))
with pytest.raises(RuntimeError, match='must not be superuser or BYPASSRLS'):
await release_migration.run_cloud_release_migration(
ap,
environ={'TEST_RELEASE_OPERATOR_DSN': postgres_url},
)
async with postgres_engine.connect() as conn:
await conn.execute(text(f'ALTER ROLE {quote(runtime_role)} NOBYPASSRLS'))
await conn.execute(text(f'ALTER TABLE bots OWNER TO {quote(runtime_role)}'))
with pytest.raises(RuntimeError, match='owns tenant tables'):
await release_migration.run_cloud_release_migration(
ap,
environ={'TEST_RELEASE_OPERATOR_DSN': postgres_url},
)
finally:
async with postgres_engine.connect() as conn:
table_exists = await conn.scalar(text("SELECT to_regclass('bots') IS NOT NULL"))
if table_exists:
await conn.execute(text(f'ALTER TABLE bots OWNER TO {quote(operator_role)}'))
await conn.execute(text(f'DROP OWNED BY {quote(runtime_role)}'))
await conn.execute(text(f'DROP ROLE IF EXISTS {quote(runtime_role)}'))
async def test_runtime_role_catalog_validator_rejects_delegation_and_escape_hatches(
postgres_url: str,
postgres_engine: AsyncEngine,
clean_database,
monkeypatch,
) -> None:
_restore_postgres_registry(monkeypatch)
monkeypatch.setattr(constants, 'instance_id', 'release-runtime-role-catalog-test')
suffix = uuid.uuid4().hex[:12]
runtime_role = f'lb_release_runtime_{suffix}'
delegated_role = f'lb_release_delegate_{suffix}'
extra_schema = f'lb_release_schema_{suffix}'
extra_view = f'lb_release_view_{suffix}'
owned_routine = f'lb_release_owned_routine_{suffix}'
security_definer = f'lb_release_definer_{suffix}'
foreign_wrapper = f'lb_release_fdw_{suffix}'
foreign_server = f'lb_release_server_{suffix}'
persistent_setting_secret = f'lb_release_secret_{suffix}'
database_name = sa.engine.make_url(postgres_url).database
assert database_name
quote = postgres_engine.dialect.identifier_preparer.quote
async with postgres_engine.connect() as conn:
await conn.execute(text(f"CREATE ROLE {quote(runtime_role)} LOGIN PASSWORD '{_RUNTIME_PASSWORD}'"))
await conn.execute(text(f'CREATE ROLE {quote(delegated_role)}'))
ap = _application(postgres_url, runtime_role=runtime_role)
try:
await release_migration.run_cloud_release_migration(
ap,
environ={'TEST_RELEASE_OPERATOR_DSN': postgres_url},
)
manager = ap.persistence_mgr
assert isinstance(manager, PersistenceManager)
async with postgres_engine.connect() as conn:
await conn.execute(text(f'GRANT pg_read_all_data TO {quote(runtime_role)}'))
with pytest.raises(RuntimeError, match='must not participate in role memberships'):
await manager._validate_configured_runtime_postgres_role()
async with postgres_engine.connect() as conn:
await conn.execute(text(f'REVOKE pg_read_all_data FROM {quote(runtime_role)}'))
# Reject delegation in the other direction too: no role may be
# allowed to SET ROLE to the runtime identity or administer it.
await conn.execute(text(f'GRANT {quote(runtime_role)} TO {quote(delegated_role)} WITH ADMIN OPTION'))
with pytest.raises(RuntimeError, match='must not participate in role memberships'):
await manager._validate_configured_runtime_postgres_role()
async with postgres_engine.connect() as conn:
await conn.execute(text(f'REVOKE {quote(runtime_role)} FROM {quote(delegated_role)}'))
await conn.execute(
text(f'GRANT SELECT ON TABLE public.metadata TO {quote(runtime_role)} WITH GRANT OPTION')
)
with pytest.raises(RuntimeError, match='GRANT OPTION'):
await manager._validate_configured_runtime_postgres_role()
async with postgres_engine.connect() as conn:
await conn.execute(
text(f'REVOKE GRANT OPTION FOR SELECT ON TABLE public.metadata FROM {quote(runtime_role)}')
)
await conn.execute(text(f'ALTER ROLE {quote(runtime_role)} SET search_path TO public'))
with pytest.raises(RuntimeError, match='persistent session overrides'):
await manager._validate_configured_runtime_postgres_role()
async with postgres_engine.connect() as conn:
await conn.execute(text(f'ALTER ROLE {quote(runtime_role)} RESET search_path'))
await conn.execute(text(f'ALTER DATABASE {quote(database_name)} SET search_path TO public'))
with pytest.raises(RuntimeError, match='persistent session overrides'):
await manager._validate_configured_runtime_postgres_role()
async with postgres_engine.connect() as conn:
await conn.execute(text(f'ALTER DATABASE {quote(database_name)} RESET search_path'))
await conn.execute(text(f'ALTER ROLE {quote(runtime_role)} SET session_replication_role TO replica'))
with pytest.raises(RuntimeError, match='persistent session overrides'):
await manager._validate_configured_runtime_postgres_role()
async with postgres_engine.connect() as conn:
await conn.execute(text(f'ALTER ROLE {quote(runtime_role)} RESET session_replication_role'))
await conn.execute(
text(f"ALTER ROLE {quote(runtime_role)} SET application_name TO '{persistent_setting_secret}'")
)
with pytest.raises(RuntimeError, match='persistent session overrides') as error:
await manager._validate_configured_runtime_postgres_role()
assert persistent_setting_secret not in str(error.value)
async with postgres_engine.connect() as conn:
await conn.execute(text(f'ALTER ROLE {quote(runtime_role)} RESET application_name'))
await conn.execute(text('CREATE EXTENSION dblink'))
with pytest.raises(RuntimeError, match='extensions must include vector and be limited'):
await manager._validate_configured_runtime_postgres_role()
async with postgres_engine.connect() as conn:
await conn.execute(text('DROP EXTENSION dblink'))
await manager._validate_configured_runtime_postgres_role()
async with postgres_engine.connect() as conn:
await conn.execute(text(f'GRANT CREATE ON DATABASE {quote(database_name)} TO {quote(runtime_role)}'))
await conn.execute(text(f'GRANT CREATE ON SCHEMA public TO {quote(runtime_role)}'))
runtime_url = (
sa.engine.make_url(postgres_url)
.set(username=runtime_role, password=_RUNTIME_PASSWORD)
.render_as_string(hide_password=False)
)
runtime_engine = create_async_engine(runtime_url)
try:
async with runtime_engine.begin() as conn:
# hstore is a trusted extension in the production PG16 image,
# so this creates a real runtime-owned extension catalog row.
await conn.execute(text('CREATE EXTENSION hstore'))
finally:
await runtime_engine.dispose()
async with postgres_engine.connect() as conn:
await conn.execute(text(f'REVOKE CREATE ON SCHEMA public FROM {quote(runtime_role)}'))
await conn.execute(text(f'REVOKE CREATE ON DATABASE {quote(database_name)} FROM {quote(runtime_role)}'))
with pytest.raises(RuntimeError, match='must not own extensions'):
await manager._validate_configured_runtime_postgres_role()
async with postgres_engine.connect() as conn:
await conn.execute(text('DROP EXTENSION hstore CASCADE'))
await manager._validate_configured_runtime_postgres_role()
async with postgres_engine.connect() as conn:
await conn.execute(text(f'CREATE FOREIGN DATA WRAPPER {quote(foreign_wrapper)}'))
await conn.execute(
text(f'CREATE SERVER {quote(foreign_server)} FOREIGN DATA WRAPPER {quote(foreign_wrapper)}')
)
await conn.execute(text(f'CREATE USER MAPPING FOR {quote(runtime_role)} SERVER {quote(foreign_server)}'))
with pytest.raises(RuntimeError, match='foreign data wrappers, servers, or user mappings'):
await manager._validate_configured_runtime_postgres_role()
async with postgres_engine.connect() as conn:
await conn.execute(text(f'DROP FOREIGN DATA WRAPPER {quote(foreign_wrapper)} CASCADE'))
await manager._validate_configured_runtime_postgres_role()
async with postgres_engine.connect() as conn:
await conn.execute(text(f'CREATE SCHEMA {quote(extra_schema)} AUTHORIZATION {quote(runtime_role)}'))
with pytest.raises(RuntimeError, match='non-business schemas'):
await manager._validate_configured_runtime_postgres_role()
async with postgres_engine.connect() as conn:
await conn.execute(text(f'DROP SCHEMA {quote(extra_schema)} CASCADE'))
await conn.execute(text(f'CREATE VIEW public.{quote(extra_view)} AS SELECT key FROM public.metadata'))
await conn.execute(text(f'GRANT SELECT ON public.{quote(extra_view)} TO {quote(runtime_role)}'))
with pytest.raises(RuntimeError, match='non-business objects|table privileges are unsafe'):
await manager._validate_configured_runtime_postgres_role()
async with postgres_engine.connect() as conn:
await conn.execute(text(f'DROP VIEW public.{quote(extra_view)}'))
await conn.execute(text(f'GRANT SELECT (key) ON public.metadata TO {quote(runtime_role)}'))
with pytest.raises(RuntimeError, match='column-level ACLs'):
await manager._validate_configured_runtime_postgres_role()
async with postgres_engine.connect() as conn:
await conn.execute(text(f'REVOKE SELECT (key) ON public.metadata FROM {quote(runtime_role)}'))
# System file access functions are not SECURITY DEFINER, so the
# validator must reject their explicit EXECUTE ACL independently.
await conn.execute(
text(f'GRANT EXECUTE ON FUNCTION pg_catalog.pg_read_file(text) TO {quote(runtime_role)}')
)
runtime_application = _application(postgres_url, runtime_role=runtime_role)
runtime_manager = PersistenceManager(runtime_application, mode=PersistenceMode.CLOUD_RUNTIME)
runtime_application.persistence_mgr = runtime_manager
try:
with pytest.raises(RuntimeError, match='explicit EXECUTE privileges on routines'):
await runtime_manager.initialize()
finally:
await runtime_manager.get_db_engine().dispose()
async with postgres_engine.connect() as conn:
await conn.execute(
text(f'REVOKE EXECUTE ON FUNCTION pg_catalog.pg_read_file(text) FROM {quote(runtime_role)}')
)
await manager._validate_configured_runtime_postgres_role()
async with postgres_engine.connect() as conn:
# replica disables ordinary triggers/rules and foreign-key
# enforcement; it must never reach the runtime identity.
await conn.execute(text(f'GRANT SET ON PARAMETER session_replication_role TO {quote(runtime_role)}'))
with pytest.raises(RuntimeError, match='explicit SET or ALTER SYSTEM parameter privileges'):
await manager._validate_configured_runtime_postgres_role()
async with postgres_engine.connect() as conn:
await conn.execute(text(f'REVOKE SET ON PARAMETER session_replication_role FROM {quote(runtime_role)}'))
await manager._validate_configured_runtime_postgres_role()
async with postgres_engine.connect() as conn:
await conn.execute(
text(f"CREATE FUNCTION public.{quote(owned_routine)}() RETURNS integer LANGUAGE sql AS 'SELECT 1'")
)
await conn.execute(text(f'ALTER FUNCTION public.{quote(owned_routine)}() OWNER TO {quote(runtime_role)}'))
with pytest.raises(RuntimeError, match='must not own routines'):
await manager._validate_configured_runtime_postgres_role()
async with postgres_engine.connect() as conn:
await conn.execute(text(f'DROP FUNCTION public.{quote(owned_routine)}()'))
async with postgres_engine.connect() as conn:
await conn.execute(
text(
f'CREATE FUNCTION public.{quote(security_definer)}() RETURNS integer '
"LANGUAGE sql SECURITY DEFINER AS 'SELECT 1'"
)
)
# Extension membership must not exempt an executable definer from
# the runtime audit, even for an allowlisted extension.
await conn.execute(text(f'ALTER EXTENSION vector ADD FUNCTION public.{quote(security_definer)}()'))
with pytest.raises(RuntimeError, match='SECURITY DEFINER'):
await manager._validate_configured_runtime_postgres_role()
async with postgres_engine.connect() as conn:
await conn.execute(text(f'ALTER EXTENSION vector DROP FUNCTION public.{quote(security_definer)}()'))
finally:
async with postgres_engine.connect() as conn:
await conn.execute(text(f'ALTER DATABASE {quote(database_name)} RESET search_path'))
await conn.execute(text(f'ALTER ROLE {quote(runtime_role)} RESET search_path'))
await conn.execute(text(f'ALTER ROLE {quote(runtime_role)} RESET session_replication_role'))
await conn.execute(text(f'ALTER ROLE {quote(runtime_role)} RESET application_name'))
await conn.execute(text(f'REVOKE pg_read_all_data FROM {quote(runtime_role)}'))
await conn.execute(text(f'REVOKE {quote(runtime_role)} FROM {quote(delegated_role)}'))
await conn.execute(text(f'REVOKE CREATE ON SCHEMA public FROM {quote(runtime_role)}'))
await conn.execute(text(f'REVOKE CREATE ON DATABASE {quote(database_name)} FROM {quote(runtime_role)}'))
await conn.execute(
text(f'REVOKE EXECUTE ON FUNCTION pg_catalog.pg_read_file(text) FROM {quote(runtime_role)}')
)
await conn.execute(text(f'REVOKE SET ON PARAMETER session_replication_role FROM {quote(runtime_role)}'))
await conn.execute(text('DROP EXTENSION IF EXISTS dblink CASCADE'))
await conn.execute(text('DROP EXTENSION IF EXISTS hstore CASCADE'))
await conn.execute(text(f'DROP FOREIGN DATA WRAPPER IF EXISTS {quote(foreign_wrapper)} CASCADE'))
await conn.execute(text(f'DROP FUNCTION IF EXISTS public.{quote(owned_routine)}()'))
security_definer_is_extension_member = await conn.scalar(
text(
"""
SELECT EXISTS (
SELECT 1
FROM pg_depend dependency
JOIN pg_extension extension ON extension.oid = dependency.refobjid
WHERE dependency.classid = 'pg_proc'::regclass
AND dependency.objid = to_regprocedure(:routine)
AND dependency.refclassid = 'pg_extension'::regclass
AND dependency.deptype = 'e'
AND extension.extname = 'vector'
)
"""
),
{'routine': f'public.{security_definer}()'},
)
if security_definer_is_extension_member:
await conn.execute(text(f'ALTER EXTENSION vector DROP FUNCTION public.{quote(security_definer)}()'))
await conn.execute(text(f'DROP FUNCTION IF EXISTS public.{quote(security_definer)}()'))
await conn.execute(text(f'DROP VIEW IF EXISTS public.{quote(extra_view)}'))
await conn.execute(text(f'DROP SCHEMA IF EXISTS {quote(extra_schema)} CASCADE'))
await conn.execute(text(f'DROP OWNED BY {quote(runtime_role)}'))
await conn.execute(text(f'DROP ROLE IF EXISTS {quote(delegated_role)}'))
await conn.execute(text(f'DROP ROLE IF EXISTS {quote(runtime_role)}'))
async def test_direct_postgres_head_stamp_fails_without_business_schema(
postgres_engine: AsyncEngine,
clean_database,
) -> None:
await run_alembic_stamp(postgres_engine, '0012_plugin_identity')
with pytest.raises(RuntimeError, match='requires the knowledge_bases table'):
await run_alembic_upgrade(postgres_engine)
assert await get_alembic_current(postgres_engine) == '0012_plugin_identity'
async def test_release_entrypoint_fails_immediately_when_another_job_holds_lock(
postgres_url: str,
postgres_engine: AsyncEngine,
clean_database,
monkeypatch,
) -> None:
_restore_postgres_registry(monkeypatch)
ap = _application(postgres_url)
async with postgres_engine.connect() as lock_connection:
assert (
await lock_connection.scalar(
text('SELECT pg_try_advisory_lock(:lock_id)'),
{'lock_id': _RELEASE_MIGRATION_ADVISORY_LOCK_ID},
)
is True
)
try:
with pytest.raises(RuntimeError, match='already holds the advisory lock'):
await release_migration.run_cloud_release_migration(
ap,
environ={'TEST_RELEASE_OPERATOR_DSN': postgres_url},
)
finally:
assert (
await lock_connection.scalar(
text('SELECT pg_advisory_unlock(:lock_id)'),
{'lock_id': _RELEASE_MIGRATION_ADVISORY_LOCK_ID},
)
is True
)
async with postgres_engine.connect() as conn:
assert await conn.run_sync(lambda sync_conn: sa.inspect(sync_conn).get_table_names()) == []
@@ -195,10 +195,16 @@ async def test_sqlite_scoped_keys_allow_cross_workspace_but_reject_same_workspac
await conn.execute(
sa.text(
'INSERT INTO plugin_settings '
'(workspace_uuid, plugin_author, plugin_name, enabled) '
"VALUES (:workspace_uuid, 'author', 'plugin', 1)"
'(workspace_uuid, plugin_author, plugin_name, enabled, '
'installation_uuid, artifact_digest, runtime_revision) '
"VALUES (:workspace_uuid, 'author', 'plugin', 1, "
':installation_uuid, :artifact_digest, 1)'
),
{'workspace_uuid': second_workspace_uuid},
{
'workspace_uuid': second_workspace_uuid,
'installation_uuid': str(uuid.uuid4()),
'artifact_digest': hashlib.sha256(b'test-plugin-artifact').hexdigest(),
},
)
await conn.execute(
sa.text(
@@ -0,0 +1,415 @@
from __future__ import annotations
import asyncio
import datetime as dt
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
from langbot_plugin.box.backend import BaseSandboxBackend
from langbot_plugin.box.client import ActionRPCBoxClient
from langbot_plugin.box.errors import BoxAdmissionError
from langbot_plugin.box.models import (
BoxExecutionResult,
BoxExecutionStatus,
BoxNetworkMode,
BoxSessionInfo,
BoxSpec,
)
from langbot_plugin.box.runtime import BoxRuntime
from langbot_plugin.box.server import BoxServerHandler
from langbot_plugin.runtime.io.handler import Handler
from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.box.service import BoxService
from langbot.pkg.cloud.entitlements import (
EntitlementResolver,
EntitlementSnapshot,
EntitlementUnavailableError,
)
pytestmark = pytest.mark.integration
_UTC = dt.timezone.utc
class _AdmissionBackend(BaseSandboxBackend):
name = 'nsjail'
def __init__(self, logger):
super().__init__(logger)
self.started_specs: list[BoxSpec] = []
self.stopped_sessions: list[str] = []
async def is_available(self) -> bool:
return True
async def get_readiness(self, *, workspace_path=None, strict=False) -> dict:
return {
'available': True,
'cgroup_v2': True,
'namespace_isolation': True,
'mount_isolation': True,
'network_isolation': True,
'hard_workspace_quota': True,
'hard_skill_storage_quota': True,
'bounded_ephemeral_storage': True,
'inode_quota': True,
}
async def start_session(self, spec: BoxSpec) -> BoxSessionInfo:
self.started_specs.append(spec)
now = dt.datetime.now(_UTC)
return BoxSessionInfo(
session_id=spec.session_id,
backend_name=self.name,
backend_session_id=f'jail-{len(self.started_specs)}',
image=spec.image,
network=spec.network,
host_path=spec.host_path,
host_path_mode=spec.host_path_mode,
mount_path=spec.mount_path,
persistent=spec.persistent,
cpus=spec.cpus,
memory_mb=spec.memory_mb,
pids_limit=spec.pids_limit,
read_only_rootfs=spec.read_only_rootfs,
workspace_quota_mb=spec.workspace_quota_mb,
created_at=now,
last_used_at=now,
)
async def exec(self, session: BoxSessionInfo, spec: BoxSpec) -> BoxExecutionResult:
await asyncio.sleep(0)
return BoxExecutionResult(
session_id=session.session_id,
backend_name=self.name,
status=BoxExecutionStatus.COMPLETED,
exit_code=0,
stdout=spec.cmd,
stderr='',
duration_ms=1,
)
async def stop_session(self, session: BoxSessionInfo):
self.stopped_sessions.append(session.session_id)
class _QueueConnection:
def __init__(self, rx: asyncio.Queue[str], tx: asyncio.Queue[str]):
self._rx = rx
self._tx = tx
async def send(self, message: str) -> None:
await self._tx.put(message)
async def receive(self) -> str:
return await self._rx.get()
async def close(self) -> None:
return None
async def _rpc_client(runtime: BoxRuntime):
client_to_server: asyncio.Queue[str] = asyncio.Queue()
server_to_client: asyncio.Queue[str] = asyncio.Queue()
client_connection = _QueueConnection(server_to_client, client_to_server)
server_connection = _QueueConnection(client_to_server, server_to_client)
server_handler = BoxServerHandler(
server_connection,
runtime,
host_control_authenticated=True,
trusted_instance_uuid='instance-a',
)
server_task = asyncio.create_task(server_handler.run())
client_handler = Handler(client_connection)
client_task = asyncio.create_task(client_handler.run())
client = ActionRPCBoxClient(logger=Mock())
client.set_handler(client_handler)
return client, server_task, client_task
class _Entitlements:
def __init__(self):
self.snapshots: dict[str, EntitlementSnapshot] = {}
async def get_workspace_entitlement(self, workspace_uuid: str) -> EntitlementSnapshot:
return self.snapshots[workspace_uuid]
def _snapshot(workspace_uuid: str, *, revision: int = 1, managed: bool = True) -> EntitlementSnapshot:
return EntitlementSnapshot(
instance_uuid='instance-a',
workspace_uuid=workspace_uuid,
entitlement_revision=revision,
status='active',
not_before=1,
expires_at=4_000_000_000,
features={'managed_sandbox': managed},
limits={'managed_sandbox_sessions': 1 if managed else 0},
)
def _context(workspace_uuid: str, *, revision: int = 1) -> ExecutionContext:
return ExecutionContext(
instance_uuid='instance-a',
workspace_uuid=workspace_uuid,
placement_generation=1,
entitlement_revision=revision,
)
def _query(context: ExecutionContext, query_id: int):
query = pipeline_query.Query.model_construct(
query_id=query_id,
bot_uuid='bot-a',
pipeline_uuid='pipeline-a',
launcher_type='person',
launcher_id=f'user-{query_id}',
variables={},
)
object.__setattr__(query, 'instance_uuid', context.instance_uuid)
object.__setattr__(query, 'workspace_uuid', context.workspace_uuid)
object.__setattr__(query, 'placement_generation', context.placement_generation)
object.__setattr__(query, '_execution_context', context)
return query
async def _stack(tmp_path):
shared_root = tmp_path / 'shared-box'
workspace_root = shared_root / 'workspaces'
workspace_root.mkdir(parents=True)
box_config = {
'enabled': True,
'backend': 'nsjail',
'runtime': {'endpoint': 'ws://langbot-box:5410'},
'local': {
'profile': 'default',
'host_root': str(shared_root),
'default_workspace': str(workspace_root),
'allowed_mount_roots': [str(shared_root)],
},
'admission': {
'required': True,
'logical_session_id': 'global',
'required_backend': 'nsjail',
'max_sessions': 1,
'max_managed_processes': 0,
'max_grant_ttl_sec': 300,
'max_timeout_sec': 60,
'cpus': 0.5,
'memory_mb': 256,
'pids_limit': 64,
'read_only_rootfs': True,
'workspace_quota_mb': 32,
'readiness_cache_sec': 0,
},
}
logger = Mock()
backend = _AdmissionBackend(logger)
runtime = BoxRuntime(logger, backends=[backend])
runtime.init(box_config)
await runtime.initialize()
client, server_task, client_task = await _rpc_client(runtime)
entitlements = _Entitlements()
workspace_service = SimpleNamespace(
instance_uuid='instance-a',
get_execution_binding=AsyncMock(
side_effect=lambda workspace_uuid, expected_generation: SimpleNamespace(
instance_uuid='instance-a',
workspace_uuid=workspace_uuid,
placement_generation=expected_generation,
)
),
)
app = SimpleNamespace(
logger=logger,
deployment=SimpleNamespace(multi_workspace_enabled=True),
entitlement_resolver=EntitlementResolver('instance-a', entitlements),
workspace_service=workspace_service,
instance_config=SimpleNamespace(data={'box': box_config, 'system': {'limitation': {}}}),
)
service = BoxService(app, client=client)
await service.initialize()
return service, runtime, backend, entitlements, server_task, client_task
@pytest.mark.asyncio
async def test_concurrent_first_use_creates_one_persistent_global_session(tmp_path):
service, runtime, backend, entitlements, server_task, client_task = await _stack(tmp_path)
context = _context('workspace-a')
entitlements.snapshots[context.workspace_uuid] = _snapshot(context.workspace_uuid)
try:
first, second = await asyncio.gather(
service.execute_tool({'command': 'echo first'}, _query(context, 1)),
service.execute_tool({'command': 'echo second'}, _query(context, 2)),
)
assert first['session_id'] == 'global'
assert second['session_id'] == 'global'
assert len(backend.started_specs) == 1
spec = backend.started_specs[0]
assert spec.persistent is True
assert spec.network == BoxNetworkMode.OFF
assert spec.cpus == 0.5
assert spec.memory_mb == 256
assert spec.pids_limit == 64
assert spec.workspace_quota_mb == 32
finally:
server_task.cancel()
client_task.cancel()
await runtime.shutdown()
@pytest.mark.asyncio
async def test_entitlement_loss_revokes_and_closes_existing_global_session(tmp_path):
service, runtime, backend, entitlements, server_task, client_task = await _stack(tmp_path)
context = _context('workspace-a')
entitlements.snapshots[context.workspace_uuid] = _snapshot(context.workspace_uuid, revision=1)
try:
await service.execute_tool({'command': 'true'}, _query(context, 1))
assert len(runtime.get_sessions()) == 1
entitlements.snapshots[context.workspace_uuid] = _snapshot(
context.workspace_uuid,
revision=2,
managed=False,
)
with pytest.raises(EntitlementUnavailableError):
await service.execute_tool({'command': 'true'}, _query(context, 2))
assert runtime.get_sessions() == []
assert len(backend.stopped_sessions) == 1
finally:
server_task.cancel()
client_task.cancel()
await runtime.shutdown()
@pytest.mark.asyncio
async def test_two_workspaces_get_isolated_physical_sessions_and_paths(tmp_path):
service, runtime, backend, entitlements, server_task, client_task = await _stack(tmp_path)
first = _context('workspace-a')
second = _context('workspace-b')
entitlements.snapshots[first.workspace_uuid] = _snapshot(first.workspace_uuid)
entitlements.snapshots[second.workspace_uuid] = _snapshot(second.workspace_uuid)
try:
result_a = await service.execute_tool({'command': 'tenant-a'}, _query(first, 1))
result_b = await service.execute_tool({'command': 'tenant-b'}, _query(second, 2))
assert result_a['session_id'] == result_b['session_id'] == 'global'
assert len(backend.started_specs) == 2
assert backend.started_specs[0].session_id != backend.started_specs[1].session_id
assert backend.started_specs[0].host_path != backend.started_specs[1].host_path
assert len(runtime.get_sessions()) == 2
finally:
server_task.cancel()
client_task.cancel()
await runtime.shutdown()
@pytest.mark.asyncio
async def test_cloud_skills_reject_host_paths_and_require_managed_entitlement(tmp_path):
service, runtime, backend, entitlements, server_task, client_task = await _stack(tmp_path)
first = _context('workspace-a')
second = _context('workspace-b')
ineligible = _context('workspace-free')
entitlements.snapshots[first.workspace_uuid] = _snapshot(first.workspace_uuid)
entitlements.snapshots[second.workspace_uuid] = _snapshot(second.workspace_uuid)
entitlements.snapshots[ineligible.workspace_uuid] = _snapshot(
ineligible.workspace_uuid,
managed=False,
)
try:
private = await service.create_skill(
second,
{
'name': 'private',
'instructions': 'workspace-b secret',
},
)
own_skill = await service.create_skill(
first,
{
'name': 'runner',
'instructions': 'Run scripts/main.py',
},
)
await service.write_skill_file(first, 'runner', 'scripts/main.py', "print('ok')")
await service.write_skill_file(first, 'runner', 'requirements.txt', 'requests==2.32.0\n')
refreshed_skill = await service.get_skill(first, 'runner')
assert refreshed_skill is not None
assert refreshed_skill['python_project'] is True
await service.execute_tool(
{
'command': 'python /workspace/.skills/runner/scripts/main.py',
'workdir': '/workspace/.skills/runner',
},
_query(first, 91),
skill_name='runner',
)
mounted_spec = backend.started_specs[-1]
assert len(mounted_spec.extra_mounts) == 1
assert mounted_spec.extra_mounts[0].host_path == own_skill['package_root']
assert mounted_spec.extra_mounts[0].mount_path == '/workspace/.skills/runner'
assert mounted_spec.extra_mounts[0].mode.value == 'ro'
with pytest.raises(BoxAdmissionError, match='Scanning arbitrary host'):
await service.scan_skill_directory(first, private['package_root'])
with pytest.raises(BoxAdmissionError, match='package_root is runtime-owned'):
await service.create_skill(
first,
{
'name': 'stolen',
'package_root': private['package_root'],
},
)
assert await service.get_skill(first, 'private') is None
with pytest.raises(EntitlementUnavailableError):
await service.list_skills(ineligible)
finally:
server_task.cancel()
client_task.cancel()
await runtime.shutdown()
@pytest.mark.asyncio
async def test_forged_plan_network_session_and_managed_process_never_reach_runtime(tmp_path):
service, runtime, backend, entitlements, server_task, client_task = await _stack(tmp_path)
context = _context('workspace-a')
entitlements.snapshots[context.workspace_uuid] = _snapshot(context.workspace_uuid)
query = _query(context, 1)
try:
with pytest.raises(BoxAdmissionError, match='host-controlled'):
await service.execute_spec_payload(
{'cmd': 'true', 'session_id': 'global', 'plan': 'pro'},
query,
)
with pytest.raises(BoxAdmissionError, match='network access is disabled'):
await service.execute_spec_payload(
{'cmd': 'true', 'session_id': 'global', 'network': 'on'},
query,
)
with pytest.raises(BoxAdmissionError, match='session_id is runtime-owned'):
await service.execute_spec_payload(
{'cmd': 'true', 'session_id': 'attacker'},
query,
)
with pytest.raises(BoxAdmissionError, match='Managed processes are disabled'):
await service.start_managed_process(
context,
'global',
{'command': 'sleep', 'args': ['60']},
)
assert backend.started_specs == []
assert runtime.get_sessions() == []
finally:
server_task.cancel()
client_task.cancel()
await runtime.shutdown()
+7
View File
@@ -46,6 +46,13 @@ def build_ap() -> SimpleNamespace:
)
ap.apikey_service = SimpleNamespace(authenticate_api_key=authenticate_api_key)
@contextlib.asynccontextmanager
async def tenant_scope(workspace_uuid: str):
assert workspace_uuid == 'workspace-1'
yield
ap.persistence_mgr = SimpleNamespace(tenant_scope=tenant_scope)
ap.bot_service = SimpleNamespace(
get_bots=AsyncMock(return_value=[{'uuid': 'bot-1', 'name': 'Demo Bot', 'adapter': 'telegram'}])
)
@@ -1,5 +1,6 @@
from __future__ import annotations
import contextlib
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
@@ -101,6 +102,60 @@ async def test_public_webhook_error_uses_same_generic_error_contract():
assert 'do-not-return' not in (await response.get_data(as_text=True))
async def test_public_webhook_carries_scope_without_holding_database_session():
class ScopeOnlyPersistenceManager:
mode = SimpleNamespace(value='cloud_runtime')
def __init__(self):
self.active_workspace = None
@contextlib.asynccontextmanager
async def tenant_scope(self, workspace_uuid):
self.active_workspace = workspace_uuid
try:
yield
finally:
self.active_workspace = None
def current_session(self):
return None
persistence_mgr = ScopeOnlyPersistenceManager()
workspace_uuid = '00000000-0000-0000-0000-00000000000a'
bot_uuid = '11111111-1111-4111-8111-111111111111'
class Adapter:
async def handle_unified_webhook(self, **_kwargs):
assert persistence_mgr.active_workspace == workspace_uuid
assert persistence_mgr.current_session() is None
return {'ok': True}
async def get_execution_binding(resolved_workspace_uuid, expected_generation=None):
assert resolved_workspace_uuid == workspace_uuid
assert expected_generation == 4
runtime_bot = SimpleNamespace(
workspace_uuid=workspace_uuid,
placement_generation=4,
enable=True,
adapter=Adapter(),
)
application = SimpleNamespace(
logger=Mock(),
persistence_mgr=persistence_mgr,
platform_mgr=SimpleNamespace(resolve_public_bot=AsyncMock(return_value=runtime_bot)),
workspace_service=SimpleNamespace(get_execution_binding=get_execution_binding),
)
quart_app = quart.Quart(__name__)
await WebhookRouterGroup(application, quart_app).initialize()
response = await quart_app.test_client().post(f'/bots/{bot_uuid}')
assert response.status_code == 200
assert await response.get_json() == {'ok': True}
assert persistence_mgr.active_workspace is None
async def test_authentication_failure_does_not_return_internal_exception_text():
logger = Mock()
application = SimpleNamespace(
@@ -0,0 +1,73 @@
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
import quart
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.api.http.controller import group
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
pytestmark = pytest.mark.asyncio
async def test_authenticated_route_does_not_hold_database_session_during_external_wait():
entered = asyncio.Event()
release = asyncio.Event()
observations: list[bool] = []
engine = create_async_engine('sqlite+aiosqlite:///:memory:')
persistence = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
persistence.db = SimpleNamespace(get_engine=lambda: engine)
class BlockingRouter(group.RouterGroup):
name = 'blocking-route-test'
path = '/blocking-route-test'
async def initialize(self) -> None:
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _():
observations.append(persistence.current_session() is None)
entered.set()
await release.wait()
observations.append(persistence.current_session() is None)
return self.success(data={})
account = SimpleNamespace(uuid='account-a', user='owner@example.com')
access = SimpleNamespace(
execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=1),
workspace=SimpleNamespace(uuid='workspace-a'),
membership=SimpleNamespace(uuid='membership-a', role='owner', projection_revision=1),
)
application = SimpleNamespace(
persistence_mgr=persistence,
deployment=SimpleNamespace(multi_workspace_enabled=False),
user_service=SimpleNamespace(get_authenticated_account=AsyncMock(return_value=account)),
workspace_collaboration_service=SimpleNamespace(resolve_account_workspace=AsyncMock(return_value=access)),
logger=Mock(),
)
quart_app = quart.Quart(__name__)
await BlockingRouter(application, quart_app).initialize()
client = quart_app.test_client()
request = asyncio.create_task(
client.get(
'/blocking-route-test',
headers={'Authorization': 'Bearer token', 'X-Workspace-Id': 'workspace-a'},
)
)
try:
await entered.wait()
assert observations == [True]
release.set()
response = await request
assert response.status_code == 200
assert observations == [True, True]
finally:
release.set()
if not request.done():
await request
await engine.dispose()
@@ -166,6 +166,29 @@ async def test_revoked_expired_and_unknown_keys_fail_closed(api_key_context):
assert await service.authenticate_api_key(expired_secret) is None
async def test_revoke_winning_last_used_update_race_fails_authentication(api_key_context):
application, service, context, _engine = api_key_context
created = await service.create_api_key(context, 'Racing revoke')
original_execute = application.persistence_mgr.execute_async
injected_revoke = False
async def execute_with_revoke(statement, *args, **kwargs):
nonlocal injected_revoke
if (
not injected_revoke
and isinstance(statement, sqlalchemy.sql.dml.Update)
and statement.table.name == ApiKey.__tablename__
):
injected_revoke = True
await original_execute(sqlalchemy.update(ApiKey).where(ApiKey.id == created['id']).values(status='revoked'))
return await original_execute(statement, *args, **kwargs)
application.persistence_mgr.execute_async = execute_with_revoke
assert await service.authenticate_api_key(created['key']) is None
assert injected_revoke is True
async def test_cross_workspace_crud_and_secret_guessing_are_isolated(api_key_context):
application, service, first_context, engine = api_key_context
second_workspace_uuid = str(uuid.uuid4())
@@ -13,6 +13,7 @@ Source: src/langbot/pkg/api/http/service/maintenance.py
from __future__ import annotations
import contextlib
import pytest
from unittest.mock import AsyncMock, Mock, patch, MagicMock
from types import SimpleNamespace
@@ -196,6 +197,51 @@ class TestMaintenanceServiceCleanupExpiredFiles:
assert ap.logger.warning.called
assert 'uploaded_files' in result
async def test_cloud_cleanup_carries_scope_without_holding_database_session(self):
class ScopeOnlyPersistenceManager:
mode = SimpleNamespace(value='cloud_runtime')
def __init__(self):
self.active_workspace = None
@contextlib.asynccontextmanager
async def tenant_scope(self, workspace_uuid):
self.active_workspace = workspace_uuid
try:
yield
finally:
self.active_workspace = None
def current_session(self):
return None
persistence_mgr = ScopeOnlyPersistenceManager()
application = SimpleNamespace(
persistence_mgr=persistence_mgr,
instance_config=SimpleNamespace(data={}),
logger=SimpleNamespace(warning=Mock()),
)
service = MaintenanceService(application)
async def cleanup_uploads(_context, _retention_days):
assert persistence_mgr.active_workspace == TEST_CONTEXT.workspace_uuid
assert persistence_mgr.current_session() is None
return 2
def cleanup_logs(_retention_days):
assert persistence_mgr.active_workspace == TEST_CONTEXT.workspace_uuid
assert persistence_mgr.current_session() is None
return 1
service._cleanup_expired_uploaded_files = cleanup_uploads
service._cleanup_expired_log_files = cleanup_logs
assert await service.cleanup_expired_files(TEST_CONTEXT) == {
'uploaded_files': 2,
'log_files': 1,
}
assert persistence_mgr.active_workspace is None
class TestMaintenanceServiceGetStorageAnalysis:
"""Tests for get_storage_analysis method."""
@@ -13,6 +13,7 @@ Source: src/langbot/pkg/api/http/service/mcp.py
from __future__ import annotations
import asyncio
import copy
import pytest
from unittest.mock import AsyncMock, Mock, MagicMock
@@ -485,6 +486,54 @@ class TestMCPServiceCreateMCPServer:
# Verify - host_mcp_server was called
ap.tool_mgr.mcp_tool_loader.host_mcp_server.assert_called_once()
async def test_create_mcp_server_does_not_start_host_until_transaction_commits(self):
"""The Runtime must not observe a server row that can still roll back."""
gate = asyncio.get_running_loop().create_future()
class PersistenceManagerStub:
def create_after_commit_gate(self):
return gate
ap = SimpleNamespace()
ap.persistence_mgr = PersistenceManagerStub()
ap.instance_config = SimpleNamespace(data={'system': {'limitation': {'max_extensions': -1}}})
observed = []
async def host_mcp_server(context, config):
observed.append((context, config))
ap.tool_mgr = SimpleNamespace(
mcp_tool_loader=SimpleNamespace(
host_mcp_server=host_mcp_server,
_hosted_mcp_tasks=[],
)
)
server_entity = _create_mock_mcp_server(server_uuid='new-uuid', enable=True)
results = [
_create_mock_result([]),
Mock(),
_create_mock_result(first_item=server_entity),
]
ap.persistence_mgr.execute_async = AsyncMock(side_effect=results)
ap.persistence_mgr.serialize_model = Mock(
return_value={'uuid': 'new-uuid', 'name': 'New Server', 'enable': True}
)
service = _service(ap)
await service.create_mcp_server(_CONTEXT, {'name': 'New Server', 'enable': True})
await asyncio.sleep(0)
assert observed == []
gate.set_result(None)
await ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks[0]
assert observed == [
(
_CONTEXT,
{'uuid': 'new-uuid', 'name': 'New Server', 'enable': True},
)
]
async def test_create_mcp_server_disabled_no_load(self):
"""Does not load server when disabled."""
# Setup
@@ -11,7 +11,9 @@ from langbot.pkg.api.http.authz import WorkspaceRequiredError
from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.api.http.service.monitoring import MonitoringService
from langbot.pkg.entity.persistence.base import Base
from langbot.pkg.entity.persistence.monitoring import MonitoringMessage
from langbot.pkg.entity.persistence.workspace import Workspace
from langbot.pkg.persistence.mgr import PersistenceManager
pytestmark = pytest.mark.asyncio
@@ -136,6 +138,26 @@ async def test_same_session_and_resource_ids_do_not_collide(service):
assert (await service.get_message_details(context_a, message_b))['found'] is False
async def test_tool_call_inherits_context_from_connection_message_row(service):
context = _context(WORKSPACE_A)
message_id = await _record_message(service, context, 'tool context')
await service.record_tool_call(
context,
tool_name='search',
tool_source='native',
duration=12,
message_id=message_id,
)
tool_calls, total = await service.get_tool_calls(context)
assert total == 1
assert tool_calls[0]['bot_id'] == 'same-bot'
assert tool_calls[0]['pipeline_id'] == 'same-pipeline'
assert tool_calls[0]['session_id'] == 'same-session'
assert tool_calls[0]['message_id'] == message_id
async def test_feedback_upsert_and_cancel_are_workspace_scoped(service):
context_a = _context(WORKSPACE_A)
context_b = _context(WORKSPACE_B)
@@ -152,3 +174,58 @@ async def test_feedback_upsert_and_cancel_are_workspace_scoped(service):
await service.record_feedback(context_a, feedback_id='same-feedback', feedback_type=3)
assert (await service.get_feedback_stats(context_a))['total_feedback'] == 0
assert (await service.get_feedback_stats(context_b))['total_feedback'] == 1
async def test_cleanup_commits_sqlite_delete_before_vacuum(tmp_path):
engine = create_async_engine(
f'sqlite+aiosqlite:///{tmp_path / "monitoring-cleanup.db"}',
connect_args={'timeout': 0.1},
)
application = SimpleNamespace(
instance_config=SimpleNamespace(data={'database': {'use': 'sqlite'}}),
)
manager = PersistenceManager(application)
manager.db = SimpleNamespace(get_engine=lambda: engine)
application.persistence_mgr = manager
try:
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
await connection.execute(
sqlalchemy.insert(Workspace).values(
uuid=WORKSPACE_A,
instance_uuid='instance',
name='A',
slug='a',
source='cloud_projection',
)
)
await connection.execute(
sqlalchemy.insert(MonitoringMessage).values(
id='expired-message',
workspace_uuid=WORKSPACE_A,
timestamp=datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
- datetime.timedelta(days=30),
bot_id='bot',
bot_name='Bot',
pipeline_id='pipeline',
pipeline_name='Pipeline',
message_content='expired',
session_id='session',
status='success',
level='info',
)
)
deleted = await MonitoringService(application).cleanup_expired_records(
_context(WORKSPACE_A),
retention_days=1,
)
assert deleted['monitoring_messages'] == 1
async with engine.connect() as connection:
remaining = await connection.scalar(
sqlalchemy.select(sqlalchemy.func.count()).select_from(MonitoringMessage)
)
assert remaining == 0
finally:
await engine.dispose()
+11 -4
View File
@@ -45,9 +45,16 @@ async def test_verify_api_key_keeps_db_validation_for_lbk_keys(key_exists):
if key_exists
else None
)
query_result = Mock()
query_result.first.return_value = key
persistence_mgr = SimpleNamespace(execute_async=AsyncMock(side_effect=[query_result, Mock(rowcount=1)]))
discovery_result = Mock()
discovery_result.first.return_value = key
query_results = [discovery_result]
if key_exists:
scoped_result = Mock()
scoped_result.first.return_value = key
update_result = Mock()
update_result.scalar_one_or_none.return_value = key.id
query_results.extend([scoped_result, update_result])
persistence_mgr = SimpleNamespace(execute_async=AsyncMock(side_effect=query_results))
instance_config = SimpleNamespace(data={'api': {'global_api_key': ''}})
workspace_service = SimpleNamespace(
get_execution_binding=AsyncMock(
@@ -69,7 +76,7 @@ async def test_verify_api_key_keeps_db_validation_for_lbk_keys(key_exists):
result = await service.verify_api_key('lbk_valid_format')
assert result is key_exists
assert persistence_mgr.execute_async.await_count == (2 if key_exists else 1)
assert persistence_mgr.execute_async.await_count == (3 if key_exists else 1)
if key_exists:
workspace_service.get_execution_binding.assert_awaited_once_with('workspace-a')
else:
@@ -0,0 +1,121 @@
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.api.http.service.apikey import ApiKeyIdentity
from langbot.pkg.api.mcp.context import get_request_context
from langbot.pkg.api.mcp.mount import MCPMount
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
from langbot.pkg.persistence.tenant_uow import PersistenceScopeKind
@pytest.mark.asyncio
async def test_mcp_mount_keeps_request_context_but_no_session_during_stream_wait(tmp_path) -> None:
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "mcp-short-scope.db"}')
table = sa.Table('mcp_scope_probe', sa.MetaData(), sa.Column('id', sa.Integer, primary_key=True))
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
manager.db = SimpleNamespace(get_engine=lambda: engine)
checked_out = 0
def on_checkout(*_args):
nonlocal checked_out
checked_out += 1
def on_checkin(*_args):
nonlocal checked_out
checked_out -= 1
sa.event.listen(engine.sync_engine, 'checkout', on_checkout)
sa.event.listen(engine.sync_engine, 'checkin', on_checkin)
try:
async with engine.begin() as conn:
await conn.run_sync(table.metadata.create_all)
identity = ApiKeyIdentity(
instance_uuid='instance-1',
workspace_uuid='workspace-1',
placement_generation=7,
api_key_uuid='key-1',
permissions=frozenset({'pipelines:read'}),
)
app = SimpleNamespace(
apikey_service=SimpleNamespace(authenticate_api_key=AsyncMock(return_value=identity)),
persistence_mgr=manager,
deployment_admission=None,
deployment=None,
)
stream_waiting = asyncio.Event()
release_stream = asyncio.Event()
observations: list[tuple[str, str, bool]] = []
async def fake_mcp_asgi(scope, receive, send):
del scope, receive
context = get_request_context()
assert manager.current_scope().kind is PersistenceScopeKind.WORKSPACE
assert manager.current_session() is None
await manager.execute_async(sa.select(table.c.id))
assert manager.current_session() is None
observations.append((context.request_id, context.workspace_uuid, manager.current_session() is None))
stream_waiting.set()
await release_stream.wait()
preserved_context = get_request_context()
observations.append(
(
preserved_context.request_id,
preserved_context.workspace_uuid,
manager.current_session() is None,
)
)
await manager.execute_async(sa.select(table.c.id))
assert manager.current_session() is None
await send({'type': 'http.response.start', 'status': 200, 'headers': []})
await send({'type': 'http.response.body', 'body': b'{}'})
async def unused_quart_asgi(scope, receive, send):
del scope, receive, send
raise AssertionError('MCP request was routed to Quart')
mount = MCPMount.__new__(MCPMount)
mount.ap = app
mount._mcp_asgi = fake_mcp_asgi
sent_messages: list[dict] = []
async def receive():
return {'type': 'http.request', 'body': b'', 'more_body': False}
async def send(message):
sent_messages.append(message)
async def release_after_observation() -> None:
await asyncio.wait_for(stream_waiting.wait(), timeout=2)
assert checked_out == 0
release_stream.set()
release_task = asyncio.create_task(release_after_observation())
await mount.wrap(unused_quart_asgi)(
{
'type': 'http',
'path': '/mcp',
'headers': [(b'x-api-key', b'secret')],
},
receive,
send,
)
await release_task
assert sent_messages[0]['status'] == 200
assert len(observations) == 2
assert observations[0] == observations[1]
assert observations[0][1:] == ('workspace-1', True)
assert checked_out == 0
assert manager.current_scope() is None
with pytest.raises(RuntimeError, match='context is unavailable'):
get_request_context()
finally:
await engine.dispose()
@@ -1,5 +1,6 @@
from __future__ import annotations
from contextlib import asynccontextmanager
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
@@ -106,3 +107,33 @@ async def test_background_plugin_operation_refences_captured_generation(plugin_r
await router._run_fenced_plugin_operation(CONTEXT, operation)
operation.assert_not_awaited()
@pytest.mark.asyncio
async def test_background_plugin_operation_revalidates_inside_short_tenant_uow(plugin_router_cls):
scopes = []
@asynccontextmanager
async def tenant_uow(workspace_uuid):
scopes.append(workspace_uuid)
yield
connector = SimpleNamespace(
require_workspace_context=AsyncMock(side_effect=lambda context: context),
)
operation = AsyncMock(return_value='done')
router = object.__new__(plugin_router_cls)
router.ap = SimpleNamespace(
plugin_connector=connector,
persistence_mgr=SimpleNamespace(
mode=SimpleNamespace(value='cloud_runtime'),
tenant_uow=tenant_uow,
),
)
result = await router._run_fenced_plugin_operation(CONTEXT, operation)
assert result == 'done'
assert scopes == [CONTEXT.workspace_uuid]
connector.require_workspace_context.assert_awaited_once_with(CONTEXT)
operation.assert_awaited_once()
@@ -0,0 +1,69 @@
from __future__ import annotations
from contextlib import asynccontextmanager
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from langbot.pkg.api.http.context import (
PrincipalContext,
PrincipalType,
RequestContext,
WorkspaceContext,
)
from langbot.pkg.api.http.controller.groups.pipelines.websocket_chat import WebSocketChatRouterGroup
@pytest.mark.asyncio
async def test_websocket_pipeline_lookup_opens_workspace_uow_after_auth_scope_closed() -> None:
workspace_uuid = 'workspace-a'
scopes: list[str] = []
in_scope = False
@asynccontextmanager
async def tenant_uow(selected_workspace_uuid: str):
nonlocal in_scope
assert not in_scope
in_scope = True
scopes.append(selected_workspace_uuid)
try:
yield
finally:
in_scope = False
async def get_pipeline(_context, _pipeline_uuid):
assert in_scope
return {'uuid': 'pipeline-a'}
adapter = Mock()
router = object.__new__(WebSocketChatRouterGroup)
router.ap = SimpleNamespace(
persistence_mgr=SimpleNamespace(
mode=SimpleNamespace(value='cloud_runtime'),
tenant_uow=tenant_uow,
),
pipeline_service=SimpleNamespace(get_pipeline=AsyncMock(side_effect=get_pipeline)),
platform_mgr=SimpleNamespace(get_websocket_proxy_bot=AsyncMock(return_value=SimpleNamespace(adapter=adapter))),
)
request_context = RequestContext(
instance_uuid='instance-a',
placement_generation=1,
request_id='request-a',
auth_type='user_token',
principal=PrincipalContext(
principal_type=PrincipalType.ACCOUNT,
account_uuid='account-a',
),
workspace=WorkspaceContext(
workspace_uuid=workspace_uuid,
membership_uuid='membership-a',
role='owner',
permissions=frozenset(),
),
)
result = await router._get_scoped_adapter(request_context, 'pipeline-a')
assert result is adapter
assert scopes == [workspace_uuid]
+230
View File
@@ -0,0 +1,230 @@
from __future__ import annotations
import datetime as dt
import hashlib
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from langbot_plugin.box.models import SandboxAdmissionPolicy
from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.box.admission import (
BoxAdmissionError,
SandboxAdmissionController,
require_cloud_admission_policy,
)
from langbot.pkg.box.service import BoxService
from langbot.pkg.cloud.entitlements import (
EntitlementResolver,
EntitlementSnapshot,
EntitlementUnavailableError,
)
_UTC = dt.timezone.utc
_CONTEXT = ExecutionContext(
instance_uuid='instance-a',
workspace_uuid='workspace-a',
placement_generation=3,
entitlement_revision=7,
)
def _snapshot(
*,
revision: int = 7,
managed: bool = True,
sessions: int = 1,
expires_at: int = 2_000,
) -> EntitlementSnapshot:
return EntitlementSnapshot(
instance_uuid='instance-a',
workspace_uuid='workspace-a',
entitlement_revision=revision,
status='active',
not_before=1,
expires_at=expires_at,
features={'managed_sandbox': managed},
limits={'managed_sandbox_sessions': sessions},
)
def _controller(snapshot: EntitlementSnapshot, *, now: float = 1_000.25):
provider = SimpleNamespace(get_workspace_entitlement=AsyncMock(return_value=snapshot))
resolver = EntitlementResolver('instance-a', provider)
client = SimpleNamespace(
upsert_sandbox_admission_grant=AsyncMock(
side_effect=lambda grant: {
'installed': True,
'workspace_uuid': grant.workspace_uuid,
'execution_generation': grant.execution_generation,
'entitlement_revision': grant.entitlement_revision,
'max_sessions': grant.max_sessions,
'max_managed_processes': grant.max_managed_processes,
}
),
revoke_sandbox_admission_grant=AsyncMock(
side_effect=lambda revocation: {
'revoked': True,
'workspace_uuid': revocation.workspace_uuid,
'entitlement_revision': revocation.entitlement_revision,
}
),
)
app = SimpleNamespace(entitlement_resolver=resolver, logger=Mock())
controller = SandboxAdmissionController(
app,
client,
policy=SandboxAdmissionPolicy(required=True, max_grant_ttl_sec=300),
wall_time=lambda: now,
)
return controller, client, provider
def test_cloud_admission_policy_requires_positive_workspace_quota():
with pytest.raises(BoxAdmissionError, match='workspace quota must be a positive integer'):
require_cloud_admission_policy(
{
'required': True,
'workspace_quota_mb': 0,
}
)
policy = require_cloud_admission_policy(
{
'required': True,
'workspace_quota_mb': 32,
}
)
assert policy.workspace_quota_mb == 32
@pytest.mark.asyncio
async def test_active_generic_entitlement_installs_short_lived_numeric_grant():
controller, client, provider = _controller(_snapshot())
grant = await controller.require(_CONTEXT)
assert grant.instance_uuid == _CONTEXT.instance_uuid
assert grant.workspace_uuid == _CONTEXT.workspace_uuid
assert grant.execution_generation == _CONTEXT.placement_generation
assert grant.entitlement_revision == 7
assert grant.max_sessions == 1
assert grant.max_managed_processes == 0
assert grant.expires_at == dt.datetime.fromtimestamp(1_300, tz=_UTC)
assert (grant.expires_at - dt.datetime.fromtimestamp(1_000.25, tz=_UTC)).total_seconds() < 300
provider.get_workspace_entitlement.assert_awaited_once_with('workspace-a')
client.upsert_sandbox_admission_grant.assert_awaited_once_with(grant)
client.revoke_sandbox_admission_grant.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize(
'snapshot',
[
_snapshot(managed=False),
_snapshot(sessions=0),
_snapshot(sessions=2),
],
)
async def test_non_eligible_entitlement_revokes_and_fails_closed(snapshot):
controller, client, _provider = _controller(snapshot)
with pytest.raises(EntitlementUnavailableError):
await controller.require(_CONTEXT)
client.upsert_sandbox_admission_grant.assert_not_awaited()
revocation = client.revoke_sandbox_admission_grant.await_args.args[0]
assert revocation.entitlement_revision == snapshot.entitlement_revision
@pytest.mark.asyncio
async def test_transient_entitlement_failure_does_not_tombstone_valid_revision():
controller, client, provider = _controller(_snapshot())
await controller.require(_CONTEXT)
provider.get_workspace_entitlement.side_effect = RuntimeError('control plane unavailable')
with pytest.raises(RuntimeError, match='control plane unavailable'):
await controller.require(_CONTEXT)
client.revoke_sandbox_admission_grant.assert_not_awaited()
provider.get_workspace_entitlement.side_effect = None
provider.get_workspace_entitlement.return_value = _snapshot()
recovered = await controller.require(_CONTEXT)
assert recovered.entitlement_revision == 7
@pytest.mark.asyncio
async def test_runtime_receipt_mismatch_is_revoked_and_never_admitted():
controller, client, _provider = _controller(_snapshot())
client.upsert_sandbox_admission_grant.return_value = {'installed': True, 'workspace_uuid': 'other'}
client.upsert_sandbox_admission_grant.side_effect = None
with pytest.raises(Exception, match='invalid sandbox admission receipt'):
await controller.require(_CONTEXT)
client.revoke_sandbox_admission_grant.assert_not_awaited()
@pytest.mark.asyncio
async def test_authoritative_cancelled_revision_is_revoked():
cancelled = _snapshot(revision=8).model_copy(update={'status': 'cancelled'})
controller, client, _provider = _controller(cancelled)
with pytest.raises(EntitlementUnavailableError, match='not active'):
await controller.require(_CONTEXT)
revocation = client.revoke_sandbox_admission_grant.await_args.args[0]
assert revocation.entitlement_revision == 8
@pytest.mark.asyncio
async def test_cloud_box_readiness_failure_aborts_service_initialization(tmp_path):
workspace_root = tmp_path / 'box' / 'workspaces'
workspace_root.mkdir(parents=True)
box_config = {
'enabled': True,
'backend': 'nsjail',
'runtime': {'endpoint': 'ws://box:5410'},
'local': {
'host_root': str(tmp_path / 'box'),
'default_workspace': str(workspace_root),
'allowed_mount_roots': [str(tmp_path / 'box')],
},
'admission': {
'required': True,
'logical_session_id': 'global',
'required_backend': 'nsjail',
'max_sessions': 1,
'max_managed_processes': 0,
'max_grant_ttl_sec': 300,
'workspace_quota_mb': 32,
},
}
client = SimpleNamespace(
initialize=AsyncMock(),
verify_shared_workspace=AsyncMock(
side_effect=lambda marker_name: {
'marker_name': marker_name,
'size': (workspace_root / marker_name).stat().st_size,
'sha256': hashlib.sha256((workspace_root / marker_name).read_bytes()).hexdigest(),
}
),
get_backend_info=AsyncMock(return_value={'name': 'docker', 'available': True}),
)
app = SimpleNamespace(
logger=Mock(),
deployment=SimpleNamespace(multi_workspace_enabled=True),
entitlement_resolver=Mock(),
workspace_service=SimpleNamespace(instance_uuid='instance-a'),
instance_config=SimpleNamespace(data={'box': box_config}),
)
service = BoxService(app, client=client)
with pytest.raises(Exception, match='nsjail isolation readiness failed'):
await service.initialize()
assert service.available is False
+138 -13
View File
@@ -117,6 +117,9 @@ class _InProcessBoxRuntimeClient(BoxRuntimeClient):
async def init(self, config: dict) -> None:
self._runtime.init(config)
async def verify_shared_workspace(self, marker_name: str) -> dict:
return self._runtime.verify_shared_workspace(marker_name)
class FakeBackend(BaseSandboxBackend):
def __init__(self, logger: Mock, available: bool = True):
@@ -322,6 +325,43 @@ def test_separated_box_runtime_does_not_create_default_workspace_in_langbot(tmp_
assert not (host_root / 'default').exists()
@pytest.mark.asyncio
async def test_cloud_initialize_fails_when_core_and_runtime_volumes_are_separated(tmp_path):
logger = Mock()
core_root = tmp_path / 'core-box'
runtime_root = tmp_path / 'runtime-box'
(core_root / 'default').mkdir(parents=True)
runtime = BoxRuntime(logger=logger, backends=[FakeBackend(logger)], session_ttl_sec=300)
runtime.init(
{
'local': {
'host_root': str(runtime_root),
'default_workspace': 'default',
'allowed_mount_roots': [str(runtime_root)],
}
}
)
app = make_app(logger, host_root=str(core_root))
app.deployment = SimpleNamespace(multi_workspace_enabled=True)
app.instance_config.data['box'].update(
{
'backend': 'nsjail',
'admission': {'required': True, 'workspace_quota_mb': 32},
}
)
service = BoxService(
app,
client=_InProcessBoxRuntimeClient(logger, runtime),
)
with pytest.raises(BoxValidationError, match='shared durable Workspace volume'):
await service.initialize()
assert service.available is False
assert list((core_root / 'default').glob('.langbot-box-volume-probe-*')) == []
await runtime.shutdown()
def test_separated_box_runtime_allows_box_owned_missing_host_path(tmp_path):
logger = Mock()
runtime = BoxRuntime(logger=logger, backends=[FakeBackend(logger)], session_ttl_sec=300)
@@ -1888,7 +1928,7 @@ class TestInboundOutboundRoundTrip:
assert '/workspace/inbox/' in parameters['command']
return {
'ok': True,
'stdout': '["/workspace/inbox/42/image_1.png"]',
'stdout': '["/workspace/inbox/query-42/image_1.png"]',
'stderr': '',
}
@@ -1898,7 +1938,7 @@ class TestInboundOutboundRoundTrip:
assert len(descriptors) == 1
d = descriptors[0]
assert d['type'] == 'Image'
assert d['path'] == '/workspace/inbox/42/image_1.png'
assert d['path'] == '/workspace/inbox/query-42/image_1.png'
assert d['size'] == len(img_bytes)
@pytest.mark.asyncio
@@ -2011,7 +2051,7 @@ class TestAttachmentHostPath:
assert d['type'] == 'Image'
assert d['size'] == len(big)
# File actually landed on the host workspace.
host_file = os.path.join(ws, 'inbox', str(query.query_id), d['name'])
host_file = os.path.join(ws, 'inbox', str(query.query_uuid), d['name'])
assert os.path.isfile(host_file)
assert open(host_file, 'rb').read() == big
@@ -2023,7 +2063,7 @@ class TestAttachmentHostPath:
service, ws = self._service_with_workspace(tmp_path)
# Seed a stale file under the same query_id (simulates webchat id reuse).
stale_dir = os.path.join(ws, 'inbox', '42')
stale_dir = os.path.join(ws, 'inbox', 'query-42')
os.makedirs(stale_dir, exist_ok=True)
open(os.path.join(stale_dir, 'image_1.png'), 'wb').write(b'STALE-OLD-IMAGE')
@@ -2039,11 +2079,40 @@ class TestAttachmentHostPath:
# No leftover content from the stale image.
assert b'STALE-OLD-IMAGE' not in open(host_file, 'rb').read()
@pytest.mark.asyncio
async def test_inbound_host_replaces_query_symlink_without_touching_other_workspace(self, tmp_path):
import base64
import langbot_plugin.api.entities.builtin.platform.message as platform_message
service, ws = self._service_with_workspace(tmp_path)
other_workspace = tmp_path / 'other-workspace'
other_workspace.mkdir()
protected = other_workspace / 'protected.txt'
protected.write_bytes(b'workspace-b-secret')
inbox = os.path.join(ws, 'inbox')
os.makedirs(inbox, exist_ok=True)
os.symlink(other_workspace, os.path.join(inbox, 'query-42'))
query = make_query()
payload = b'workspace-a-input'
query.message_chain = platform_message.MessageChain(
[platform_message.File(name='input.bin', base64=base64.b64encode(payload).decode())]
)
service.execute_tool = AsyncMock(side_effect=AssertionError('exec must not be used on host path'))
descriptors = await service.materialize_inbound_attachments(query)
assert descriptors[0]['path'] == '/workspace/inbox/query-42/input.bin'
assert protected.read_bytes() == b'workspace-b-secret'
assert not os.path.islink(os.path.join(inbox, 'query-42'))
assert open(os.path.join(inbox, 'query-42', 'input.bin'), 'rb').read() == payload
@pytest.mark.asyncio
async def test_outbound_reads_host_and_clears(self, tmp_path):
service, ws = self._service_with_workspace(tmp_path)
query = make_query()
outbox = os.path.join(ws, 'outbox', str(query.query_id))
outbox = os.path.join(ws, 'outbox', str(query.query_uuid))
os.makedirs(outbox, exist_ok=True)
# A large file that would be truncated on the exec/stdout path:
big_png = b'\x89PNG\r\n\x1a\n' + b'y' * (400 * 1024)
@@ -2063,13 +2132,69 @@ class TestAttachmentHostPath:
# Outbox cleared after collection.
assert os.listdir(outbox) == []
@pytest.mark.asyncio
async def test_outbound_host_never_follows_query_or_file_symlinks(self, tmp_path):
service, ws = self._service_with_workspace(tmp_path)
query = make_query()
other_workspace = tmp_path / 'other-workspace'
other_workspace.mkdir()
secret = other_workspace / 'secret.txt'
secret.write_bytes(b'workspace-b-secret')
outbox_root = os.path.join(ws, 'outbox')
os.makedirs(outbox_root, exist_ok=True)
# A hostile query-directory replacement is rejected rather than read.
query_dir = os.path.join(outbox_root, str(query.query_uuid))
os.symlink(other_workspace, query_dir)
with pytest.raises(BoxValidationError, match='symbolic link'):
await service.collect_outbound_attachments(query)
assert secret.read_bytes() == b'workspace-b-secret'
os.unlink(query_dir)
os.makedirs(query_dir)
os.symlink(secret, os.path.join(query_dir, 'leak.txt'))
service.execute_tool = AsyncMock(side_effect=AssertionError('exec must not be used on host path'))
assert await service.collect_outbound_attachments(query) == []
assert secret.read_bytes() == b'workspace-b-secret'
@pytest.mark.asyncio
async def test_outbound_host_fails_closed_on_inode_bomb(self, tmp_path):
service, ws = self._service_with_workspace(tmp_path)
query = make_query()
outbox = os.path.join(ws, 'outbox', str(query.query_uuid))
os.makedirs(outbox, exist_ok=True)
harmless_target = tmp_path / 'harmless-target'
harmless_target.write_bytes(b'x')
# Symlinks do not count toward the 20 returned files, so this proves
# traversal itself has a bounded entry budget.
for index in range(513):
os.symlink(harmless_target, os.path.join(outbox, f'entry-{index}'))
with pytest.raises(BoxValidationError, match='symbolic link'):
await service.collect_outbound_attachments(query)
assert harmless_target.read_bytes() == b'x'
def test_host_attachment_directories_use_query_uuid_not_process_local_id(self, tmp_path):
service, _ws = self._service_with_workspace(tmp_path)
first = make_query(query_id=7)
second = make_query(query_id=7)
object.__setattr__(first, 'query_uuid', 'replica-a-query')
object.__setattr__(second, 'query_uuid', 'replica-b-query')
first_path = service._host_query_dir(service.OUTBOX_SUBDIR, first)
second_path = service._host_query_dir(service.OUTBOX_SUBDIR, second)
assert first_path is not None and first_path.endswith('/outbox/replica-a-query')
assert second_path is not None and second_path.endswith('/outbox/replica-b-query')
assert first_path != second_path
@pytest.mark.asyncio
async def test_outbound_empty_clears_stale_host_dir(self, tmp_path):
# Reusing a query_id (counter resets on restart) must not re-send files
# a previous run left in the outbox: an empty collection still clears it.
service, ws = self._service_with_workspace(tmp_path)
query = make_query()
outbox = os.path.join(ws, 'outbox', str(query.query_id))
outbox = os.path.join(ws, 'outbox', str(query.query_uuid))
os.makedirs(outbox, exist_ok=True)
# Stale file from a prior turn; the agent produced nothing this turn —
# but _read_outbox_host would still pick it up, so collection must drop
@@ -2118,16 +2243,16 @@ class TestAttachmentHostPath:
os.makedirs(os.path.join(outbox, '0'), exist_ok=True)
# Simulate a host delete that cannot remove the root-owned outbox.
import shutil as _shutil
from langbot.pkg.box import secure_fs
real_rmtree = _shutil.rmtree
real_purge = secure_fs.purge_subdirectory
def fake_rmtree(path, *a, **k):
if os.path.abspath(path) == os.path.abspath(outbox):
return # "permission denied" — silently leaves the dir
return real_rmtree(path, *a, **k)
def fake_purge(root, subdir):
if os.path.abspath(os.path.join(root, subdir)) == os.path.abspath(outbox):
raise PermissionError('root-owned')
return real_purge(root, subdir)
monkeypatch.setattr(_shutil, 'rmtree', fake_rmtree)
monkeypatch.setattr(secure_fs, 'purge_subdirectory', fake_purge)
service.build_spec = Mock()
service.client.execute = AsyncMock()
+156 -1
View File
@@ -1,11 +1,14 @@
from __future__ import annotations
import dataclasses
from types import SimpleNamespace
import pytest
from langbot.pkg.cloud.bootstrap import (
CloudBootstrapError,
CloudRuntimeUnavailableError,
DeploymentAdmissionGuard,
OpenSourceDeployment,
VerifiedCloudDeployment,
resolve_deployment,
@@ -62,8 +65,34 @@ class _EntryPoints(list):
def _cloud_config() -> dict:
return {
'database': {'use': 'postgresql'},
'vdb': {'use': 'pgvector'},
'vdb': {
'use': 'pgvector',
'pgvector': {
'use_business_database': True,
'allowed_dimensions': [384, 768, 1536],
},
},
'mcp': {'stdio': {'enabled': False}},
'plugin': {'worker': {'require_hard_limits': True}},
'box': {
'enabled': True,
'backend': 'nsjail',
'runtime': {'endpoint': 'ws://langbot-box:5410'},
'admission': {
'required': True,
'logical_session_id': 'global',
'required_backend': 'nsjail',
'max_sessions': 1,
'max_managed_processes': 0,
'max_grant_ttl_sec': 300,
'workspace_quota_mb': 32,
},
'local': {
'host_root': '/var/lib/langbot/box',
'default_workspace': '/var/lib/langbot/box/workspaces',
'allowed_mount_roots': ['/var/lib/langbot/box'],
},
},
# Proves mutable product metadata does not participate in selection.
'system': {'edition': 'community'},
}
@@ -99,6 +128,7 @@ async def test_verified_closed_entry_point_activates_cloud_policy():
('database', {'use': 'sqlite'}, 'database.use=postgresql'),
('vdb', {'use': 'chroma'}, 'vdb.use=pgvector'),
('mcp', {'stdio': {'enabled': True}}, 'mcp.stdio.enabled=false'),
('plugin', {'worker': {'require_hard_limits': False}}, 'plugin.worker.require_hard_limits=true'),
],
)
async def test_cloud_runtime_config_is_fail_closed(field, value, message):
@@ -114,6 +144,79 @@ async def test_cloud_runtime_config_is_fail_closed(field, value, message):
)
@pytest.mark.parametrize(
('pgvector_config', 'message'),
[
({'use_business_database': False, 'allowed_dimensions': [1536]}, 'use_business_database=true'),
({'use_business_database': True, 'allowed_dimensions': []}, 'allowed_dimensions'),
({'use_business_database': True, 'allowed_dimensions': [3072]}, 'allowed_dimensions'),
({'use_business_database': True, 'allowed_dimensions': [True]}, 'allowed_dimensions'),
],
)
async def test_cloud_pgvector_contract_is_fail_closed(pgvector_config, message):
config = _cloud_config()
config['vdb']['pgvector'] = pgvector_config
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,
)
@pytest.mark.parametrize(
('mutate', 'message'),
[
(lambda config: config['box'].update(enabled=False), 'box.enabled=true'),
(lambda config: config['box'].update(backend='docker'), 'box.backend=nsjail'),
(lambda config: config['box']['runtime'].update(endpoint=''), 'box.runtime.endpoint'),
(
lambda config: config['box']['admission'].update(max_sessions=2),
'grant-enforced Box admission',
),
(
lambda config: config['box']['admission'].update(max_managed_processes=1),
'zero managed processes',
),
(
lambda config: config['box']['admission'].update(max_grant_ttl_sec=301),
'max_grant_ttl_sec',
),
(
lambda config: config['box']['admission'].update(workspace_quota_mb=0),
'workspace_quota_mb must be a positive integer',
),
(
lambda config: config['box']['admission'].update(workspace_quota_mb=True),
'workspace_quota_mb must be a positive integer',
),
(
lambda config: config['box']['local'].update(default_workspace='relative/workspaces'),
'default_workspace must be an absolute',
),
(
lambda config: config['box']['local'].update(
default_workspace='/other/workspaces',
),
'under allowed_mount_roots',
),
],
)
async def test_cloud_box_contract_is_fail_closed(mutate, message):
config = _cloud_config()
mutate(config)
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())
@@ -134,3 +237,55 @@ async def test_duplicate_closed_providers_fail_closed():
entry_points=lambda: _EntryPoints([_EntryPoint(_Provider()), _EntryPoint(_Provider())]),
now=1_000,
)
async def test_deployment_admission_expires_even_after_wall_clock_rollback():
wall = [1_000.0]
monotonic = [50.0]
deployment = dataclasses.replace(
_Provider().bootstrap(instance_uuid='instance-a', instance_config={}),
expires_at=1_010,
)
guard = DeploymentAdmissionGuard(
'instance-a',
deployment,
wall_time=lambda: wall[0],
monotonic_time=lambda: monotonic[0],
)
assert guard.require_active() is deployment
wall[0] = 900.0
monotonic[0] = 60.0
with pytest.raises(CloudRuntimeUnavailableError, match='expired'):
guard.require_active()
async def test_deployment_admission_accepts_only_monotonic_non_conflicting_renewal():
wall = [1_000.0]
monotonic = [50.0]
current = dataclasses.replace(
_Provider().bootstrap(instance_uuid='instance-a', instance_config={}),
expires_at=1_010,
)
guard = DeploymentAdmissionGuard(
'instance-a',
current,
wall_time=lambda: wall[0],
monotonic_time=lambda: monotonic[0],
)
renewed = dataclasses.replace(
current,
manifest_jti='manifest-b',
manifest_generation=4,
expires_at=2_000,
)
guard.replace(renewed)
assert guard.require_active() is renewed
rollback = dataclasses.replace(current, manifest_generation=2)
with pytest.raises(CloudRuntimeUnavailableError, match='rolled back'):
guard.replace(rollback)
conflicting = dataclasses.replace(renewed, manifest_jti='different')
with pytest.raises(CloudRuntimeUnavailableError, match='conflicting'):
guard.replace(conflicting)
@@ -84,3 +84,26 @@ async def test_resolver_rejects_same_revision_with_different_contents():
await resolver.resolve('workspace-a', now=150)
with pytest.raises(EntitlementUnavailableError, match='conflicting contents'):
await resolver.resolve('workspace-a', now=150)
@pytest.mark.asyncio
async def test_resolver_checks_deployment_admission_before_and_after_provider_call():
checks = 0
def require_admission() -> None:
nonlocal checks
checks += 1
if checks == 2:
raise RuntimeError('manifest expired during provider call')
provider = AsyncMock()
provider.get_workspace_entitlement = AsyncMock(return_value=_snapshot())
resolver = EntitlementResolver(
'instance-a',
provider,
deployment_admission=require_admission,
)
with pytest.raises(RuntimeError, match='expired during provider call'):
await resolver.resolve('workspace-a', now=150)
assert checks == 2
+29
View File
@@ -35,6 +35,22 @@ class TestApplyEnvOverridesToConfig:
assert result['system']['name'] == 'custom_name'
def test_override_log_never_prints_secret_value(self, capsys):
"""Environment-backed credentials must not be copied into logs."""
load_config = get_load_config_module()
secret = 'database-password-that-must-not-leak'
cfg = {'database': {'postgresql': {'password': ''}}}
env = {'DATABASE__POSTGRESQL__PASSWORD': secret}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
captured = capsys.readouterr().out
assert result['database']['postgresql']['password'] == secret
assert 'DATABASE__POSTGRESQL__PASSWORD' in captured
assert secret not in captured
def test_override_int_value(self):
"""Test overriding an int value with proper conversion."""
load_config = get_load_config_module()
@@ -196,6 +212,19 @@ class TestApplyEnvOverridesToConfig:
assert result['system']['name'] == 'default'
def test_skip_env_vars_with_empty_path_segments(self, capsys):
"""Platform variables such as __CF_USER_TEXT_ENCODING are not config."""
load_config = get_load_config_module()
cfg = {'system': {'name': 'default'}}
env = {'__CF_USER_TEXT_ENCODING': '0x1F5:0x0:0x64'}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
assert result == cfg
assert capsys.readouterr().out == ''
def test_nested_config_path(self):
"""Test overriding deeply nested config."""
load_config = get_load_config_module()
+48
View File
@@ -12,6 +12,7 @@ from __future__ import annotations
import pytest
import asyncio
import contextvars
import sys
from unittest.mock import Mock, MagicMock
from contextlib import contextmanager
@@ -360,6 +361,53 @@ class TestAsyncTaskManager:
wrapper.cancel()
@pytest.mark.asyncio
async def test_create_task_does_not_inherit_request_context(self):
"""Long-lived tasks must receive identity through explicit arguments."""
_, _, AsyncTaskManager = get_taskmgr_classes()
mock_app = create_mock_app()
manager = AsyncTaskManager(mock_app)
request_value = contextvars.ContextVar('request_value', default=None)
token = request_value.set('request-scoped-transaction')
observed = []
async def detached_task(captured_workspace: str) -> None:
observed.append((request_value.get(), captured_workspace))
try:
wrapper = manager.create_task(detached_task('workspace-a'))
await wrapper.task
finally:
request_value.reset(token)
assert observed == [(None, 'workspace-a')]
@pytest.mark.asyncio
async def test_create_task_waits_for_registered_transaction_commit(self):
_, _, AsyncTaskManager = get_taskmgr_classes()
mock_app = create_mock_app()
gate = asyncio.get_running_loop().create_future()
class PersistenceManagerStub:
def create_after_commit_gate(self):
return gate
mock_app.persistence_mgr = PersistenceManagerStub()
manager = AsyncTaskManager(mock_app)
observed = []
async def background_work() -> None:
observed.append('started')
wrapper = manager.create_task(background_work())
await asyncio.sleep(0)
assert observed == []
gate.set_result(None)
await wrapper.task
assert observed == ['started']
@pytest.mark.asyncio
async def test_get_stats_counts_correctly(self):
"""Test get_stats returns correct counts."""
@@ -11,9 +11,19 @@ Note: Uses import isolation to break circular import chains.
from __future__ import annotations
import sys
from unittest.mock import Mock, MagicMock
from contextlib import contextmanager
from typing import Generator
from unittest.mock import MagicMock, Mock
import pytest
@pytest.fixture(autouse=True)
def isolate_database_manager_registry(monkeypatch: pytest.MonkeyPatch) -> None:
"""Keep decorator tests from mutating the process-wide manager registry."""
from langbot.pkg.persistence import database
monkeypatch.setattr(database, 'preregistered_managers', list(database.preregistered_managers))
@contextmanager
@@ -0,0 +1,90 @@
from __future__ import annotations
from types import SimpleNamespace
import pytest
# Persistence manager performs the package's database-manager registration;
# importing a concrete manager first would enter the historical app/mgr cycle.
from langbot.pkg.persistence import mgr as _persistence_mgr # noqa: F401
from langbot.pkg.persistence.databases import postgresql
@pytest.mark.asyncio
async def test_postgresql_manager_parses_explicit_url_without_string_reassembly(monkeypatch) -> None:
captured = None
sentinel_engine = object()
def create_engine(url):
nonlocal captured
captured = url
return sentinel_engine
monkeypatch.setattr(postgresql.sqlalchemy_asyncio, 'create_async_engine', create_engine)
ap = SimpleNamespace(
instance_config=SimpleNamespace(
data={
'database': {
'postgresql': {
'url': 'postgresql://runtime:p%40ss@db.internal:5432/langbot?sslmode=require',
}
}
}
)
)
manager = postgresql.PostgreSQLDatabaseManager(ap)
await manager.initialize()
assert captured.drivername == 'postgresql+asyncpg'
assert captured.password == 'p@ss'
assert captured.query['ssl'] == 'require'
assert 'sslmode' not in captured.query
assert manager.engine is sentinel_engine
@pytest.mark.asyncio
async def test_postgresql_manager_builds_structured_url_with_special_password(monkeypatch) -> None:
captured = None
def create_engine(url):
nonlocal captured
captured = url
return object()
monkeypatch.setattr(postgresql.sqlalchemy_asyncio, 'create_async_engine', create_engine)
ap = SimpleNamespace(
instance_config=SimpleNamespace(
data={
'database': {
'postgresql': {
'host': 'db.internal',
'port': 5432,
'user': 'runtime',
'password': 'p@ss:/?#word',
'database': 'langbot',
}
}
}
)
)
await postgresql.PostgreSQLDatabaseManager(ap).initialize()
assert captured.password == 'p@ss:/?#word'
assert captured.host == 'db.internal'
assert captured.database == 'langbot'
@pytest.mark.asyncio
async def test_postgresql_manager_rejects_non_postgresql_url_without_echoing_secret() -> None:
ap = SimpleNamespace(
instance_config=SimpleNamespace(
data={'database': {'postgresql': {'url': 'sqlite:///operator-super-secret.db'}}}
)
)
manager = postgresql.PostgreSQLDatabaseManager(ap)
with pytest.raises(ValueError, match='valid PostgreSQL') as exc_info:
await manager.initialize()
assert 'operator-super-secret' not in str(exc_info.value)
@@ -0,0 +1,153 @@
from __future__ import annotations
import logging
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
import sqlalchemy
from langbot.__main__ import _build_parser
from langbot.pkg.persistence import release_migration
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
def _cloud_config(*, database_use: str = 'postgresql', runtime_user: str = 'langbot_runtime') -> dict:
return {
'database': {
'use': database_use,
'postgresql': {
'host': 'runtime-db',
'port': 5432,
'user': runtime_user,
'password': 'runtime-secret',
'database': 'langbot',
},
'cloud_migration': {
'operator_dsn_env': 'TEST_LANGBOT_OPERATOR_DSN',
},
},
'vdb': {
'use': 'pgvector',
'pgvector': {
'use_business_database': True,
'allowed_dimensions': [384, 1536],
},
},
}
def _operator_environ(
*,
user: str = 'langbot_migrator',
database: str = 'langbot',
host: str = 'runtime-db',
port: int = 5432,
) -> dict[str, str]:
return {
'TEST_LANGBOT_OPERATOR_DSN': (f'postgresql://{user}:operator%40secret@{host}:{port}/{database}?sslmode=require')
}
def test_cloud_migration_cli_is_explicit() -> None:
args = _build_parser().parse_args(['migrate', '--cloud'])
assert args.command == 'migrate'
assert args.cloud is True
with pytest.raises(SystemExit) as exc_info:
_build_parser().parse_args(['migrate'])
assert exc_info.value.code == 2
def test_operator_url_is_separate_and_preserves_escaped_secret() -> None:
url = release_migration._operator_database_url(
_cloud_config(),
environ=_operator_environ(),
)
assert url.drivername == 'postgresql+asyncpg'
assert url.username == 'langbot_migrator'
assert url.password == 'operator@secret'
assert url.host == 'runtime-db'
assert url.port == 5432
assert url.database == 'langbot'
assert url.query['ssl'] == 'require'
assert 'sslmode' not in url.query
@pytest.mark.parametrize(
('config', 'environ', 'message'),
[
(_cloud_config(database_use='sqlite'), _operator_environ(), 'SQLite fallback is forbidden'),
(_cloud_config(), {}, 'requires the operator DSN'),
(_cloud_config(), {'TEST_LANGBOT_OPERATOR_DSN': 'not a secret://operator-password'}, 'DSN is invalid'),
(
_cloud_config(),
{'TEST_LANGBOT_OPERATOR_DSN': 'postgresql://operator:secret@runtime-db:not-a-port/langbot'},
'DSN is invalid',
),
(_cloud_config(), _operator_environ(user='langbot_runtime'), 'distinct operator role'),
(_cloud_config(), _operator_environ(database='another_database'), 'configured runtime database'),
(_cloud_config(), _operator_environ(host='other-cluster'), 'runtime PostgreSQL endpoint'),
(_cloud_config(), _operator_environ(port=6432), 'runtime PostgreSQL endpoint'),
],
)
def test_operator_url_rejects_unsafe_configuration(config: dict, environ: dict[str, str], message: str) -> None:
with pytest.raises(release_migration.CloudReleaseMigrationConfigurationError, match=message) as exc_info:
release_migration._operator_database_url(config, environ=environ)
assert 'operator-password' not in str(exc_info.value)
@pytest.mark.asyncio
async def test_release_migration_disposes_operator_engine_on_failure(monkeypatch) -> None:
engine = SimpleNamespace(dispose=AsyncMock())
manager = SimpleNamespace(
db=SimpleNamespace(engine=engine),
initialize=AsyncMock(side_effect=RuntimeError('migration failed')),
)
def manager_factory(*args, **kwargs):
del args, kwargs
return manager
monkeypatch.setattr(release_migration, 'PersistenceManager', manager_factory)
ap = SimpleNamespace(
instance_config=SimpleNamespace(data=_cloud_config()),
logger=logging.getLogger('release-migration-disposal-test'),
persistence_mgr=None,
)
with pytest.raises(RuntimeError, match='migration failed'):
await release_migration.run_cloud_release_migration(ap, environ=_operator_environ())
assert ap.persistence_mgr is manager
engine.dispose.assert_awaited_once()
@pytest.mark.asyncio
async def test_release_mode_rejects_sqlite_before_schema_changes(tmp_path, monkeypatch) -> None:
from langbot.pkg.persistence import mgr as persistence_mgr_module
from langbot.pkg.persistence.databases.sqlite import SQLiteDatabaseManager
monkeypatch.setattr(persistence_mgr_module.database, 'preregistered_managers', [SQLiteDatabaseManager])
ap = SimpleNamespace(
instance_config=SimpleNamespace(
data={
'database': {
'use': 'sqlite',
'sqlite': {'path': str(tmp_path / 'must-not-migrate.db')},
}
}
),
logger=logging.getLogger('release-migration-sqlite-rejection-test'),
)
manager = PersistenceManager(ap, mode=PersistenceMode.RELEASE_MIGRATION)
with pytest.raises(RuntimeError, match='requires PostgreSQL'):
await manager.initialize()
await manager.get_db_engine().dispose()
engine = sqlalchemy.create_engine(f'sqlite:///{tmp_path / "must-not-migrate.db"}')
try:
assert sqlalchemy.inspect(engine).get_table_names() == []
finally:
engine.dispose()
+350 -1
View File
@@ -1,11 +1,23 @@
from __future__ import annotations
import asyncio
import contextvars
from types import SimpleNamespace
import pytest
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.core.task_boundary import create_detached_task
from langbot.pkg.entity.persistence.user import User
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
from langbot.pkg.persistence.tenant_uow import TenantUnitOfWork
from langbot.pkg.persistence.tenant_uow import (
CrossScopeTransactionError,
PersistenceScopeKind,
TenantScopeRequiredError,
TenantUnitOfWork,
TransactionRollbackOnlyError,
)
pytestmark = pytest.mark.asyncio
@@ -61,3 +73,340 @@ def test_persistence_mode_must_be_a_trusted_enum() -> None:
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
assert manager.mode is PersistenceMode.CLOUD_RUNTIME
async def test_manager_reuses_one_session_and_rejects_cross_workspace(tmp_path) -> None:
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "manager-uow.db"}')
table = sa.Table(
'manager_rows',
sa.MetaData(),
sa.Column('id', sa.Integer, primary_key=True),
sa.Column('workspace_uuid', sa.String(36), nullable=False),
)
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
manager.db = SimpleNamespace(get_engine=lambda: engine)
try:
async with engine.begin() as conn:
await conn.run_sync(table.metadata.create_all)
with pytest.raises(TenantScopeRequiredError, match='explicit Workspace or discovery'):
await manager.execute_async(sa.select(table))
async with manager.tenant_uow('workspace-a') as outer:
await manager.execute_async(sa.insert(table).values(id=1, workspace_uuid='workspace-a'))
async with manager.tenant_uow('workspace-a') as inner:
assert inner.session is outer.session
assert manager.current_session() is outer.session
assert (await manager.execute_async(sa.select(table.c.id))).scalar_one() == 1
with pytest.raises(CrossScopeTransactionError, match='while workspace scope is active'):
async with manager.tenant_uow('workspace-b'):
pass
async with engine.connect() as conn:
assert (await conn.execute(sa.select(table.c.id))).scalars().all() == [1]
finally:
await engine.dispose()
async def test_manager_scoped_execute_preserves_core_row_and_scalar_contract(tmp_path) -> None:
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "manager-result-contract.db"}')
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
manager.db = SimpleNamespace(get_engine=lambda: engine)
try:
async with engine.begin() as conn:
await conn.run_sync(User.__table__.create)
await conn.execute(
sa.insert(User).values(
uuid='account-a',
user='owner@example.com',
normalized_email='owner@example.com',
password='hashed-password',
)
)
async with manager.tenant_uow('workspace-a') as uow:
result = await manager.execute_async(sa.select(User))
row = result.first()
assert row is not None
assert row.uuid == 'account-a'
assert row.user == 'owner@example.com'
scalar_result = await manager.execute_async(sa.select(User.uuid))
assert scalar_result.scalar_one() == 'account-a'
list_result = await manager.execute_async(sa.select(User.user))
assert list_result.scalars().all() == ['owner@example.com']
# Direct UoW execution remains an ORM API for code that opts into it.
orm_result = await uow.execute(sa.select(User))
assert orm_result.scalars().one().uuid == 'account-a'
finally:
await engine.dispose()
async def test_transaction_free_tenant_scope_opens_one_short_uow_per_database_call(tmp_path) -> None:
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "short-scope.db"}')
table = sa.Table(
'short_scope_rows',
sa.MetaData(),
sa.Column('id', sa.Integer, primary_key=True),
sa.Column('workspace_uuid', sa.String(36), nullable=False),
)
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
manager.db = SimpleNamespace(get_engine=lambda: engine)
try:
async with engine.begin() as conn:
await conn.run_sync(table.metadata.create_all)
async with manager.tenant_scope('workspace-a'):
assert manager.current_scope() is not None
assert manager.current_scope().kind is PersistenceScopeKind.WORKSPACE
assert manager.current_scope().settings == (('langbot.workspace_uuid', 'workspace-a'),)
assert manager.current_session() is None
await manager.execute_async(sa.insert(table).values(id=1, workspace_uuid='workspace-a'))
assert manager.current_session() is None
# The first statement has already committed. Long external waits
# inside this boundary retain only identity, never a DB session.
await asyncio.sleep(0)
assert manager.current_session() is None
assert (await manager.execute_async(sa.select(table.c.id))).scalar_one() == 1
assert manager.current_session() is None
async with manager.tenant_scope('workspace-a'):
assert manager.current_session() is None
with pytest.raises(CrossScopeTransactionError, match='while workspace scope is active'):
async with manager.tenant_scope('workspace-b'):
pass
with pytest.raises(CrossScopeTransactionError, match='while workspace scope is active'):
async with manager.tenant_uow('workspace-b'):
pass
assert manager.current_scope() is None
with pytest.raises(TenantScopeRequiredError, match='explicit Workspace'):
await manager.execute_async(sa.select(table))
finally:
await engine.dispose()
async def test_transaction_free_scope_requires_explicit_child_task_scope(tmp_path) -> None:
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "short-scope-child.db"}')
table = sa.Table('child_scope_rows', sa.MetaData(), sa.Column('id', sa.Integer, primary_key=True))
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
manager.db = SimpleNamespace(get_engine=lambda: engine)
try:
async with engine.begin() as conn:
await conn.run_sync(table.metadata.create_all)
async with manager.tenant_scope('workspace-a'):
async def inherited_access() -> None:
await manager.execute_async(sa.select(table))
with pytest.raises(CrossScopeTransactionError, match='cannot be inherited by child tasks'):
await asyncio.create_task(inherited_access())
async def explicitly_scoped_access() -> None:
async with manager.tenant_scope('workspace-a'):
await manager.execute_async(sa.insert(table).values(id=1))
assert manager.current_session() is None
await asyncio.create_task(explicitly_scoped_access())
assert manager.current_session() is None
async with engine.connect() as conn:
assert (await conn.execute(sa.select(table.c.id))).scalars().all() == [1]
finally:
await engine.dispose()
async def test_caught_nested_failure_marks_outer_transaction_rollback_only(tmp_path) -> None:
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "rollback-only.db"}')
table = sa.Table('rollback_rows', sa.MetaData(), sa.Column('id', sa.Integer, primary_key=True))
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
manager.db = SimpleNamespace(get_engine=lambda: engine)
try:
async with engine.begin() as conn:
await conn.run_sync(table.metadata.create_all)
with pytest.raises(TransactionRollbackOnlyError, match='transaction was rolled back'):
async with manager.tenant_uow('workspace-a'):
await manager.execute_async(sa.insert(table).values(id=1))
try:
async with manager.tenant_uow('workspace-a'):
raise ValueError('caught nested failure')
except ValueError:
pass
async with engine.connect() as conn:
assert (await conn.execute(sa.select(table))).all() == []
finally:
await engine.dispose()
@pytest.mark.parametrize('executor_kind', ['manager', 'uow', 'session'])
async def test_caught_database_error_rolls_back_and_cancels_after_commit_gate(tmp_path, executor_kind: str) -> None:
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / f"db-error-{executor_kind}.db"}')
table = sa.Table('unique_rows', sa.MetaData(), sa.Column('id', sa.Integer, primary_key=True))
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
manager.db = SimpleNamespace(get_engine=lambda: engine)
try:
async with engine.begin() as conn:
await conn.run_sync(table.metadata.create_all)
with pytest.raises(TransactionRollbackOnlyError, match='after-commit work was cancelled'):
async with manager.tenant_uow('workspace-a') as uow:
statement = sa.insert(table).values(id=1)
if executor_kind == 'manager':
await manager.execute_async(statement)
elif executor_kind == 'uow':
await uow.execute(statement)
else:
await uow.session.execute(statement)
gate = manager.create_after_commit_gate()
assert gate is not None
try:
if executor_kind == 'manager':
await manager.execute_async(statement)
elif executor_kind == 'uow':
await uow.execute(statement)
else:
await uow.session.execute(statement)
except sa.exc.IntegrityError:
pass
assert gate.cancelled()
async with engine.connect() as conn:
assert (await conn.execute(sa.select(table))).all() == []
finally:
await engine.dispose()
async def test_child_task_must_open_its_own_explicit_uow(tmp_path) -> None:
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "child-task.db"}')
table = sa.Table(
'child_rows',
sa.MetaData(),
sa.Column('id', sa.Integer, primary_key=True),
sa.Column('workspace_uuid', sa.String(36), nullable=False),
)
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
manager.db = SimpleNamespace(get_engine=lambda: engine)
try:
async with engine.begin() as conn:
await conn.run_sync(table.metadata.create_all)
async with manager.tenant_uow('workspace-a'):
async def inherited_access() -> None:
await manager.execute_async(sa.select(table))
with pytest.raises(CrossScopeTransactionError, match='cannot be inherited by child tasks'):
await asyncio.create_task(inherited_access())
async def explicitly_scoped_access() -> None:
async with manager.tenant_uow('workspace-a'):
await manager.execute_async(sa.insert(table).values(id=2, workspace_uuid='workspace-a'))
await asyncio.create_task(explicitly_scoped_access())
async with engine.connect() as conn:
assert (await conn.execute(sa.select(table.c.id))).scalars().all() == [2]
finally:
await engine.dispose()
async def test_detached_task_starts_without_parent_scope_and_rolls_back_its_uow(tmp_path) -> None:
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "detached-task.db"}')
table = sa.Table(
'detached_rows',
sa.MetaData(),
sa.Column('id', sa.Integer, primary_key=True),
sa.Column('workspace_uuid', sa.String(36), nullable=False),
)
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
manager.db = SimpleNamespace(get_engine=lambda: engine)
try:
async with engine.begin() as conn:
await conn.run_sync(table.metadata.create_all)
async with manager.tenant_uow('workspace-a'):
async def detached_write() -> None:
# Before the detached boundary this access raises
# CrossScopeTransactionError because asyncio copies the
# parent's ActiveScopedTransaction into this child task.
assert manager.current_scope() is None
async with manager.tenant_uow('workspace-a'):
await manager.execute_async(sa.insert(table).values(id=2, workspace_uuid='workspace-a'))
raise RuntimeError('roll back detached write')
task = create_detached_task(detached_write())
with pytest.raises(RuntimeError, match='roll back detached write'):
await task
await manager.execute_async(sa.insert(table).values(id=1, workspace_uuid='workspace-a'))
async with engine.connect() as conn:
assert (await conn.execute(sa.select(table.c.id))).scalars().all() == [1]
finally:
await engine.dispose()
async def test_after_commit_task_waits_for_commit_and_starts_with_empty_context(tmp_path) -> None:
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "after-commit.db"}')
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
manager.db = SimpleNamespace(get_engine=lambda: engine)
request_value = contextvars.ContextVar('after_commit_request_value', default=None)
observed = []
try:
async with manager.tenant_uow('workspace-a'):
token = request_value.set('request-scope')
async def after_commit_work() -> None:
observed.append((request_value.get(), manager.current_scope()))
try:
task = create_detached_task(
after_commit_work(),
after_commit_manager=manager,
)
await asyncio.sleep(0)
assert observed == []
finally:
request_value.reset(token)
await task
assert observed == [(None, None)]
finally:
await engine.dispose()
async def test_after_commit_task_is_cancelled_and_coroutine_closed_on_rollback(tmp_path) -> None:
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "after-rollback.db"}')
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
manager.db = SimpleNamespace(get_engine=lambda: engine)
started = False
async def should_not_start() -> None:
nonlocal started
started = True
coro = should_not_start()
try:
with pytest.raises(RuntimeError, match='rollback request'):
async with manager.tenant_uow('workspace-a'):
task = create_detached_task(coro, after_commit_manager=manager)
await asyncio.sleep(0)
assert not task.done()
raise RuntimeError('rollback request')
await asyncio.sleep(0)
assert task.cancelled()
assert not started
assert coro.cr_frame is None
finally:
await engine.dispose()
+39 -5
View File
@@ -13,8 +13,11 @@ from __future__ import annotations
import pytest
import asyncio
import contextvars
from contextlib import asynccontextmanager
from unittest.mock import Mock, AsyncMock
from importlib import import_module
from types import SimpleNamespace
from tests.factories import (
FakeApp,
@@ -894,17 +897,48 @@ class TestMessageAggregatorWorkspaceIsolation:
app = make_aggregator_app()
enable_aggregation(app)
agg = get_aggregator_module().MessageAggregator(app)
delayed_flush = AsyncMock()
request_value = contextvars.ContextVar('aggregator_request_value', default=None)
token = request_value.set('request-scope')
observed = []
async def delayed_flush(*args):
observed.append((request_value.get(), args[2]))
monkeypatch.setattr(agg, '_delayed_flush', delayed_flush)
context = execution_context(pipeline_uuid='test-pipeline')
await agg.add_message(**scoped_message_kwargs(context))
await asyncio.sleep(0)
try:
await agg.add_message(**scoped_message_kwargs(context))
await asyncio.sleep(0)
finally:
request_value.reset(token)
delayed_flush.assert_awaited_once()
assert delayed_flush.await_args.args[2] is context
assert observed == [(None, context)]
await agg.flush_all()
@pytest.mark.asyncio
async def test_delayed_flush_opens_explicit_workspace_uow(self, monkeypatch):
app = make_aggregator_app()
app.persistence_mgr.mode = SimpleNamespace(value='cloud_runtime')
scopes = []
@asynccontextmanager
async def tenant_uow(workspace_uuid):
scopes.append(workspace_uuid)
yield
app.persistence_mgr.tenant_uow = tenant_uow
agg = get_aggregator_module().MessageAggregator(app)
flush = AsyncMock()
monkeypatch.setattr(agg, '_flush_buffer', flush)
context = execution_context('workspace-a', pipeline_uuid='test-pipeline')
key = aggregation_key(context, pipeline_uuid='test-pipeline')
await agg._delayed_flush(key, 0, context)
assert scopes == ['workspace-a']
flush.assert_awaited_once_with(key, context)
@pytest.mark.asyncio
async def test_flush_rejects_context_from_another_workspace(self):
app = make_aggregator_app()
@@ -1,10 +1,15 @@
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, Mock
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 PersistenceScopeKind
from langbot.pkg.pipeline.controller import Controller
from langbot.pkg.workspace.errors import WorkspaceGenerationMismatchError
@@ -44,6 +49,79 @@ async def test_controller_drops_stale_query_before_pipeline_lookup(
query_pool.condition.notify_all.assert_called_once_with()
@pytest.mark.asyncio
async def test_cloud_controller_releases_database_connection_during_pipeline_wait(
tmp_path,
mock_app,
sample_query,
):
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "pipeline-short-scope.db"}')
table = sa.Table('pipeline_scope_probe', sa.MetaData(), sa.Column('id', sa.Integer, primary_key=True))
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
manager.db = SimpleNamespace(get_engine=lambda: engine)
checked_out = 0
def on_checkout(*_args):
nonlocal checked_out
checked_out += 1
def on_checkin(*_args):
nonlocal checked_out
checked_out -= 1
sa.event.listen(engine.sync_engine, 'checkout', on_checkout)
sa.event.listen(engine.sync_engine, 'checkin', on_checkin)
try:
async with engine.begin() as conn:
await conn.run_sync(table.metadata.create_all)
_prepare_scheduler(mock_app)
mock_app.persistence_mgr = manager
pipeline_waiting = asyncio.Event()
release_pipeline = asyncio.Event()
async def get_binding(*_args, **_kwargs):
assert manager.current_scope().kind is PersistenceScopeKind.WORKSPACE
assert manager.current_session() is None
await manager.execute_async(sa.select(table.c.id))
assert manager.current_session() is None
return SimpleNamespace(
instance_uuid='test-instance',
workspace_uuid='test-workspace',
placement_generation=1,
)
async def run_pipeline(_query):
await manager.execute_async(sa.select(table.c.id))
assert manager.current_session() is None
pipeline_waiting.set()
await release_pipeline.wait()
assert manager.current_scope().kind is PersistenceScopeKind.WORKSPACE
assert manager.current_session() is None
runtime_pipeline = SimpleNamespace(run=AsyncMock(side_effect=run_pipeline))
async def get_pipeline(*_args, **_kwargs):
await manager.execute_async(sa.select(table.c.id))
assert manager.current_session() is None
return runtime_pipeline
mock_app.workspace_service.get_execution_binding = AsyncMock(side_effect=get_binding)
mock_app.pipeline_mgr.get_pipeline_by_uuid = AsyncMock(side_effect=get_pipeline)
controller = Controller(mock_app)
task = asyncio.create_task(controller._process_query(sample_query))
await asyncio.wait_for(pipeline_waiting.wait(), timeout=2)
assert checked_out == 0
assert not task.done()
release_pipeline.set()
await asyncio.wait_for(task, timeout=2)
assert checked_out == 0
runtime_pipeline.run.assert_awaited_once_with(sample_query)
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_controller_revalidates_generation_before_running_pipeline(
mock_app,
@@ -1,12 +1,15 @@
from __future__ import annotations
import contextlib
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from langbot.pkg.api.http.authz import WorkspaceRequiredError
from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.platform.botmgr import PlatformManager, RuntimeBot
import langbot_plugin.api.entities.builtin.platform.events as platform_events
WORKSPACE_A = '00000000-0000-0000-0000-00000000000a'
@@ -112,3 +115,71 @@ def test_runtime_bot_rejects_workspace_mismatch():
logger=SimpleNamespace(),
execution_context=_context(WORKSPACE_B, BOT_A),
)
class _ScopeOnlyPersistenceManager:
mode = SimpleNamespace(value='cloud_runtime')
def __init__(self):
self.active_workspace = None
@contextlib.asynccontextmanager
async def tenant_scope(self, workspace_uuid: str):
assert self.active_workspace is None
self.active_workspace = workspace_uuid
try:
yield
finally:
self.active_workspace = None
def current_session(self):
return None
class _ListenerAdapter:
def __init__(self):
self.listeners = {}
def register_listener(self, event_type, listener):
self.listeners[event_type] = listener
@pytest.mark.asyncio
async def test_platform_callback_carries_scope_without_holding_database_session():
persistence_mgr = _ScopeOnlyPersistenceManager()
adapter = _ListenerAdapter()
async def push_person_message(*_args, **_kwargs):
assert persistence_mgr.active_workspace == WORKSPACE_A
assert persistence_mgr.current_session() is None
return True
application = SimpleNamespace(
persistence_mgr=persistence_mgr,
workspace_service=_WorkspaceService(),
webhook_pusher=SimpleNamespace(push_person_message=push_person_message),
)
entity = SimpleNamespace(
uuid=BOT_A,
workspace_uuid=WORKSPACE_A,
name='Bot',
enable=True,
pipeline_routing_rules=[],
use_pipeline_uuid=None,
)
logger = SimpleNamespace(info=AsyncMock(), error=AsyncMock())
runtime = RuntimeBot(
ap=application,
bot_entity=entity,
adapter=adapter,
logger=logger,
execution_context=_context(WORKSPACE_A, BOT_A),
)
await runtime.initialize()
listener = adapter.listeners[platform_events.FriendMessage]
event = SimpleNamespace(message_chain=[], sender=SimpleNamespace(id='user'))
await listener(event, adapter)
assert persistence_mgr.active_workspace is None
logger.info.assert_awaited()
@@ -9,19 +9,32 @@ Tests cover:
from __future__ import annotations
from contextlib import nullcontext
from types import SimpleNamespace
import pytest
from unittest.mock import Mock, AsyncMock
from unittest.mock import AsyncMock, Mock
from importlib import import_module
from tests.factories import text_query
from langbot_plugin.entities.io.context import ActionContext
from langbot_plugin.entities.io.context import InstallationBinding
from langbot.pkg.api.http.context import ExecutionContext
TEST_ACTION_CONTEXT = ActionContext(
TEST_EXECUTION_CONTEXT = ExecutionContext(
instance_uuid='instance-a',
workspace_uuid='workspace-a',
placement_generation=1,
)
TEST_INSTALLATION_BINDING = InstallationBinding(
instance_uuid=TEST_EXECUTION_CONTEXT.instance_uuid,
workspace_uuid=TEST_EXECUTION_CONTEXT.workspace_uuid,
placement_generation=TEST_EXECUTION_CONTEXT.placement_generation,
installation_uuid='00000000-0000-4000-8000-000000000001',
runtime_revision=1,
artifact_digest='a' * 64,
)
def get_connector_module():
@@ -37,6 +50,7 @@ def create_mock_app():
mock_app.instance_config.data = {'plugin': {'enable': True}}
mock_app.persistence_mgr = AsyncMock()
mock_app.persistence_mgr.execute_async = AsyncMock()
mock_app.persistence_mgr.tenant_uow = None
return mock_app
@@ -47,7 +61,19 @@ def create_mock_connector():
async def mock_disconnect_callback(conn):
pass
return connector.PluginRuntimeConnector(create_mock_app(), mock_disconnect_callback)
instance = connector.PluginRuntimeConnector(create_mock_app(), mock_disconnect_callback)
instance._execution_context.set(TEST_EXECUTION_CONTEXT)
instance._operation_bindings = AsyncMock(return_value=[TEST_INSTALLATION_BINDING])
instance._target_binding = AsyncMock(return_value=TEST_INSTALLATION_BINDING)
instance._load_workspace_settings = AsyncMock(return_value=[])
instance.require_workspace_context = AsyncMock(side_effect=lambda context: context)
return instance
def configure_handler(connector, runtime_handler):
runtime_handler.installation_scope = Mock(side_effect=lambda _binding: nullcontext())
connector.handler = runtime_handler
return runtime_handler
class TestListPlugins:
@@ -76,8 +102,7 @@ class TestListPlugins:
get_connector_module()
connector = create_mock_connector()
connector.handler = AsyncMock()
connector.handler.require_bound_action_context = Mock(return_value=TEST_ACTION_CONTEXT)
configure_handler(connector, AsyncMock())
connector.handler.list_plugins = AsyncMock(
return_value=[{'manifest': {'manifest': {'metadata': {'author': 'test', 'name': 'plugin'}}}}]
)
@@ -86,8 +111,7 @@ class TestListPlugins:
connector.handler.list_plugins.assert_called_once()
assert result == [{'manifest': {'manifest': {'metadata': {'author': 'test', 'name': 'plugin'}}}}]
statement = connector.ap.persistence_mgr.execute_async.await_args.args[0]
assert 'workspace-a' in statement.compile().params.values()
connector._load_workspace_settings.assert_awaited_once_with(TEST_EXECUTION_CONTEXT)
@pytest.mark.asyncio
async def test_filters_by_component_kinds(self):
@@ -95,8 +119,7 @@ class TestListPlugins:
get_connector_module()
connector = create_mock_connector()
connector.handler = AsyncMock()
connector.handler.require_bound_action_context = Mock(return_value=TEST_ACTION_CONTEXT)
configure_handler(connector, AsyncMock())
connector.handler.list_plugins = AsyncMock(
return_value=[
{
@@ -123,8 +146,7 @@ class TestListPlugins:
get_connector_module()
connector = create_mock_connector()
connector.handler = AsyncMock()
connector.handler.require_bound_action_context = Mock(return_value=TEST_ACTION_CONTEXT)
configure_handler(connector, AsyncMock())
connector.handler.list_plugins = AsyncMock(
return_value=[
{
@@ -171,7 +193,7 @@ class TestPluginDiagnostics:
'response_sources': response_sources,
}
connector.handler = AsyncMock()
configure_handler(connector, AsyncMock())
connector.handler.emit_event = AsyncMock(side_effect=emit_event_response)
fake_event_ctx = Mock()
@@ -215,7 +237,7 @@ class TestPluginDiagnostics:
],
}
connector.handler = AsyncMock()
configure_handler(connector, AsyncMock())
connector.handler.emit_event = AsyncMock(side_effect=emit_event_response)
fake_event_ctx = Mock()
@@ -238,7 +260,7 @@ class TestPluginDiagnostics:
connector_module.context.EventContext.from_event = original_from_event
connector_module.context.EventContext.model_validate = original_model_validate
assert '_response_sources' not in vars(event_ctx)
assert event_ctx._response_sources == []
assert event_ctx._emitted_plugins == [
{'manifest': {'metadata': {'author': 'tester', 'name': 'demo'}}},
]
@@ -253,7 +275,7 @@ class TestPluginDiagnostics:
mock_app = create_mock_app()
mock_app.instance_config.data = {'plugin': {'enable': False}}
connector = connector_module.PluginRuntimeConnector(mock_app, mock_disconnect)
connector.handler = AsyncMock()
configure_handler(connector, AsyncMock())
await connector.notify_plugin_diagnostic({'code': 'response_delivery_failed'})
@@ -262,7 +284,7 @@ class TestPluginDiagnostics:
@pytest.mark.asyncio
async def test_notify_plugin_diagnostic_is_best_effort(self):
connector = create_mock_connector()
connector.handler = AsyncMock()
configure_handler(connector, AsyncMock())
connector.handler.notify_plugin_diagnostic = AsyncMock(side_effect=RuntimeError('action not found'))
await connector.notify_plugin_diagnostic({'code': 'response_delivery_failed'})
@@ -297,7 +319,7 @@ class TestListKnowledgeEngines:
get_connector_module()
connector = create_mock_connector()
connector.handler = AsyncMock()
configure_handler(connector, AsyncMock())
connector.handler.list_knowledge_engines = AsyncMock(
return_value=[{'plugin_id': 'author/engine', 'name': 'Engine'}]
)
@@ -334,7 +356,7 @@ class TestListParsers:
get_connector_module()
connector = create_mock_connector()
connector.handler = AsyncMock()
configure_handler(connector, AsyncMock())
connector.handler.list_parsers = AsyncMock(
return_value=[{'plugin_id': 'author/parser', 'supported_mime_types': ['text/plain']}]
)
@@ -354,7 +376,7 @@ class TestCallParser:
get_connector_module()
connector = create_mock_connector()
connector.handler = AsyncMock()
configure_handler(connector, AsyncMock())
connector.handler.parse_document = AsyncMock(return_value={'content': 'parsed'})
result = await connector.call_parser(
@@ -381,7 +403,7 @@ class TestRAGMethods:
get_connector_module()
connector = create_mock_connector()
connector.handler = AsyncMock()
configure_handler(connector, AsyncMock())
connector.handler.rag_ingest_document = AsyncMock(return_value={'status': 'success'})
result = await connector.call_rag_ingest('author/engine', {'file': 'test.pdf'})
@@ -395,7 +417,7 @@ class TestRAGMethods:
get_connector_module()
connector = create_mock_connector()
connector.handler = AsyncMock()
configure_handler(connector, AsyncMock())
connector.handler.retrieve_knowledge = AsyncMock(
return_value={
'results': [
@@ -424,7 +446,7 @@ class TestRAGMethods:
get_connector_module()
connector = create_mock_connector()
connector.handler = AsyncMock()
configure_handler(connector, AsyncMock())
connector.handler.get_rag_creation_schema = AsyncMock(return_value={'properties': {'name': {'type': 'string'}}})
result = await connector.get_rag_creation_schema('author/engine')
@@ -438,7 +460,7 @@ class TestRAGMethods:
get_connector_module()
connector = create_mock_connector()
connector.handler = AsyncMock()
configure_handler(connector, AsyncMock())
connector.handler.get_rag_retrieval_schema = AsyncMock(
return_value={'properties': {'top_k': {'type': 'integer'}}}
)
@@ -454,7 +476,7 @@ class TestRAGMethods:
get_connector_module()
connector = create_mock_connector()
connector.handler = AsyncMock()
configure_handler(connector, AsyncMock())
connector.handler.rag_on_kb_create = AsyncMock(return_value={'status': 'ok'})
await connector.rag_on_kb_create('author/engine', 'kb-uuid', {'model': 'test'})
@@ -467,7 +489,7 @@ class TestRAGMethods:
get_connector_module()
connector = create_mock_connector()
connector.handler = AsyncMock()
configure_handler(connector, AsyncMock())
connector.handler.rag_on_kb_delete = AsyncMock(return_value={'status': 'ok'})
await connector.rag_on_kb_delete('author/engine', 'kb-uuid')
@@ -480,7 +502,7 @@ class TestRAGMethods:
get_connector_module()
connector = create_mock_connector()
connector.handler = AsyncMock()
configure_handler(connector, AsyncMock())
connector.handler.rag_delete_document = AsyncMock(return_value=True)
result = await connector.call_rag_delete_document('author/engine', 'doc-uuid', 'kb-uuid')
@@ -574,7 +596,7 @@ class TestGetPluginInfo:
get_connector_module()
connector = create_mock_connector()
connector.handler = AsyncMock()
configure_handler(connector, AsyncMock())
connector.handler.get_plugin_info = AsyncMock(return_value={'manifest': {'metadata': {'name': 'plugin'}}})
result = await connector.get_plugin_info('author', 'plugin')
@@ -587,17 +609,39 @@ class TestSetPluginConfig:
"""Tests for set_plugin_config method."""
@pytest.mark.asyncio
async def test_calls_handler_set_plugin_config(self):
"""Test that handler.set_plugin_config is called."""
async def test_updates_revision_then_applies_desired_state(self):
"""Config changes are fenced by a new runtime revision."""
get_connector_module()
connector = create_mock_connector()
connector.handler = AsyncMock()
connector.handler.set_plugin_config = AsyncMock(return_value={'status': 'ok'})
configure_handler(connector, AsyncMock())
connector.handler.register_installation_binding = Mock()
connector.handler.apply_plugin_installation = AsyncMock(return_value={'state': 'running'})
setting = SimpleNamespace(
installation_uuid=TEST_INSTALLATION_BINDING.installation_uuid,
runtime_revision=1,
artifact_digest=TEST_INSTALLATION_BINDING.artifact_digest,
enabled=True,
install_info={'_artifact_storage': 'tenant_binary_storage_v1'},
)
connector._setting_for_plugin = AsyncMock(return_value=(TEST_EXECUTION_CONTEXT, setting))
connector.ap.persistence_mgr.execute_async = AsyncMock(return_value=SimpleNamespace(rowcount=1))
await connector.set_plugin_config('author', 'plugin', {'setting': 'value'})
connector.handler.set_plugin_config.assert_called_once_with('author', 'plugin', {'setting': 'value'})
applied_binding = connector.handler.apply_plugin_installation.await_args.args[0]
assert applied_binding.runtime_revision == 2
assert applied_binding.installation_uuid == TEST_INSTALLATION_BINDING.installation_uuid
connector.handler.register_installation_binding.assert_called_once_with(
applied_binding,
plugin_author='author',
plugin_name='plugin',
)
connector.handler.apply_plugin_installation.assert_awaited_once_with(
applied_binding,
artifact_package=None,
enabled=True,
)
class TestPingPluginRuntime:
@@ -621,7 +665,7 @@ class TestPingPluginRuntime:
get_connector_module()
connector = create_mock_connector()
connector.handler = AsyncMock()
configure_handler(connector, AsyncMock())
connector.handler.ping = AsyncMock(return_value={'status': 'ok'})
await connector.ping_plugin_runtime()
+64 -54
View File
@@ -7,7 +7,6 @@ import pytest
from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.plugin.connector import PluginRuntimeConnector, PluginRuntimeNotConnectedError
from langbot_plugin.entities.io.context import ActionContext
from langbot_plugin.runtime.security import (
PLUGIN_RUNTIME_CONTROL_TOKEN_ENV,
PLUGIN_RUNTIME_CONTROL_TOKEN_HEADER,
@@ -91,7 +90,7 @@ async def test_enabled_connector_reports_not_connected_after_workspace_validatio
@pytest.mark.asyncio
async def test_connector_resolves_oss_singleton_binding_from_workspace_service():
async def test_oss_connector_resolves_singleton_only_for_legacy_callers():
connector = make_connector()
connector.ap.workspace_service = SimpleNamespace(
get_local_execution_binding=AsyncMock(
@@ -103,31 +102,29 @@ async def test_connector_resolves_oss_singleton_binding_from_workspace_service()
)
)
assert await connector._resolve_action_context() == ActionContext(
assert await connector._current_execution_context() == ExecutionContext(
instance_uuid='instance-a',
workspace_uuid='workspace-a',
placement_generation=3,
)
@pytest.mark.asyncio
async def test_edition_metadata_cannot_enable_multi_workspace_runtime_binding():
def test_edition_metadata_cannot_enable_shared_runtime_profile():
connector = make_connector()
connector.ap.instance_config.data['system'] = {'edition': 'cloud'}
connector.ap.workspace_service = SimpleNamespace(
policy=SimpleNamespace(multi_workspace_enabled=False),
get_local_execution_binding=AsyncMock(
return_value=SimpleNamespace(
instance_uuid='instance-a',
workspace_uuid='workspace-a',
placement_generation=1,
)
),
assert connector.runtime_profile == 'oss_dev'
def test_closed_deployment_selects_instance_scoped_shared_profile():
app = SimpleNamespace(
instance_config=SimpleNamespace(data={'plugin': {'enable': True}}),
deployment=SimpleNamespace(mode='cloud'),
)
context = await connector._resolve_action_context()
connector = PluginRuntimeConnector(app, AsyncMock())
assert context.workspace_uuid == 'workspace-a'
assert connector.runtime_profile == 'shared'
def test_external_runtime_control_headers_require_strong_secret(monkeypatch):
@@ -148,48 +145,61 @@ def test_local_runtime_control_headers_generate_ephemeral_secret(monkeypatch):
@pytest.mark.asyncio
async def test_connector_fails_closed_without_trusted_workspace_service():
async def test_oss_legacy_fallback_fails_without_workspace_service():
connector = make_connector()
with pytest.raises(
RuntimeError,
match='Plugin Runtime Workspace binding is unavailable',
):
await connector._resolve_action_context()
with pytest.raises(AttributeError):
await connector._current_execution_context()
@pytest.mark.asyncio
async def test_cloud_connector_never_falls_back_to_ghost_local_workspace():
connector = make_connector()
connector.ap.instance_config.data['system'] = {'edition': 'cloud'}
app = SimpleNamespace(
instance_config=SimpleNamespace(data={'plugin': {'enable': True}}),
deployment=SimpleNamespace(mode='cloud'),
)
get_local_binding = AsyncMock()
connector.ap.workspace_service = SimpleNamespace(
policy=SimpleNamespace(multi_workspace_enabled=True),
app.workspace_service = SimpleNamespace(
get_local_execution_binding=get_local_binding,
)
connector = PluginRuntimeConnector(app, AsyncMock())
with pytest.raises(
RuntimeError,
match='require an explicit projected Workspace binding',
):
await connector._resolve_action_context()
with pytest.raises(Exception, match='Plugin resource not found'):
await connector._current_execution_context()
get_local_binding.assert_not_awaited()
@pytest.mark.asyncio
async def test_cloud_connector_initialize_fails_before_opening_unbound_runtime():
connector = make_connector()
connector.ap.instance_config.data['system'] = {'edition': 'cloud'}
connector.ap.workspace_service = SimpleNamespace(
policy=SimpleNamespace(multi_workspace_enabled=True),
def test_worker_policy_is_loaded_only_from_instance_configuration():
app = SimpleNamespace(
instance_config=SimpleNamespace(
data={
'plugin': {
'enable': True,
'worker': {
'max_cpus': 1.5,
'max_memory_mb': 768,
'max_pids': 64,
'max_open_files': 128,
'max_file_size_mb': 32,
'require_hard_limits': True,
},
# A plugin-controlled value at any other path is ignored.
'manifest': {'max_memory_mb': 99999},
}
}
)
)
connector = PluginRuntimeConnector(app, AsyncMock())
with pytest.raises(
RuntimeError,
match='require an explicit projected Workspace binding',
):
await connector.initialize()
policy = connector._load_worker_policy()
assert policy.max_cpus == 1.5
assert policy.max_memory_mb == 768
assert policy.max_pids == 64
assert policy.max_open_files == 128
assert policy.max_file_size_mb == 32
assert policy.require_hard_limits is True
@pytest.mark.asyncio
@@ -198,17 +208,11 @@ async def test_explicit_cloud_binding_is_revalidated_against_projection():
instance_config=SimpleNamespace(
data={
'plugin': {'enable': True},
'system': {'edition': 'cloud'},
}
)
)
configured = ActionContext(
instance_uuid='instance-a',
workspace_uuid='workspace-cloud-a',
placement_generation=7,
),
deployment=SimpleNamespace(mode='cloud'),
)
app.workspace_service = SimpleNamespace(
policy=SimpleNamespace(multi_workspace_enabled=True),
get_execution_binding=AsyncMock(
return_value=SimpleNamespace(
instance_uuid='instance-a',
@@ -216,13 +220,19 @@ async def test_explicit_cloud_binding_is_revalidated_against_projection():
placement_generation=7,
)
),
get_local_execution_binding=AsyncMock(),
)
connector = PluginRuntimeConnector(app, AsyncMock(), action_context=configured)
connector = PluginRuntimeConnector(app, AsyncMock())
connector.handler = SimpleNamespace()
connector._synchronize_workspace = AsyncMock()
configured = ExecutionContext(
instance_uuid='instance-a',
workspace_uuid='workspace-cloud-a',
placement_generation=7,
)
assert await connector._resolve_action_context() == configured
assert await connector.require_workspace_context(configured) == configured
app.workspace_service.get_execution_binding.assert_awaited_once_with(
'workspace-cloud-a',
expected_generation=7,
)
app.workspace_service.get_local_execution_binding.assert_not_awaited()
connector._synchronize_workspace.assert_awaited_once_with(configured)
@@ -0,0 +1,482 @@
from __future__ import annotations
import datetime
import hashlib
from contextlib import nullcontext
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from langbot_plugin.entities.io.context import InstallationBinding
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.plugin.connector import (
PluginInstallationFailedError,
PluginRuntimeConnector,
)
def connection_result_connector(execute_async: AsyncMock) -> PluginRuntimeConnector:
app = SimpleNamespace(
instance_config=SimpleNamespace(data={'plugin': {'enable': True}}),
deployment=SimpleNamespace(mode='cloud'),
persistence_mgr=SimpleNamespace(
tenant_uow=None,
execute_async=execute_async,
),
logger=Mock(),
)
return PluginRuntimeConnector(app, AsyncMock())
def execution_binding(workspace_uuid: str, generation: int = 1) -> SimpleNamespace:
return SimpleNamespace(
instance_uuid='instance-a',
workspace_uuid=workspace_uuid,
placement_generation=generation,
)
def plugin_setting(
workspace_suffix: str,
artifact_digest: str,
*,
durable: bool = True,
) -> SimpleNamespace:
return SimpleNamespace(
plugin_author='author',
plugin_name=f'plugin-{workspace_suffix}',
installation_uuid=f'00000000-0000-4000-8000-0000000000{workspace_suffix}',
runtime_revision=1,
artifact_digest=artifact_digest,
enabled=True,
priority=0,
created_at=datetime.datetime(2026, 1, 1),
install_source='local',
install_info={'_artifact_storage': 'tenant_binary_storage_v1'} if durable else {},
)
def runtime_handler(
*,
missing_artifacts: list[str] | None = None,
failed_installations: list[dict[str, str]] | None = None,
) -> SimpleNamespace:
return SimpleNamespace(
register_installation_binding=Mock(),
unregister_installation_binding=Mock(),
reconcile_plugin_installations=AsyncMock(
return_value={
'applied': [],
'removed': [],
'missing_artifacts': missing_artifacts or [],
'failed_installations': failed_installations or [],
}
),
apply_plugin_installation=AsyncMock(return_value={'state': 'starting'}),
installation_scope=Mock(side_effect=lambda _binding: nullcontext()),
list_plugins=AsyncMock(return_value=[]),
)
def shared_connector(
projected_bindings: list[list[SimpleNamespace]],
settings: dict[str, list[SimpleNamespace]],
) -> PluginRuntimeConnector:
async def get_execution_binding(workspace_uuid: str, *, expected_generation: int | None = None):
for binding_set in projected_bindings:
for binding in binding_set:
if binding.workspace_uuid == workspace_uuid:
assert expected_generation in (None, binding.placement_generation)
return binding
raise AssertionError(f'unexpected Workspace {workspace_uuid}')
app = SimpleNamespace(
instance_config=SimpleNamespace(data={'plugin': {'enable': True}}),
deployment=SimpleNamespace(mode='cloud'),
workspace_service=SimpleNamespace(
list_active_execution_bindings=AsyncMock(side_effect=projected_bindings),
get_execution_binding=AsyncMock(side_effect=get_execution_binding),
),
persistence_mgr=SimpleNamespace(tenant_uow=None),
logger=Mock(),
)
connector = PluginRuntimeConnector(app, AsyncMock())
connector._load_workspace_settings = AsyncMock(side_effect=lambda context: settings[context.workspace_uuid])
return connector
@pytest.mark.asyncio
async def test_shared_reconnect_replays_two_workspaces_and_removes_missing_projection():
binding_a = execution_binding('workspace-a')
binding_b = execution_binding('workspace-b')
setting_a = plugin_setting('01', 'a' * 64)
setting_b = plugin_setting('02', 'b' * 64)
connector = shared_connector(
[[binding_a, binding_b], [binding_a]],
{'workspace-a': [setting_a], 'workspace-b': [setting_b]},
)
first_handler = runtime_handler()
connector.handler = first_handler
await connector._prepare_connected_runtime()
first_desired = first_handler.reconcile_plugin_installations.await_args.args[0]
assert {state.binding.workspace_uuid for state in first_desired} == {'workspace-a', 'workspace-b'}
second_handler = runtime_handler()
connector.handler = second_handler
await connector._prepare_connected_runtime()
second_desired = second_handler.reconcile_plugin_installations.await_args.args[0]
assert [state.binding.workspace_uuid for state in second_desired] == ['workspace-a']
second_handler.unregister_installation_binding.assert_called_once_with(first_desired[1].binding)
assert set(connector._workspace_installations) == {'workspace-a'}
assert set(connector._known_desired_states) == {setting_a.installation_uuid}
@pytest.mark.asyncio
async def test_fresh_shared_runtime_cache_replays_persisted_local_package():
package = b'local-lbpkg-bytes'
digest = hashlib.sha256(package).hexdigest()
binding = execution_binding('workspace-a')
setting = plugin_setting('01', digest)
connector = shared_connector([[binding]], {'workspace-a': [setting]})
connector.handler = runtime_handler(missing_artifacts=[setting.installation_uuid])
connector._load_artifact_package = AsyncMock(return_value=package)
await connector._prepare_connected_runtime()
desired = connector.handler.reconcile_plugin_installations.await_args.args[0][0]
connector._load_artifact_package.assert_awaited_once()
connector.handler.apply_plugin_installation.assert_awaited_once_with(
desired.binding,
artifact_package=package,
enabled=True,
)
@pytest.mark.asyncio
async def test_oss_upgrade_keeps_legacy_data_plugins_when_no_lbpkg_was_backfilled():
binding = execution_binding('workspace-a')
setting = plugin_setting('01', hashlib.sha256(b'legacy-installation').hexdigest(), durable=False)
legacy_plugin = {
'debug': False,
'manifest': {'manifest': {'metadata': {'author': 'author', 'name': 'plugin-01'}}},
'components': [],
}
app = SimpleNamespace(
instance_config=SimpleNamespace(data={'plugin': {'enable': True}}),
deployment=SimpleNamespace(mode='oss'),
workspace_service=SimpleNamespace(get_local_execution_binding=AsyncMock(return_value=binding)),
persistence_mgr=SimpleNamespace(tenant_uow=None),
logger=Mock(),
)
connector = PluginRuntimeConnector(app, AsyncMock())
connector.handler = runtime_handler(missing_artifacts=[setting.installation_uuid])
connector.handler.list_plugins = AsyncMock(return_value=[legacy_plugin])
connector.handler.apply_plugin_installation = AsyncMock(return_value={'state': 'artifact_missing'})
connector._load_workspace_settings = AsyncMock(return_value=[setting])
connector._load_artifact_package = AsyncMock(return_value=None)
await connector._prepare_connected_runtime()
plugins = await connector.list_plugins()
assert plugins == [legacy_plugin]
assert connector.handler.list_plugins.await_count >= 2
connector._load_artifact_package.assert_awaited_once()
assert not hasattr(connector.handler, 'delete_plugin')
@pytest.mark.asyncio
async def test_local_install_persists_verified_package_before_runtime_apply():
package = b'local-lbpkg-bytes'
digest = hashlib.sha256(package).hexdigest()
execution_context = ExecutionContext(
instance_uuid='instance-a',
workspace_uuid='workspace-a',
placement_generation=1,
)
binding = InstallationBinding(
instance_uuid='instance-a',
workspace_uuid='workspace-a',
placement_generation=1,
installation_uuid='00000000-0000-4000-8000-000000000001',
runtime_revision=1,
artifact_digest=digest,
)
app = SimpleNamespace(
instance_config=SimpleNamespace(data={'plugin': {'enable': True}}),
deployment=SimpleNamespace(mode='cloud'),
logger=Mock(),
)
connector = PluginRuntimeConnector(app, AsyncMock())
connector.handler = runtime_handler()
connector._current_execution_context = AsyncMock(return_value=execution_context)
connector._inspect_plugin_package = Mock(return_value=('author', 'plugin'))
connector._store_artifact_package = AsyncMock()
connector._persist_installation_package = AsyncMock(return_value=(binding, None, False))
connector._wait_for_installed_plugin_ready = AsyncMock()
await connector.install_plugin(
PluginInstallSource.LOCAL,
{'plugin_file': package},
)
connector._store_artifact_package.assert_awaited_once_with(execution_context, digest, package)
connector.handler.apply_plugin_installation.assert_awaited_once_with(
binding,
artifact_package=package,
enabled=True,
)
@pytest.mark.asyncio
@pytest.mark.parametrize(('remaining_references', 'statement_count'), [(1, 1), (0, 2)])
async def test_artifact_cleanup_is_reference_counted_within_workspace(
remaining_references: int,
statement_count: int,
):
app = SimpleNamespace(
instance_config=SimpleNamespace(data={'plugin': {'enable': True}}),
deployment=SimpleNamespace(mode='cloud'),
)
connector = PluginRuntimeConnector(app, AsyncMock())
execution_context = ExecutionContext(
instance_uuid='instance-a',
workspace_uuid='workspace-a',
placement_generation=1,
)
statements = []
async def execute(statement):
statements.append(statement)
return SimpleNamespace(scalar_one=lambda: remaining_references)
await connector._delete_artifact_if_unreferenced(
execution_context,
'a' * 64,
execute=execute,
)
assert len(statements) == statement_count
@pytest.mark.asyncio
async def test_artifact_store_and_load_accept_connection_scalar_results():
package = b'persisted-lbpkg'
digest = hashlib.sha256(package).hexdigest()
execution_context = ExecutionContext(
instance_uuid='instance-a',
workspace_uuid='workspace-a',
placement_generation=1,
)
execute_async = AsyncMock(return_value=SimpleNamespace(scalar_one_or_none=lambda: package))
connector = connection_result_connector(execute_async)
await connector._store_artifact_package(execution_context, digest, package)
loaded = await connector._load_artifact_package(execution_context, digest)
assert loaded == package
assert execute_async.await_count == 2
@pytest.mark.asyncio
async def test_workspace_settings_accept_connection_mapping_results():
created_at = datetime.datetime(2026, 1, 1)
row = {
'workspace_uuid': 'workspace-a',
'plugin_author': 'author',
'plugin_name': 'plugin',
'installation_uuid': '00000000-0000-4000-8000-000000000001',
'artifact_digest': 'a' * 64,
'runtime_revision': 2,
'enabled': True,
'priority': 3,
'config': {'key': 'value'},
'install_source': 'local',
'install_info': {'_artifact_storage': 'tenant_binary_storage_v1'},
'created_at': created_at,
'updated_at': created_at,
}
mapped_result = SimpleNamespace(mappings=lambda: SimpleNamespace(all=lambda: [row]))
connector = connection_result_connector(AsyncMock(return_value=mapped_result))
settings = await connector._load_workspace_settings(
ExecutionContext(
instance_uuid='instance-a',
workspace_uuid='workspace-a',
placement_generation=1,
)
)
assert len(settings) == 1
assert settings[0].installation_uuid == row['installation_uuid']
assert settings[0].runtime_revision == 2
assert settings[0].created_at == created_at
@pytest.mark.asyncio
async def test_installation_update_accepts_connection_row_result():
old_digest = 'a' * 64
new_digest = 'b' * 64
existing = SimpleNamespace(
installation_uuid='00000000-0000-4000-8000-000000000001',
runtime_revision=4,
artifact_digest=old_digest,
install_info={'_artifact_storage': 'tenant_binary_storage_v1'},
)
execute_async = AsyncMock(
side_effect=[
SimpleNamespace(first=lambda: existing),
SimpleNamespace(rowcount=1),
]
)
connector = connection_result_connector(execute_async)
execution_context = ExecutionContext(
instance_uuid='instance-a',
workspace_uuid='workspace-a',
placement_generation=7,
)
binding, previous_digest, previous_was_durable = await connector._persist_installation_package(
execution_context,
plugin_author='author',
plugin_name='plugin',
install_source=PluginInstallSource.LOCAL,
install_info={},
artifact_digest=new_digest,
)
assert binding.installation_uuid == existing.installation_uuid
assert binding.runtime_revision == 5
assert binding.artifact_digest == new_digest
assert previous_digest == old_digest
assert previous_was_durable is True
@pytest.mark.asyncio
async def test_apply_dependency_failure_raises_stable_observable_error():
binding = execution_binding('workspace-a')
setting = plugin_setting('01', 'a' * 64)
connector = shared_connector([[binding]], {'workspace-a': [setting]})
connector.handler = runtime_handler()
desired = (
await connector._load_workspace_desired_states(
ExecutionContext(
instance_uuid='instance-a',
workspace_uuid='workspace-a',
placement_generation=1,
)
)
)[0]
connector.handler.apply_plugin_installation.return_value = {
'installation_uuid': setting.installation_uuid,
'state': 'failed',
'error_code': 'dependency_prepare_failed',
'message': 'Plugin dependency installer exited with code 1',
}
with pytest.raises(PluginInstallationFailedError) as exc_info:
await connector._apply_desired_state(desired)
error = exc_info.value
assert error.installation_uuid == setting.installation_uuid
assert error.error_code == 'dependency_prepare_failed'
assert '[dependency_prepare_failed]' in str(error)
assert connector._installation_failures[setting.installation_uuid] == {
'installation_uuid': setting.installation_uuid,
'error_code': 'dependency_prepare_failed',
'message': 'Plugin dependency installer exited with code 1',
}
connector.ap.logger.error.assert_called_once()
@pytest.mark.asyncio
async def test_shared_reconcile_records_one_failure_without_blocking_other_state():
binding_a = execution_binding('workspace-a')
binding_b = execution_binding('workspace-b')
setting_a = plugin_setting('01', 'a' * 64)
setting_b = plugin_setting('02', 'b' * 64)
failure = {
'installation_uuid': setting_a.installation_uuid,
'error_code': 'dependency_prepare_failed',
'message': 'Plugin dependency installer exited with code 1',
}
connector = shared_connector(
[[binding_a, binding_b]],
{'workspace-a': [setting_a], 'workspace-b': [setting_b]},
)
connector.handler = runtime_handler(failed_installations=[failure])
await connector._prepare_connected_runtime()
desired = connector.handler.reconcile_plugin_installations.await_args.args[0]
assert {item.binding.installation_uuid for item in desired} == {
setting_a.installation_uuid,
setting_b.installation_uuid,
}
assert connector._installation_failures == {
setting_a.installation_uuid: failure,
}
assert set(connector._known_desired_states) == {
setting_a.installation_uuid,
setting_b.installation_uuid,
}
connector.ap.logger.error.assert_called_once_with(
'Plugin installation %s failed during reconcile [%s]: %s',
setting_a.installation_uuid,
'dependency_prepare_failed',
'Plugin dependency installer exited with code 1',
)
@pytest.mark.asyncio
async def test_missing_artifact_repair_adds_dependency_failure_and_continues():
package_a = b'package-a'
package_b = b'package-b'
binding = execution_binding('workspace-a')
setting_a = plugin_setting('01', hashlib.sha256(package_a).hexdigest())
setting_b = plugin_setting('02', hashlib.sha256(package_b).hexdigest())
connector = shared_connector(
[[binding]],
{'workspace-a': [setting_a, setting_b]},
)
connector.handler = runtime_handler(
missing_artifacts=[
setting_a.installation_uuid,
setting_b.installation_uuid,
]
)
connector._load_artifact_package = AsyncMock(side_effect=[package_a, package_b])
connector.handler.apply_plugin_installation.side_effect = [
{
'installation_uuid': setting_a.installation_uuid,
'state': 'failed',
'error_code': 'dependency_prepare_failed',
'message': 'Plugin dependency installer exited with code 1',
},
{'installation_uuid': setting_b.installation_uuid, 'state': 'starting'},
]
result = await connector.reconcile_projected_workspaces(
[
ExecutionContext(
instance_uuid='instance-a',
workspace_uuid='workspace-a',
placement_generation=1,
)
]
)
assert connector.handler.apply_plugin_installation.await_count == 2
assert result['failed_installations'] == [
{
'installation_uuid': setting_a.installation_uuid,
'error_code': 'dependency_prepare_failed',
'message': 'Plugin dependency installer exited with code 1',
}
]
assert setting_a.installation_uuid in connector._installation_failures
assert setting_b.installation_uuid not in connector._installation_failures
@@ -0,0 +1,174 @@
from __future__ import annotations
import httpx
import pytest
from langbot.pkg.plugin import connector as connector_module
from .test_connector_methods import create_mock_connector
TRUSTED_LEGACY_REQUEST = {
'owner': 'langbot-app',
'repo': 'demo-plugin',
'release_tag': 'v1.0.0',
'asset_url': 'https://github.com/langbot-app/demo-plugin/releases/download/v1.0.0/demo.lbpkg',
}
def _patch_client(monkeypatch, handler):
real_async_client = httpx.AsyncClient
observed: list[dict] = []
def client_factory(*args, **kwargs):
observed.append(dict(kwargs))
return real_async_client(
transport=httpx.MockTransport(handler),
follow_redirects=kwargs.get('follow_redirects', False),
trust_env=kwargs.get('trust_env', True),
timeout=kwargs.get('timeout'),
)
monkeypatch.setattr(connector_module.httpx, 'AsyncClient', client_factory)
return observed
@pytest.mark.parametrize(
'asset_url',
[
'http://127.0.0.1/internal.lbpkg',
'https://169.254.169.254/latest/meta-data',
'https://github.com@127.0.0.1/internal.lbpkg',
'https://evil.example/langbot-app/demo-plugin/releases/download/v1.0.0/demo.lbpkg',
],
)
@pytest.mark.asyncio
async def test_github_install_rejects_internal_or_untrusted_asset_url_before_network(
monkeypatch,
asset_url,
):
connector = create_mock_connector()
monkeypatch.setattr(
connector_module.httpx,
'AsyncClient',
lambda *_args, **_kwargs: pytest.fail('network client must not be created'),
)
with pytest.raises(ValueError, match='GitHub release asset URL'):
await connector._download_github_package(
{**TRUSTED_LEGACY_REQUEST, 'asset_url': asset_url},
None,
)
@pytest.mark.asyncio
async def test_github_asset_id_is_resolved_server_side_and_redirect_escape_is_rejected(
monkeypatch,
):
connector = create_mock_connector()
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith('/releases/42'):
return httpx.Response(
200,
json={
'id': 42,
'tag_name': 'v1.0.0',
'assets': [{'id': 99, 'size': 128, 'state': 'uploaded'}],
},
)
if request.url.path.endswith('/releases/assets/99'):
return httpx.Response(
302,
headers={'location': 'http://169.254.169.254/latest/meta-data'},
)
raise AssertionError(f'unexpected request: {request.url}')
observed = _patch_client(monkeypatch, handler)
with pytest.raises(ValueError, match='untrusted host'):
await connector._download_github_package(
{
'owner': 'langbot-app',
'repo': 'demo-plugin',
'release_tag': 'v1.0.0',
'release_id': 42,
'asset_id': 99,
'asset_url': 'https://attacker.invalid/ignored',
},
None,
)
assert len(observed) == 1
assert observed[0]['trust_env'] is False
assert observed[0]['follow_redirects'] is False
@pytest.mark.asyncio
async def test_github_download_rejects_oversized_content_length(monkeypatch):
connector = create_mock_connector()
monkeypatch.setattr(connector_module, '_GITHUB_PLUGIN_DOWNLOAD_MAX_BYTES', 8)
def handler(_request: httpx.Request) -> httpx.Response:
return httpx.Response(200, headers={'content-length': '9'}, content=b'')
_patch_client(monkeypatch, handler)
with pytest.raises(ValueError, match='10 MiB download limit'):
await connector._download_github_package(TRUSTED_LEGACY_REQUEST, None)
@pytest.mark.asyncio
async def test_github_download_counts_chunked_stream_bytes(monkeypatch):
connector = create_mock_connector()
monkeypatch.setattr(connector_module, '_GITHUB_PLUGIN_DOWNLOAD_MAX_BYTES', 8)
class ChunkedBody(httpx.AsyncByteStream):
async def __aiter__(self):
yield b'1234'
yield b'56789'
def handler(_request: httpx.Request) -> httpx.Response:
return httpx.Response(200, stream=ChunkedBody())
_patch_client(monkeypatch, handler)
with pytest.raises(ValueError, match='10 MiB download limit'):
await connector._download_github_package(TRUSTED_LEGACY_REQUEST, None)
@pytest.mark.asyncio
async def test_github_asset_id_download_allows_github_object_redirect(monkeypatch):
connector = create_mock_connector()
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith('/releases/42'):
return httpx.Response(
200,
json={
'id': 42,
'tag_name': 'v1.0.0',
'assets': [{'id': 99, 'size': 7, 'state': 'uploaded'}],
},
)
if request.url.path.endswith('/releases/assets/99'):
return httpx.Response(
302,
headers={
'location': 'https://release-assets.githubusercontent.com/github-production-release-asset/demo'
},
)
if request.url.host == 'release-assets.githubusercontent.com':
return httpx.Response(200, content=b'package')
raise AssertionError(f'unexpected request: {request.url}')
_patch_client(monkeypatch, handler)
package = await connector._download_github_package(
{
'owner': 'langbot-app',
'repo': 'demo-plugin',
'release_tag': 'v1.0.0',
'release_id': 42,
'asset_id': 99,
},
None,
)
assert package == b'package'
+12 -7
View File
@@ -10,7 +10,7 @@ from unittest.mock import AsyncMock, MagicMock, Mock
import pytest
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction
from langbot_plugin.entities.io.context import ActionContext
from langbot_plugin.entities.io.context import ActionContext, InstallationBinding
def make_handler(app):
@@ -35,14 +35,19 @@ def make_handler(app):
Mock(),
AsyncMock(return_value=True),
app,
workspace_context,
)
installation_uuid = runtime_handler._remember_installation(
workspace_context,
'test-author',
'test-plugin',
installation_binding = InstallationBinding(
**workspace_context.model_dump(exclude_none=True),
installation_uuid='00000000-0000-4000-8000-000000000001',
runtime_revision=1,
artifact_digest='a' * 64,
)
runtime_handler.bind_action_context(workspace_context.for_installation(installation_uuid))
runtime_handler.register_installation_binding(
installation_binding,
plugin_author='test-author',
plugin_name='test-plugin',
)
runtime_handler._current_action_context.set(installation_binding)
query_pool = getattr(app, 'query_pool', None)
if query_pool is not None and hasattr(query_pool, 'cached_queries'):
+40 -67
View File
@@ -8,7 +8,7 @@ from unittest.mock import AsyncMock, Mock
import pytest
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction, RuntimeToLangBotAction
from langbot_plugin.entities.io.context import ActionContext
from langbot_plugin.entities.io.context import ActionContext, InstallationBinding
from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.storage.mgr import StorageMgr
@@ -52,14 +52,19 @@ def make_handler(app, workspace_context: ActionContext | None = None):
Mock(),
AsyncMock(return_value=True),
app,
workspace_context,
)
installation_uuid = runtime_handler._remember_installation(
workspace_context,
'test-author',
'test-plugin',
installation_binding = InstallationBinding(
**workspace_context.model_dump(exclude_none=True),
installation_uuid='00000000-0000-4000-8000-000000000001',
runtime_revision=1,
artifact_digest='a' * 64,
)
runtime_handler.bind_action_context(workspace_context.for_installation(installation_uuid))
runtime_handler.register_installation_binding(
installation_binding,
plugin_author='test-author',
plugin_name='test-plugin',
)
runtime_handler._current_action_context.set(installation_binding)
query_pool = getattr(app, 'query_pool', None)
if query_pool is not None and hasattr(query_pool, 'cached_queries'):
@@ -169,13 +174,10 @@ class TestInitializePluginSettings:
return mock_app
@pytest.mark.asyncio
async def test_creates_new_setting_when_not_exists(self, app):
"""New plugin settings use default enabled, priority and config values."""
async def test_rejects_desired_installation_when_setting_not_exists(self, app):
"""A desired-state worker cannot create an unowned Core setting row."""
runtime_handler = make_handler(app)
app.persistence_mgr.execute_async.side_effect = [
make_result(),
Mock(),
]
app.persistence_mgr.execute_async.return_value = make_result()
response = await runtime_handler.actions[RuntimeToLangBotAction.INITIALIZE_PLUGIN_SETTINGS.value](
{
@@ -186,34 +188,23 @@ class TestInitializePluginSettings:
}
)
assert response.code == 0
assert app.persistence_mgr.execute_async.await_count == 2
insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[1].args[0])
assert insert_params == {
'workspace_uuid': 'workspace-a',
'plugin_author': 'test-author',
'plugin_name': 'test-plugin',
'install_source': 'local',
'install_info': {'path': '/test'},
'enabled': True,
'priority': 0,
'config': {},
}
assert response.code != 0
assert 'Plugin installation setting was not found' in response.message
app.persistence_mgr.execute_async.assert_awaited_once()
@pytest.mark.asyncio
async def test_inherits_values_from_existing_setting(self, app):
"""Existing settings are replaced while preserving user-controlled values."""
async def test_existing_desired_setting_remains_core_owned(self, app):
"""Runtime initialization validates identity without rewriting Core state."""
runtime_handler = make_handler(app)
existing_setting = SimpleNamespace(
enabled=False,
priority=5,
config={'key': 'value'},
installation_uuid='00000000-0000-4000-8000-000000000001',
runtime_revision=1,
artifact_digest='a' * 64,
)
app.persistence_mgr.execute_async.side_effect = [
make_result(existing_setting),
Mock(),
Mock(),
]
app.persistence_mgr.execute_async.return_value = make_result(existing_setting)
response = await runtime_handler.actions[RuntimeToLangBotAction.INITIALIZE_PLUGIN_SETTINGS.value](
{
@@ -225,13 +216,7 @@ class TestInitializePluginSettings:
)
assert response.code == 0
assert app.persistence_mgr.execute_async.await_count == 3
insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[2].args[0])
assert insert_params['enabled'] is False
assert insert_params['priority'] == 5
assert insert_params['config'] == {'key': 'value'}
assert insert_params['install_source'] == 'github'
assert insert_params['install_info'] == {'repo': 'author/name'}
app.persistence_mgr.execute_async.assert_awaited_once()
class TestSetBinaryStorage:
@@ -367,31 +352,18 @@ class TestGetPluginSettings:
return mock_app
@pytest.mark.asyncio
async def test_returns_defaults_when_setting_not_found(self, app):
"""Default plugin settings are returned when no persisted row exists."""
async def test_rejects_desired_installation_when_setting_not_found(self, app):
"""A desired-state worker cannot synthesize settings for a missing row."""
runtime_handler = make_handler(app)
app.persistence_mgr.execute_async.return_value = make_result()
response = await runtime_handler.actions[RuntimeToLangBotAction.GET_PLUGIN_SETTINGS.value](
{
'plugin_author': 'test-author',
'plugin_name': 'test-plugin',
}
)
assert response.code == 0
assert response.data == {
'enabled': True,
'priority': 0,
'plugin_config': {},
'install_source': 'local',
'install_info': {},
'installation_uuid': runtime_handler.derive_installation_uuid(
runtime_handler.require_bound_action_context(),
'test-author',
'test-plugin',
),
}
with pytest.raises(ValueError, match='Plugin installation setting was not found'):
await runtime_handler.actions[RuntimeToLangBotAction.GET_PLUGIN_SETTINGS.value](
{
'plugin_author': 'test-author',
'plugin_name': 'test-plugin',
}
)
@pytest.mark.asyncio
async def test_returns_actual_values_when_setting_exists(self, app):
@@ -403,6 +375,9 @@ class TestGetPluginSettings:
config={'custom': 'config'},
install_source='github',
install_info={'repo': 'test/repo'},
installation_uuid='00000000-0000-4000-8000-000000000001',
runtime_revision=1,
artifact_digest='a' * 64,
)
app.persistence_mgr.execute_async.return_value = make_result(setting)
@@ -420,11 +395,9 @@ class TestGetPluginSettings:
'plugin_config': {'custom': 'config'},
'install_source': 'github',
'install_info': {'repo': 'test/repo'},
'installation_uuid': runtime_handler.derive_installation_uuid(
runtime_handler.require_bound_action_context(),
'test-author',
'test-plugin',
),
'installation_uuid': '00000000-0000-4000-8000-000000000001',
'runtime_revision': 1,
'artifact_digest': 'a' * 64,
}
+180 -37
View File
@@ -1,19 +1,22 @@
from __future__ import annotations
import asyncio
import contextlib
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from sqlalchemy.ext.asyncio import create_async_engine
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction
from langbot_plugin.entities.io.context import ActionContext
from langbot_plugin.entities.io.context import ActionContext, InstallationBinding, PluginWorkerPolicy, RuntimeIdentity
from langbot_plugin.entities.io.resp import ActionResponse
from langbot_plugin.runtime.io.connection import Connection
from langbot.pkg.plugin.handler import RuntimeConnectionHandler
from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
class EmptyResult:
@@ -49,6 +52,7 @@ def workspace_context(workspace_uuid: str = 'workspace-a') -> ActionContext:
def make_handler(workspace_uuid: str = 'workspace-a'):
context = workspace_context(workspace_uuid)
app = SimpleNamespace(
deployment=SimpleNamespace(mode='cloud'),
persistence_mgr=SimpleNamespace(execute_async=AsyncMock(return_value=EmptyResult())),
logger=Mock(),
workspace_service=SimpleNamespace(
@@ -65,14 +69,19 @@ def make_handler(workspace_uuid: str = 'workspace-a'):
Mock(),
AsyncMock(return_value=True),
app,
context,
)
installation_uuid = runtime_handler._remember_installation(
context,
'author-a',
'plugin-a',
installation_context = InstallationBinding(
**context.model_dump(exclude_none=True),
installation_uuid='00000000-0000-4000-8000-000000000001',
runtime_revision=1,
artifact_digest='a' * 64,
)
return runtime_handler, app, context.for_installation(installation_uuid)
runtime_handler.register_installation_binding(
installation_context,
plugin_author='author-a',
plugin_name='plugin-a',
)
return runtime_handler, app, installation_context
async def invoke_with_context(
@@ -92,10 +101,98 @@ async def invoke_with_context(
async def test_plugin_action_requires_installation_capability():
runtime_handler, _app, _installation_context = make_handler()
with pytest.raises(ValueError, match='missing installation_uuid'):
with pytest.raises(ValueError, match='trusted Workspace context'):
await runtime_handler.actions[PluginToRuntimeAction.GET_LANGBOT_VERSION.value]({})
@pytest.mark.asyncio
async def test_runtime_action_enters_trusted_workspace_scope():
runtime_handler, app, installation_context = make_handler()
scope_events: list[tuple[str, str]] = []
@contextlib.asynccontextmanager
async def tenant_scope(workspace_uuid: str):
scope_events.append(('enter', workspace_uuid))
try:
yield SimpleNamespace()
finally:
scope_events.append(('exit', workspace_uuid))
class RecordingPersistenceManager:
def __init__(self, execute_async):
self.execute_async = execute_async
def tenant_scope(self, workspace_uuid: str):
return tenant_scope(workspace_uuid)
app.persistence_mgr = RecordingPersistenceManager(app.persistence_mgr.execute_async)
response = await invoke_with_context(
runtime_handler,
installation_context,
PluginToRuntimeAction.GET_LANGBOT_VERSION,
{},
)
assert response.code == 0
assert scope_events == [('enter', 'workspace-a'), ('exit', 'workspace-a')]
@pytest.mark.asyncio
async def test_blocked_llm_provider_does_not_hold_tenant_database_session():
entered = asyncio.Event()
release = asyncio.Event()
observations: list[bool] = []
engine = create_async_engine('sqlite+aiosqlite:///:memory:')
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
manager.db = SimpleNamespace(get_engine=lambda: engine)
runtime_handler, app, installation_context = make_handler()
app.persistence_mgr = manager
async def invoke_llm(**_kwargs):
observations.append(manager.current_session() is None)
entered.set()
await release.wait()
observations.append(manager.current_session() is None)
return SimpleNamespace(model_dump=lambda: {'role': 'assistant', 'content': 'ok'})
app.model_mgr = SimpleNamespace(
get_model_by_uuid=AsyncMock(
return_value=SimpleNamespace(
model_entity=SimpleNamespace(workspace_uuid='workspace-a'),
provider=SimpleNamespace(invoke_llm=invoke_llm),
)
)
)
runtime_handler._require_plugin_action_context = AsyncMock(return_value=(installation_context, SimpleNamespace()))
runtime_handler._require_active_action_context = AsyncMock()
runtime_handler._resource_exists = AsyncMock(return_value=True)
action = asyncio.create_task(
invoke_with_context(
runtime_handler,
installation_context,
PluginToRuntimeAction.INVOKE_LLM,
{
'llm_model_uuid': 'model-a',
'messages': [],
},
)
)
try:
await entered.wait()
assert observations == [True]
release.set()
response = await action
assert response.code == 0
assert observations == [True, True]
finally:
release.set()
if not action.done():
await action
await engine.dispose()
@pytest.mark.asyncio
async def test_plugin_action_rejects_forged_installation_capability():
runtime_handler, _app, installation_context = make_handler()
@@ -203,14 +300,12 @@ async def test_legacy_query_id_fallback_is_workspace_scoped():
)
def test_runtime_connection_rejects_workspace_rebinding():
def test_runtime_connection_is_instance_scoped_and_unbound():
runtime_handler, _app, _installation_context = make_handler()
with pytest.raises(ValueError, match='another Workspace'):
runtime_handler.bind_action_context(workspace_context('workspace-b'))
assert runtime_handler.bound_action_context is None
def test_inbound_installation_envelope_is_preserved_only_inside_bound_workspace():
def test_inbound_tenant_action_requires_complete_installation_envelope():
runtime_handler, _app, installation_context = make_handler()
assert (
@@ -220,35 +315,69 @@ def test_inbound_installation_envelope_is_preserved_only_inside_bound_workspace(
)
== installation_context
)
with pytest.raises(ValueError, match='does not match connection Workspace'):
with pytest.raises(ValueError, match='complete InstallationBinding'):
runtime_handler.validate_inbound_action_context(
PluginToRuntimeAction.GET_BOTS.value,
workspace_context('workspace-b').for_installation('installation-b'),
)
def test_installation_uuid_is_stable_and_workspace_specific():
context_a = workspace_context('workspace-a')
context_b = workspace_context('workspace-b')
@pytest.mark.asyncio
async def test_legacy_oss_worker_capability_remains_usable_after_identity_migration():
runtime_handler, app, installation_context = make_handler()
app.deployment = SimpleNamespace(mode='oss')
setting = SimpleNamespace(
plugin_author='author-a',
plugin_name='plugin-a',
installation_uuid=installation_context.installation_uuid,
runtime_revision=installation_context.runtime_revision,
artifact_digest=installation_context.artifact_digest,
)
result = Mock()
result.first.return_value = setting
app.persistence_mgr.execute_async.return_value = result
legacy_context = workspace_context().for_installation(installation_context.installation_uuid)
first = RuntimeConnectionHandler.derive_installation_uuid(
context_a,
'author-a',
'plugin-a',
assert (
runtime_handler.validate_inbound_action_context(
PluginToRuntimeAction.GET_LANGBOT_VERSION.value,
legacy_context,
)
== legacy_context
)
repeated = RuntimeConnectionHandler.derive_installation_uuid(
context_a,
'author-a',
'plugin-a',
)
other_workspace = RuntimeConnectionHandler.derive_installation_uuid(
context_b,
'author-a',
'plugin-a',
response = await invoke_with_context(
runtime_handler,
legacy_context,
PluginToRuntimeAction.GET_LANGBOT_VERSION,
{},
)
assert first == repeated
assert first != other_workspace
assert response.code == 0
def test_installation_uuid_cannot_move_between_workspaces():
runtime_handler, _app, binding = make_handler()
moved = binding.model_copy(update={'workspace_uuid': 'workspace-b', 'runtime_revision': 2})
with pytest.raises(ValueError, match='cannot move between Workspaces'):
runtime_handler.register_installation_binding(
moved,
plugin_author='author-a',
plugin_name='plugin-a',
)
@pytest.mark.asyncio
async def test_newer_revision_fences_old_binding():
runtime_handler, _app, binding = make_handler()
newer = binding.model_copy(update={'runtime_revision': 2, 'artifact_digest': 'b' * 64})
runtime_handler.register_installation_binding(
newer,
plugin_author='author-a',
plugin_name='plugin-a',
)
with pytest.raises(ValueError, match='revision or artifact is stale'):
await runtime_handler._resolve_installation_identity(binding)
@pytest.mark.asyncio
@@ -281,16 +410,27 @@ async def test_plugin_vector_action_forwards_trusted_context_and_logical_collect
@pytest.mark.asyncio
async def test_host_to_runtime_action_carries_trusted_connector_context():
app = SimpleNamespace(logger=Mock())
context = workspace_context()
connection = RecordingConnection()
runtime_handler = RuntimeConnectionHandler(
connection,
AsyncMock(return_value=True),
app,
context,
)
task = asyncio.create_task(runtime_handler.set_runtime_config(None))
task = asyncio.create_task(
runtime_handler.set_runtime_config(
runtime_identity=RuntimeIdentity(instance_uuid='instance-a', runtime_id='runtime-a'),
worker_policy=PluginWorkerPolicy(
max_cpus=1.0,
max_memory_mb=256,
max_pids=32,
max_open_files=64,
max_file_size_mb=128,
),
runtime_profile='oss_dev',
cloud_service_url=None,
)
)
for _ in range(10):
if connection.sent:
break
@@ -299,5 +439,8 @@ async def test_host_to_runtime_action_carries_trusted_connector_context():
runtime_handler.resp_waiters[request['seq_id']].set_result(ActionResponse.success({}))
await task
assert request['data'] == {}
assert request['context'] == context.model_dump(exclude_none=True)
assert request['data']['runtime_identity'] == {
'instance_uuid': 'instance-a',
'runtime_id': 'runtime-a',
}
assert request.get('context') is None
@@ -1,7 +1,34 @@
"""Test plugin list filtering by component kinds."""
from contextlib import nullcontext
from unittest.mock import AsyncMock, MagicMock
import pytest
from langbot_plugin.entities.io.context import InstallationBinding
from langbot.pkg.api.http.context import ExecutionContext
TEST_EXECUTION_CONTEXT = ExecutionContext(
instance_uuid='instance-a',
workspace_uuid='workspace-a',
placement_generation=1,
)
TEST_INSTALLATION_BINDING = InstallationBinding(
instance_uuid=TEST_EXECUTION_CONTEXT.instance_uuid,
workspace_uuid=TEST_EXECUTION_CONTEXT.workspace_uuid,
placement_generation=TEST_EXECUTION_CONTEXT.placement_generation,
installation_uuid='00000000-0000-4000-8000-000000000001',
runtime_revision=1,
artifact_digest='a' * 64,
)
def configure_connector(connector) -> None:
connector._execution_context.set(TEST_EXECUTION_CONTEXT)
connector._operation_bindings = AsyncMock(return_value=[TEST_INSTALLATION_BINDING])
connector._load_workspace_settings = AsyncMock(return_value=[])
connector.handler.installation_scope = MagicMock(side_effect=lambda _binding: nullcontext())
@pytest.mark.asyncio
@@ -17,6 +44,7 @@ async def test_plugin_list_filter_by_component_kinds():
# Create connector
connector = PluginRuntimeConnector(mock_app, AsyncMock())
connector.handler = MagicMock()
configure_connector(connector)
# Mock plugin data with different component kinds
mock_plugins = [
@@ -90,7 +118,7 @@ async def test_plugin_list_filter_by_component_kinds():
# Mock database query
async def mock_execute_async(query):
mock_result = MagicMock()
mock_result.__iter__ = lambda self: iter([])
mock_result.scalars.return_value.all.return_value = []
return mock_result
mock_app.persistence_mgr.execute_async = mock_execute_async
@@ -123,6 +151,7 @@ async def test_plugin_list_filter_no_filter():
# Create connector
connector = PluginRuntimeConnector(mock_app, AsyncMock())
connector.handler = MagicMock()
configure_connector(connector)
# Mock plugin data with different component kinds
mock_plugins = [
@@ -157,7 +186,7 @@ async def test_plugin_list_filter_no_filter():
# Mock database query
async def mock_execute_async(query):
mock_result = MagicMock()
mock_result.__iter__ = lambda self: iter([])
mock_result.scalars.return_value.all.return_value = []
return mock_result
mock_app.persistence_mgr.execute_async = mock_execute_async
@@ -184,6 +213,7 @@ async def test_plugin_list_filter_empty_result():
# Create connector
connector = PluginRuntimeConnector(mock_app, AsyncMock())
connector.handler = MagicMock()
configure_connector(connector)
# Mock plugin data - only KnowledgeEngine plugins
mock_plugins = [
@@ -206,7 +236,7 @@ async def test_plugin_list_filter_empty_result():
# Mock database query
async def mock_execute_async(query):
mock_result = MagicMock()
mock_result.__iter__ = lambda self: iter([])
mock_result.scalars.return_value.all.return_value = []
return mock_result
mock_app.persistence_mgr.execute_async = mock_execute_async
@@ -230,6 +260,7 @@ async def test_plugin_list_filter_plugin_without_components():
# Create connector
connector = PluginRuntimeConnector(mock_app, AsyncMock())
connector.handler = MagicMock()
configure_connector(connector)
# Mock plugin data - one with components, one without
mock_plugins = [
@@ -264,7 +295,7 @@ async def test_plugin_list_filter_plugin_without_components():
# Mock database query
async def mock_execute_async(query):
mock_result = MagicMock()
mock_result.__iter__ = lambda self: iter([])
mock_result.scalars.return_value.all.return_value = []
return mock_result
mock_app.persistence_mgr.execute_async = mock_execute_async
@@ -1,8 +1,34 @@
"""Test plugin list sorting functionality."""
from contextlib import nullcontext
from datetime import datetime, timedelta
from unittest.mock import AsyncMock, MagicMock
import pytest
from langbot_plugin.entities.io.context import InstallationBinding
from langbot.pkg.api.http.context import ExecutionContext
TEST_EXECUTION_CONTEXT = ExecutionContext(
instance_uuid='instance-a',
workspace_uuid='workspace-a',
placement_generation=1,
)
TEST_INSTALLATION_BINDING = InstallationBinding(
instance_uuid=TEST_EXECUTION_CONTEXT.instance_uuid,
workspace_uuid=TEST_EXECUTION_CONTEXT.workspace_uuid,
placement_generation=TEST_EXECUTION_CONTEXT.placement_generation,
installation_uuid='00000000-0000-4000-8000-000000000001',
runtime_revision=1,
artifact_digest='a' * 64,
)
def configure_connector(connector) -> None:
connector._execution_context.set(TEST_EXECUTION_CONTEXT)
connector._operation_bindings = AsyncMock(return_value=[TEST_INSTALLATION_BINDING])
connector.handler.installation_scope = MagicMock(side_effect=lambda _binding: nullcontext())
@pytest.mark.asyncio
@@ -18,6 +44,7 @@ async def test_plugin_list_sorting_debug_first():
# Create connector
connector = PluginRuntimeConnector(mock_app, AsyncMock())
connector.handler = MagicMock()
configure_connector(connector)
# Mock plugin data with different debug states and timestamps
now = datetime.now()
@@ -60,9 +87,7 @@ async def test_plugin_list_sorting_debug_first():
connector.handler.list_plugins = AsyncMock(return_value=mock_plugins)
# Mock database query to return all timestamps in a single batch
async def mock_execute_async(query):
mock_result = MagicMock()
async def mock_load_workspace_settings(_execution_context):
# Create mock rows for all plugins with timestamps
mock_rows = []
@@ -85,12 +110,9 @@ async def test_plugin_list_sorting_debug_first():
mock_row3.created_at = now
mock_rows.append(mock_row3)
# Make the result iterable
mock_result.__iter__ = lambda self: iter(mock_rows)
return mock_rows
return mock_result
mock_app.persistence_mgr.execute_async = mock_execute_async
connector._load_workspace_settings = AsyncMock(side_effect=mock_load_workspace_settings)
# Call list_plugins
result = await connector.list_plugins()
@@ -120,6 +142,7 @@ async def test_plugin_list_sorting_by_installation_time():
# Create connector
connector = PluginRuntimeConnector(mock_app, AsyncMock())
connector.handler = MagicMock()
configure_connector(connector)
# Mock plugin data - all non-debug with different installation times
now = datetime.now()
@@ -162,9 +185,7 @@ async def test_plugin_list_sorting_by_installation_time():
connector.handler.list_plugins = AsyncMock(return_value=mock_plugins)
# Mock database query to return all timestamps in a single batch
async def mock_execute_async(query):
mock_result = MagicMock()
async def mock_load_workspace_settings(_execution_context):
# Create mock rows for all plugins with timestamps
mock_rows = []
@@ -187,12 +208,9 @@ async def test_plugin_list_sorting_by_installation_time():
mock_row3.created_at = now
mock_rows.append(mock_row3)
# Make the result iterable
mock_result.__iter__ = lambda self: iter(mock_rows)
return mock_rows
return mock_result
mock_app.persistence_mgr.execute_async = mock_execute_async
connector._load_workspace_settings = AsyncMock(side_effect=mock_load_workspace_settings)
# Call list_plugins
result = await connector.list_plugins()
@@ -217,6 +235,7 @@ async def test_plugin_list_empty():
# Create connector
connector = PluginRuntimeConnector(mock_app, AsyncMock())
connector.handler = MagicMock()
configure_connector(connector)
# Mock empty plugin list
connector.handler.list_plugins = AsyncMock(return_value=[])
+126 -1
View File
@@ -304,6 +304,22 @@ class TestSkillPathHelpers:
assert 'export VIRTUAL_ENV="$_LB_VENV_DIR"' in command
assert command.rstrip().endswith('python scripts/run.py')
def test_wrap_skill_python_env_keeps_state_outside_read_only_source(self):
from langbot.pkg.provider.tools.loaders.skill import wrap_skill_command_with_python_env
command = wrap_skill_command_with_python_env(
'python scripts/run.py',
mount_path='/workspace/.skills/demo',
state_path='/workspace/.skill-envs/demo',
)
assert '_LB_VENV_DIR="/workspace/.skill-envs/demo/.venv"' in command
assert '_LB_META_DIR="/workspace/.skill-envs/demo/.langbot"' in command
assert '_LB_TMP_DIR="/workspace/.skill-envs/demo/.tmp"' in command
assert '_LB_PIP_CACHE_DIR="/workspace/.skill-envs/demo/.cache/pip"' in command
assert 'root = "/workspace/.skills/demo"' in command
assert 'pip install "/workspace/.skills/demo"' in command
class TestSkillToolLoader:
"""The skill tool surface is now just ``activate`` + ``register_skill``.
@@ -497,7 +513,11 @@ class TestNativeToolLoaderSkillPaths:
f.write('demo instructions')
ap = _make_ap()
ap.box_service = SimpleNamespace(available=True, default_workspace=tmpdir)
ap.box_service = SimpleNamespace(
available=True,
default_workspace=tmpdir,
shares_filesystem_with_box=True,
)
ap.skill_mgr = _make_skill_manager({'demo': _make_skill_data(name='demo', package_root=tmpdir)})
loader = NativeToolLoader(ap)
@@ -511,6 +531,70 @@ class TestNativeToolLoaderSkillPaths:
assert result['content'] == 'demo instructions'
assert result['truncated'] is False
@pytest.mark.asyncio
async def test_external_runtime_read_never_interprets_package_root_on_core_host(self):
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
from langbot.pkg.provider.tools.loaders.skill import PIPELINE_BOUND_SKILLS_KEY
with tempfile.TemporaryDirectory() as tmpdir:
with open(os.path.join(tmpdir, 'SKILL.md'), 'w', encoding='utf-8') as file_obj:
file_obj.write('core-host-secret')
ap = _make_ap()
ap.box_service = SimpleNamespace(
available=True,
shares_filesystem_with_box=False,
read_skill_file=AsyncMock(return_value={'content': 'runtime-owned-content'}),
)
ap.skill_mgr = _make_skill_manager({'demo': _make_skill_data(name='demo', package_root=tmpdir)})
loader = NativeToolLoader(ap)
query = _make_query(
query_id='q-external-read',
variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']},
)
result = await loader.invoke_tool(
'read',
{'path': '/workspace/.skills/demo/SKILL.md'},
query,
)
assert result['ok'] is True
assert result['content'] == 'runtime-owned-content'
assert 'core-host-secret' not in repr(result)
ap.box_service.read_skill_file.assert_awaited_once_with(_CONTEXT, 'demo', 'SKILL.md')
@pytest.mark.asyncio
async def test_external_runtime_rejects_skill_host_fallback_without_protocol_capability(self):
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
from langbot.pkg.provider.tools.loaders.skill import PIPELINE_BOUND_SKILLS_KEY
with tempfile.TemporaryDirectory() as tmpdir:
with open(os.path.join(tmpdir, 'secret.txt'), 'w', encoding='utf-8') as file_obj:
file_obj.write('core-host-secret')
ap = _make_ap()
ap.box_service = SimpleNamespace(
available=True,
shares_filesystem_with_box=False,
)
ap.skill_mgr = _make_skill_manager({'demo': _make_skill_data(name='demo', package_root=tmpdir)})
loader = NativeToolLoader(ap)
query = _make_query(
query_id='q-external-no-protocol',
variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']},
)
with pytest.raises(ValueError, match='owned by the Box Runtime'):
await loader.invoke_tool(
'grep',
{
'path': '/workspace/.skills/demo',
'pattern': 'core-host-secret',
},
query,
)
@pytest.mark.asyncio
async def test_exec_in_activated_skill_mount_rewrites_command_and_refreshes(self):
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
@@ -542,8 +626,49 @@ class TestNativeToolLoaderSkillPaths:
tool_parameters = ap.box_service.execute_tool.await_args.args[0]
assert tool_parameters['command'] == 'python /workspace/.skills/demo/scripts/run.py'
assert tool_parameters['workdir'] == '/workspace/.skills/demo'
assert ap.box_service.execute_tool.await_args.kwargs['skill_name'] == 'demo'
ap.skill_mgr.refresh_skill_from_disk.assert_called_once_with(_CONTEXT, 'demo')
@pytest.mark.asyncio
async def test_external_runtime_python_skill_uses_trusted_metadata_and_writable_env(self):
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
from langbot.pkg.provider.tools.loaders.skill import register_activated_skill
ap = _make_ap()
ap.box_service = SimpleNamespace(
available=True,
shares_filesystem_with_box=False,
execute_tool=AsyncMock(return_value={'ok': True}),
)
ap.skill_mgr = SimpleNamespace(refresh_skill_from_disk=Mock())
loader = NativeToolLoader(ap)
query = _make_query(query_id='q-external', launcher_type='person', launcher_id='123')
register_activated_skill(
query,
_make_skill_data(
name='demo',
package_root='/box-runtime/skills/tenants/workspace/demo',
python_project=True,
),
)
result = await loader.invoke_tool(
'exec',
{
'command': 'python /workspace/.skills/demo/scripts/run.py',
'workdir': '/workspace/.skills/demo',
},
query,
)
assert result['ok'] is True
tool_parameters = ap.box_service.execute_tool.await_args.args[0]
wrapped = tool_parameters['command']
assert '_LB_VENV_DIR="/workspace/.skill-envs/demo/.venv"' in wrapped
assert 'root = "/workspace/.skills/demo"' in wrapped
assert '/box-runtime/skills/tenants/workspace/demo' not in wrapped
assert ap.box_service.execute_tool.await_args.kwargs['skill_name'] == 'demo'
@pytest.mark.asyncio
async def test_write_requires_skill_activation(self):
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
@@ -1,6 +1,7 @@
from __future__ import annotations
import base64
import contextlib
import os
import tempfile
from types import SimpleNamespace
@@ -11,6 +12,7 @@ import pytest
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
from langbot.pkg.provider.tools.loaders import native as native_loader
from langbot.pkg.provider.tools.toolmgr import ToolManager
from langbot.pkg.api.http.context import ExecutionContext
@@ -52,7 +54,8 @@ class StubLoader:
for tool in self._tools
]
async def has_tool(self, name: str) -> bool:
async def has_tool(self, *args) -> bool:
name = args[-1]
return any(tool.name == name for tool in self._tools)
async def invoke_tool(self, name: str, parameters: dict, query):
@@ -129,11 +132,55 @@ async def test_tool_manager_routes_native_tool_calls():
manager.plugin_tool_loader = StubLoader([make_tool('plugin_tool')])
manager.mcp_tool_loader = StubLoader([make_tool('mcp_tool')])
result = await manager.execute_func_call('exec', {'command': 'pwd'}, query=Mock())
query = SimpleNamespace(
_execution_context=_CONTEXT,
bot_uuid=None,
pipeline_uuid=None,
query_uuid=None,
)
result = await manager.execute_func_call('exec', {'command': 'pwd'}, query=query)
assert result == {'backend': 'fake'}
@pytest.mark.asyncio
async def test_tool_manager_hides_sandbox_and_skill_tools_without_workspace_entitlement():
box_service = SimpleNamespace(is_workspace_sandbox_available=AsyncMock(return_value=False))
manager = ToolManager(SimpleNamespace(box_service=box_service))
manager.native_tool_loader = StubLoader([make_tool('exec')])
manager.skill_tool_loader = StubLoader([make_tool('activate')])
manager.plugin_tool_loader = StubLoader([make_tool('plugin_tool')])
manager.mcp_tool_loader = StubLoader([make_tool('mcp_tool')])
tools = await manager.get_all_tools(_CONTEXT, include_skill_authoring=True)
catalog = await manager.get_tool_catalog(_CONTEXT, include_skill_authoring=True)
assert [tool.name for tool in tools] == ['plugin_tool', 'mcp_tool']
assert [item['name'] for item in catalog] == ['plugin_tool', 'mcp_tool']
assert box_service.is_workspace_sandbox_available.await_count == 2
@pytest.mark.asyncio
async def test_tool_manager_rechecks_workspace_entitlement_before_native_invocation():
box_service = SimpleNamespace(is_workspace_sandbox_available=AsyncMock(return_value=False))
manager = ToolManager(SimpleNamespace(box_service=box_service))
manager.native_tool_loader = StubLoader([make_tool('exec')], invoke_result={'unexpected': True})
manager.skill_tool_loader = StubLoader([])
manager.plugin_tool_loader = StubLoader([])
manager.mcp_tool_loader = StubLoader([])
query = SimpleNamespace(
_execution_context=_CONTEXT,
bot_uuid=None,
pipeline_uuid=None,
query_uuid=None,
)
with pytest.raises(Exception, match='exec'):
await manager.execute_func_call('exec', {'command': 'pwd'}, query=query)
box_service.is_workspace_sandbox_available.assert_awaited_once_with(_CONTEXT)
@pytest.mark.asyncio
async def test_native_tool_loader_hides_tools_when_box_unavailable():
loader = NativeToolLoader(SimpleNamespace(box_service=SimpleNamespace(available=False)))
@@ -159,6 +206,26 @@ async def test_native_tool_loader_exposes_all_tools_when_box_available():
assert await loader.has_tool(tool_name) is True
@pytest.mark.asyncio
async def test_native_tool_loader_rechecks_admission_at_the_final_invoke_boundary():
box_service = SimpleNamespace(
available=True,
require_workspace_sandbox=AsyncMock(side_effect=RuntimeError('entitlement expired')),
)
loader = NativeToolLoader(SimpleNamespace(box_service=box_service, logger=Mock()))
query = SimpleNamespace(
_execution_context=_CONTEXT,
bot_uuid=None,
pipeline_uuid=None,
query_uuid=None,
)
with pytest.raises(RuntimeError, match='entitlement expired'):
await loader.invoke_tool('read', {'path': '/workspace/private.txt'}, query)
box_service.require_workspace_sandbox.assert_awaited_once_with(_CONTEXT)
# ── read/write/edit file tool tests ─────────────────────────────
@@ -386,6 +453,103 @@ async def test_path_escape_blocked():
await loader.invoke_tool('read', {'path': '/workspace/../../etc/passwd'}, _make_query())
@pytest.mark.parametrize(
('tool_name', 'parameters'),
[
('read', {'path': '/workspace/shared/tenant-b-only.txt'}),
(
'write',
{'path': '/workspace/shared/tenant-b-only.txt', 'content': 'overwritten by tenant a'},
),
(
'edit',
{
'path': '/workspace/shared/tenant-b-only.txt',
'old_string': 'tenant-b-secret',
'new_string': 'overwritten by tenant a',
},
),
('glob', {'path': '/workspace/shared', 'pattern': '*'}),
('grep', {'path': '/workspace/shared', 'pattern': 'tenant-b-secret'}),
],
)
@pytest.mark.asyncio
async def test_host_workspace_operations_do_not_follow_a_swapped_ancestor(
monkeypatch,
tool_name: str,
parameters: dict,
):
with tempfile.TemporaryDirectory() as tmpdir:
tenant_a = os.path.join(tmpdir, 'tenant-a')
tenant_b = os.path.join(tmpdir, 'tenant-b')
os.makedirs(os.path.join(tenant_a, 'shared'))
os.makedirs(os.path.join(tenant_b, 'shared'))
tenant_b_file = os.path.join(tenant_b, 'shared', 'tenant-b-only.txt')
with open(os.path.join(tenant_a, 'shared', 'tenant-a-only.txt'), 'w', encoding='utf-8') as file_obj:
file_obj.write('tenant-a-content')
with open(tenant_b_file, 'w', encoding='utf-8') as file_obj:
file_obj.write('tenant-b-secret')
box_service = SimpleNamespace(
available=True,
default_workspace=tmpdir,
_tenant_workspace=Mock(return_value=tenant_a),
)
loader = NativeToolLoader(SimpleNamespace(box_service=box_service, logger=Mock()))
original_open_host_root = native_loader._open_host_root
@contextlib.contextmanager
def open_host_root_after_swap(location, *, create):
with original_open_host_root(location, create=create) as root_fd:
original_ancestor = os.path.join(tenant_a, 'shared-original')
os.rename(os.path.join(tenant_a, 'shared'), original_ancestor)
os.symlink(os.path.join(tenant_b, 'shared'), os.path.join(tenant_a, 'shared'))
yield root_fd
monkeypatch.setattr(native_loader, '_open_host_root', open_host_root_after_swap)
try:
result = await loader.invoke_tool(tool_name, parameters, _make_query())
except ValueError as exc:
result = {'ok': False, 'error': str(exc)}
assert result.get('ok') is False
assert 'tenant-b-secret' not in repr(result)
assert 'tenant-b-only.txt' not in repr(result)
with open(tenant_b_file, encoding='utf-8') as file_obj:
assert file_obj.read() == 'tenant-b-secret'
@pytest.mark.asyncio
async def test_host_file_api_falls_back_to_tenant_box_when_openat_is_unavailable(monkeypatch):
with tempfile.TemporaryDirectory() as tmpdir:
box_service = SimpleNamespace(
available=True,
default_workspace=tmpdir,
_tenant_workspace=Mock(return_value=tmpdir),
execute_tool=AsyncMock(
return_value={
'ok': True,
'stdout': '{"ok": true, "content": "box-owned", "truncated": false}',
'stderr': '',
}
),
)
loader = NativeToolLoader(SimpleNamespace(box_service=box_service, logger=Mock()))
monkeypatch.setattr(native_loader, '_SECURE_HOST_FILE_OPS_AVAILABLE', False)
result = await loader.invoke_tool(
'read',
{'path': '/workspace/file.txt'},
_make_query(),
)
assert result['ok'] is True
assert result['content'] == 'box-owned'
command = box_service.execute_tool.await_args.args[0]['command']
assert 'path = "/workspace/file.txt"' in command
@pytest.mark.asyncio
async def test_box_availability_helper_handles_unavailable_and_errors():
from langbot.pkg.provider.tools.loaders.availability import is_box_backend_available
+33 -1
View File
@@ -2,8 +2,10 @@
from __future__ import annotations
import contextvars
import io
import zipfile
from contextlib import asynccontextmanager
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
@@ -54,7 +56,7 @@ def _make_app() -> Mock:
storage_mgr.storage_provider.delete = AsyncMock()
app.storage_mgr = storage_mgr
app.persistence_mgr = Mock()
app.persistence_mgr.execute_async = AsyncMock()
app.persistence_mgr.execute_async = AsyncMock(return_value=SimpleNamespace(rowcount=1))
app.plugin_connector = Mock()
app.plugin_connector.require_workspace_context = AsyncMock(side_effect=lambda context: context)
app.workspace_service = SimpleNamespace(
@@ -202,6 +204,36 @@ class TestStoreZipFile:
class TestStoreFileTask:
@pytest.mark.asyncio
async def test_store_file_task_opens_uow_before_first_database_helper(self):
kb = _make_kb()
active_workspace = contextvars.ContextVar('rag_task_workspace', default=None)
observed = []
@asynccontextmanager
async def tenant_uow(workspace_uuid):
token = active_workspace.set(workspace_uuid)
try:
yield
finally:
active_workspace.reset(token)
kb.ap.persistence_mgr.mode = SimpleNamespace(value='cloud_runtime')
kb.ap.persistence_mgr.tenant_uow = tenant_uow
async def assert_execution_context(_context):
observed.append(active_workspace.get())
kb._assert_execution_context = AsyncMock(side_effect=assert_execution_context)
kb._set_file_status = AsyncMock(side_effect=[True, True])
kb._ingest_document = AsyncMock(return_value={'status': 'completed'})
object_key = _upload_key('scoped.pdf')
file_obj = SimpleNamespace(uuid='file-uuid', file_name=object_key, extension='pdf')
await kb._store_file_task(CONTEXT, file_obj, Mock())
assert observed[0] == WORKSPACE_A
@pytest.mark.asyncio
async def test_store_file_task_marks_completed_and_cleans_storage(self):
kb = _make_kb()
+41 -1
View File
@@ -1,12 +1,13 @@
from __future__ import annotations
import datetime
from contextlib import asynccontextmanager
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
import sqlalchemy
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from langbot.pkg.api.http.authz import WorkspaceRequiredError
from langbot.pkg.api.http.context import ExecutionContext
@@ -17,6 +18,7 @@ from langbot.pkg.entity.persistence.workspace import Workspace, WorkspaceExecuti
from langbot.pkg.rag.knowledge.kbmgr import RAGManager
from langbot.pkg.rag.service.runtime import RAGRuntimeService
from langbot.pkg.vector.mgr import VectorDBManager
from langbot.pkg.vector.vdbs.pgvector_db import PgVectorDatabase
from langbot.pkg.workspace.errors import WorkspaceNotFoundError
from langbot.pkg.workspace.policy import CloudWorkspacePolicy, SingleWorkspacePolicy
from langbot.pkg.workspace.service import WorkspaceService
@@ -42,6 +44,13 @@ class _PersistenceManager:
await connection.commit()
return result
@asynccontextmanager
async def tenant_uow(self, _workspace_uuid):
# This lightweight fixture does not emulate PostgreSQL RLS; production
# persistence tests cover the transaction-bound unit of work itself.
async with AsyncSession(self.engine, expire_on_commit=False) as session, session.begin():
yield SimpleNamespace(session=session)
@staticmethod
def serialize_model(model, row, masked_columns=()):
return {
@@ -348,3 +357,34 @@ async def test_stale_generation_is_rejected_before_vector_access(tenant_rag):
with pytest.raises(Exception, match='generation'):
await manager.upsert(stale, 'kb-a', [[0.1]], ['a'])
assert database.collections == []
async def test_pgvector_first_write_binds_dimension_and_later_mismatch_fails(tenant_rag):
app, engine = tenant_rag
manager = VectorDBManager(app)
pgvector = object.__new__(PgVectorDatabase)
pgvector.allowed_dimensions = frozenset({1, 2})
pgvector.add_embeddings = AsyncMock()
pgvector.search = AsyncMock(return_value={'ids': [[]], 'distances': [[]], 'metadatas': [[]]})
manager.vector_db = pgvector
context = _context(WORKSPACE_A)
await manager.upsert(context, 'kb-a', [[0.1]], ['chunk-a'])
scope = pgvector.add_embeddings.await_args.kwargs['scope']
assert scope.workspace_uuid == WORKSPACE_A
assert scope.knowledge_base_uuid == 'kb-a'
assert scope.embedding_dimension == 1
async with engine.connect() as connection:
selected_dimension = await connection.scalar(
sqlalchemy.select(KnowledgeBase.embedding_dimension).where(
KnowledgeBase.workspace_uuid == WORKSPACE_A,
KnowledgeBase.uuid == 'kb-a',
)
)
assert selected_dimension == 1
with pytest.raises(ValueError, match='dimension is 1, not 2'):
await manager.upsert(context, 'kb-a', [[0.1, 0.2]], ['chunk-b'])
with pytest.raises(ValueError, match='not enabled'):
await manager.search(context, 'kb-a', [0.1, 0.2, 0.3], 3)
+16 -21
View File
@@ -32,6 +32,12 @@ def create_mock_app():
return mock_app
def scalar_result(value=None):
"""Return the scalar-only shape used by Connection column selects."""
return Mock(scalar_one_or_none=Mock(return_value=value))
class TestSurveyManagerInit:
"""Tests for SurveyManager initialization."""
@@ -67,7 +73,7 @@ class TestSurveyManagerInit:
"""Test that initialize loads space URL from config."""
survey_module = get_survey_module()
mock_app = create_mock_app()
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(first=Mock(return_value=None)))
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=scalar_result())
manager = survey_module.SurveyManager(mock_app)
await manager.initialize()
@@ -80,7 +86,7 @@ class TestSurveyManagerInit:
survey_module = get_survey_module()
mock_app = create_mock_app()
mock_app.instance_config.data = {'space': {'url': 'https://space.example.com/'}}
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(first=Mock(return_value=None)))
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=scalar_result())
manager = survey_module.SurveyManager(mock_app)
await manager.initialize()
@@ -93,7 +99,7 @@ class TestSurveyManagerInit:
survey_module = get_survey_module()
mock_app = create_mock_app()
mock_app.instance_config.data = {}
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(first=Mock(return_value=None)))
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=scalar_result())
manager = survey_module.SurveyManager(mock_app)
await manager.initialize()
@@ -110,12 +116,7 @@ class TestLoadTriggeredEvents:
survey_module = get_survey_module()
mock_app = create_mock_app()
# Mock existing metadata row
mock_row = Mock()
mock_row.value = json.dumps(['event1', 'event2'])
mock_result = Mock()
mock_result.first = Mock(return_value=(mock_row,))
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=scalar_result(json.dumps(['event1', 'event2'])))
manager = survey_module.SurveyManager(mock_app)
await manager._load_triggered_events()
@@ -128,7 +129,7 @@ class TestLoadTriggeredEvents:
"""Test that empty set is used when no events stored."""
survey_module = get_survey_module()
mock_app = create_mock_app()
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(first=Mock(return_value=None)))
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=scalar_result())
manager = survey_module.SurveyManager(mock_app)
await manager._load_triggered_events()
@@ -218,7 +219,7 @@ class TestTriggerEvent:
"""Test that new event is added and saved."""
survey_module = get_survey_module()
mock_app = create_mock_app()
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(first=Mock(return_value=None)))
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=scalar_result())
manager = survey_module.SurveyManager(mock_app)
manager._space_url = 'https://space.example.com'
@@ -235,7 +236,7 @@ class TestRecordBotResponseSuccess:
manager = survey_module.SurveyManager(mock_app)
manager._space_url = 'https://space.example.com'
# No existing metadata rows: select returns no row
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(first=Mock(return_value=None)))
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=scalar_result())
return manager
@pytest.mark.asyncio
@@ -304,19 +305,13 @@ class TestRecordBotResponseSuccess:
survey_module = get_survey_module()
mock_app = create_mock_app()
count_row = Mock()
count_row.value = '42'
def execute_side_effect(stmt):
result = Mock()
# Both _load_triggered_events and _load_bot_response_count select
# from Metadata; return the count row only for the count key.
# Metadata.value; return the count only for the count key.
stmt_str = str(stmt.compile(compile_kwargs={'literal_binds': True}))
if survey_module.BOT_RESPONSE_COUNT_KEY in stmt_str:
result.first.return_value = (count_row,)
else:
result.first.return_value = None
return result
return scalar_result('42')
return scalar_result()
mock_app.persistence_mgr.execute_async = AsyncMock(side_effect=execute_side_effect)
+52
View File
@@ -1,6 +1,9 @@
import io
from types import SimpleNamespace
from unittest.mock import AsyncMock
import zipfile
import httpx
import pytest
from langbot.pkg.api.http.context import ExecutionContext
@@ -115,3 +118,52 @@ class TestRequireBoxForWrite:
service = SkillService(self._ap_with_disabled_box())
with pytest.raises(ValueError, match='Reading a skill file'):
await service.read_skill_file(_CONTEXT, 'x', 'a.txt')
class TestGithubSkillArchiveLimits:
@staticmethod
def _service() -> SkillService:
return SkillService(SimpleNamespace())
@pytest.mark.asyncio
async def test_download_rejects_declared_oversized_archive(self, monkeypatch):
import langbot.pkg.api.http.service.skill as skill_module
real_client = httpx.AsyncClient
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
headers={'content-length': str(10 * 1024 * 1024 + 1)},
content=b'',
request=request,
)
monkeypatch.setattr(
skill_module.httpx,
'AsyncClient',
lambda **_kwargs: real_client(transport=httpx.MockTransport(handler)),
)
with pytest.raises(ValueError, match='compressed size limit'):
await self._service()._download_github_asset('https://codeload.github.com/o/r/zip/main')
def test_copy_rejects_high_compression_ratio_before_extracting(self):
source_buffer = io.BytesIO()
with zipfile.ZipFile(source_buffer, 'w', zipfile.ZIP_DEFLATED) as archive:
archive.writestr('repo-main/skill/SKILL.md', b'---\nname: safe\n---\n')
archive.writestr('repo-main/skill/bomb.bin', b'0' * (1024 * 1024))
source_buffer.seek(0)
target_buffer = io.BytesIO()
with (
zipfile.ZipFile(source_buffer, 'r') as source_zip,
zipfile.ZipFile(target_buffer, 'w', zipfile.ZIP_DEFLATED) as target_zip,
):
with pytest.raises(ValueError, match='compression-ratio limit'):
self._service()._copy_github_skill_directory_to_zip(
source_zip,
target_zip,
'repo-main/skill',
'safe',
)
+49 -3
View File
@@ -138,6 +138,7 @@ class TestVectorDBManagerInitialization:
mgr = VectorDBManager(mock_app)
import asyncio
asyncio.get_event_loop().run_until_complete(mgr.initialize())
mock_valkey_class.assert_called_once_with(mock_app)
@@ -207,7 +208,10 @@ class TestVectorDBManagerInitialization:
asyncio.get_event_loop().run_until_complete(mgr.initialize())
mock_pgvector_class.assert_called_once_with(
mock_app, connection_string='postgresql://user:pass@host:5432/langbot'
mock_app,
connection_string='postgresql://user:pass@host:5432/langbot',
use_business_database=False,
allowed_dimensions=[384, 512, 768, 1024, 1536],
)
def test_initialize_pgvector_with_individual_params(self):
@@ -238,7 +242,14 @@ class TestVectorDBManagerInitialization:
asyncio.get_event_loop().run_until_complete(mgr.initialize())
mock_pgvector_class.assert_called_once_with(
mock_app, host='db.example.com', port=5433, database='vectordb', user='admin', password='secret'
mock_app,
host='db.example.com',
port=5433,
database='vectordb',
user='admin',
password='secret',
use_business_database=False,
allowed_dimensions=[384, 512, 768, 1024, 1536],
)
def test_initialize_pgvector_defaults(self):
@@ -260,7 +271,42 @@ class TestVectorDBManagerInitialization:
asyncio.get_event_loop().run_until_complete(mgr.initialize())
mock_pgvector_class.assert_called_once_with(
mock_app, host='localhost', port=5432, database='langbot', user='postgres', password='postgres'
mock_app,
host='localhost',
port=5432,
database='langbot',
user='postgres',
password='postgres',
use_business_database=False,
allowed_dimensions=[384, 512, 768, 1024, 1536],
)
def test_initialize_pgvector_with_shared_business_database(self):
vdb_config = {
'use': 'pgvector',
'pgvector': {
'use_business_database': True,
'allowed_dimensions': [768, 1536],
},
}
mock_app = self._create_mock_app(vdb_config)
mocks = self._make_vector_import_mocks()
mock_pgvector_class = MagicMock()
mocks['langbot.pkg.vector.vdbs.pgvector_db'].PgVectorDatabase = mock_pgvector_class
with isolated_sys_modules(mocks):
from langbot.pkg.vector.mgr import VectorDBManager
mgr = VectorDBManager(mock_app)
import asyncio
asyncio.get_event_loop().run_until_complete(mgr.initialize())
mock_pgvector_class.assert_called_once_with(
mock_app,
use_business_database=True,
allowed_dimensions=[768, 1536],
)
def test_initialize_unknown_backend_defaults_to_chroma(self):