From 9acb1774c307771e4682c3527c941fd9994197ec Mon Sep 17 00:00:00 2001 From: RockChinQ Date: Mon, 2 Nov 2026 12:28:00 +0800 Subject: [PATCH] fix(api-tools): support LANGBOT_PLUGIN_SDK_PATH env in runner tests and add real sqlite e2e test (LANG-259) - Allow overriding SDK path via LANGBOT_PLUGIN_SDK_PATH in tests/demo_e2e_workspace_runner.py and tests/unit_tests/api/test_manifest_declared_workspace_operations.py, skipping cleanly when missing - Add tests/integration/api/test_workspace_runner_plugin_e2e.py verifying example runner build, default authorization in real SQLite Core, execution, and revocation Co-authored-by: multica-agent --- tests/demo_e2e_workspace_runner.py | 53 ++- .../api/test_workspace_runner_plugin_e2e.py | 390 ++++++++++++++++++ ..._manifest_declared_workspace_operations.py | 9 +- 3 files changed, 438 insertions(+), 14 deletions(-) create mode 100644 tests/integration/api/test_workspace_runner_plugin_e2e.py diff --git a/tests/demo_e2e_workspace_runner.py b/tests/demo_e2e_workspace_runner.py index 8427a801b..33435679f 100644 --- a/tests/demo_e2e_workspace_runner.py +++ b/tests/demo_e2e_workspace_runner.py @@ -12,6 +12,7 @@ Demonstrates: from __future__ import annotations import asyncio +import os import sys from contextlib import asynccontextmanager from pathlib import Path @@ -20,10 +21,33 @@ from unittest.mock import AsyncMock, Mock import yaml +sdk_env = os.environ.get('LANGBOT_PLUGIN_SDK_PATH') +if sdk_env: + sdk_candidate = Path(sdk_env).resolve() + sdk_root = sdk_candidate if (sdk_candidate / 'examples').is_dir() else sdk_candidate.parent + sdk_src = sdk_candidate / 'src' if (sdk_candidate / 'src').is_dir() else sdk_candidate +else: + sdk_root = Path(__file__).resolve().parents[2] / 'langbot-plugin-sdk' + sdk_src = sdk_root / 'src' + +if not sdk_src.exists(): + if 'pytest' in sys.modules or os.environ.get('PYTEST_CURRENT_TEST'): + try: + import pytest + + pytest.skip( + f'langbot-plugin-sdk not found at {sdk_src} (set LANGBOT_PLUGIN_SDK_PATH to override)', + allow_module_level=True, + ) + except Exception: + pass + print(f'Skipped: langbot-plugin-sdk not found at {sdk_src} (set LANGBOT_PLUGIN_SDK_PATH to override)') + sys.exit(0) + # Ensure new SDK from repo is in sys.path -sdk_src = str(Path(__file__).resolve().parents[2] / 'langbot-plugin-sdk' / 'src') -if sdk_src not in sys.path: - sys.path.insert(0, sdk_src) +sdk_src_str = str(sdk_src) +if sdk_src_str not in sys.path: + sys.path.insert(0, sdk_src_str) from langbot_plugin.api.entities.builtin.runner.manifest import RunnerPermissions # noqa: E402 from langbot_plugin.entities.io.context import InstallationBinding # noqa: E402 @@ -46,15 +70,20 @@ async def run_demo(): # Step 1: Manifest Reading via Registry print('--- Step 1: 清单声明读取 (RunnerRegistry._validate_and_build_descriptor) ---') - manifest_yaml_path = ( - Path(__file__).resolve().parents[2] - / 'langbot-plugin-sdk' - / 'examples' - / 'workspace_runner_plugin' - / 'components' - / 'runner' - / 'default.yaml' - ) + manifest_yaml_path = sdk_root / 'examples' / 'workspace_runner_plugin' / 'components' / 'runner' / 'default.yaml' + if not manifest_yaml_path.exists(): + if 'pytest' in sys.modules or os.environ.get('PYTEST_CURRENT_TEST'): + try: + import pytest + + pytest.skip( + f'Example runner manifest not found at {manifest_yaml_path}', + allow_module_level=True, + ) + except Exception: + pass + print(f'Skipped: Example runner manifest not found at {manifest_yaml_path}') + return with open(manifest_yaml_path, 'r', encoding='utf-8') as f: comp_yaml = yaml.safe_load(f) diff --git a/tests/integration/api/test_workspace_runner_plugin_e2e.py b/tests/integration/api/test_workspace_runner_plugin_e2e.py new file mode 100644 index 000000000..bb3625846 --- /dev/null +++ b/tests/integration/api/test_workspace_runner_plugin_e2e.py @@ -0,0 +1,390 @@ +"""End-to-end integration test verifying workspace_runner_plugin build, + +manifest contract, real SQLite persistence, default authorization as an +event processor, and execution/revocation lifecycle. +""" + +from __future__ import annotations + +import logging +import os +import sys +import uuid +import zipfile +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest +import sqlalchemy +import yaml + +# Resolve SDK path +sdk_env = os.environ.get('LANGBOT_PLUGIN_SDK_PATH') +if sdk_env: + sdk_candidate = Path(sdk_env).resolve() + sdk_root = sdk_candidate if (sdk_candidate / 'examples').is_dir() else sdk_candidate.parent + sdk_src = sdk_candidate / 'src' if (sdk_candidate / 'src').is_dir() else sdk_candidate +else: + sdk_root = Path(__file__).resolve().parents[4] / 'langbot-plugin-sdk' + sdk_src = sdk_root / 'src' + +if not sdk_src.exists(): + pytest.skip( + f'langbot-plugin-sdk not found at {sdk_src} (set LANGBOT_PLUGIN_SDK_PATH to override)', allow_module_level=True + ) + +sdk_src_str = str(sdk_src) +if sdk_src_str not in sys.path: + sys.path.insert(0, sdk_src_str) + +from langbot_plugin.api.entities.builtin.runner.manifest import RunnerPermissions # noqa: E402 + +if 'workspace_operations' not in getattr(RunnerPermissions, 'model_fields', {}): + pytest.skip( + 'New SDK with workspace_operations not installed in current environment (run `uv pip install -e ../langbot-plugin-sdk`)', + allow_module_level=True, + ) + +from langbot_plugin.cli.commands import buildplugin # noqa: E402 +from langbot_plugin.entities.io.context import InstallationBinding # noqa: E402 + +from langbot.pkg.agent.runner.host_models import AgentBinding, AgentEventEnvelope, ResourcePolicy # noqa: E402 +from langbot.pkg.agent.runner.registry import RunnerRegistry # noqa: E402 +from langbot.pkg.agent.runner.resource_builder import AgentResourceBuilder # noqa: E402 +from langbot.pkg.agent.runner.session_registry import get_session_registry # noqa: E402 +from langbot.pkg.api.application.api import ApplicationAPI # noqa: E402 +from langbot.pkg.api.application.execution import ExecutionAPIContext # noqa: E402 +from langbot.pkg.api.http.context import ExecutionContext # noqa: E402 +from langbot.pkg.api.http.service.agent import AgentService # noqa: E402 +from langbot.pkg.api.http.service.bot import BotService # noqa: E402 +from langbot.pkg.entity.persistence import bot as persistence_bot # noqa: E402 +from langbot.pkg.persistence.mgr import PersistenceManager # noqa: E402 +from langbot.pkg.plugin.handler import RuntimeConnectionHandler # noqa: E402 + + +def test_example_runner_manifest_and_build(tmp_path, monkeypatch): + """Verify workspace_runner_plugin contains spec.events, passes lbp build, and outputs valid archive.""" + plugin_dir = sdk_root / 'examples' / 'workspace_runner_plugin' + assert plugin_dir.is_dir(), f'Example plugin directory not found at {plugin_dir}' + + comp_yaml_path = plugin_dir / 'components' / 'runner' / 'default.yaml' + assert comp_yaml_path.is_file(), f'Runner component not found at {comp_yaml_path}' + + with open(comp_yaml_path, 'r', encoding='utf-8') as f: + comp_yaml = yaml.safe_load(f) + + # 1. Manifest contract assertions + spec = comp_yaml.get('spec', {}) + assert 'event' in spec.get('usages', []), 'usages must declare event' + assert 'agent' in spec.get('usages', []), 'usages must declare agent' + assert spec.get('events') == ['*'], 'spec.events must be explicitly declared for event usage' + assert 'workspace_operations' in spec.get('permissions', {}), 'permissions must declare workspace_operations' + assert set(spec['permissions']['workspace_operations']) == {'bot.list', 'stats.basic'} + + # 2. lbp build assertion + monkeypatch.chdir(plugin_dir) + monkeypatch.setattr(buildplugin, 'cli_print', lambda *args, **kwargs: None) + dist_dir = tmp_path / 'dist' + pkg_path = buildplugin.build_plugin_process(str(dist_dir)) + assert pkg_path is not None, 'Plugin build must succeed' + assert pkg_path.endswith('.lbpkg') + assert Path(pkg_path).is_file() + + with zipfile.ZipFile(pkg_path) as pkg: + names = set(pkg.namelist()) + assert 'manifest.yaml' in names + assert 'components/runner/default.yaml' in names + assert 'components/runner/default.py' in names + + +@pytest.mark.asyncio +async def test_workspace_runner_real_sqlite_event_processor_lifecycle(tmp_path, monkeypatch): + """Verify installing example runner into real Core + SQLite as an event processor: + + - Default authorization check takes effect on creation + - api.workspace.list() and api.workspace.call('bot.list') succeed + - Revoking authorization results in call rejection + """ + comp_yaml_path = sdk_root / 'examples' / 'workspace_runner_plugin' / 'components' / 'runner' / 'default.yaml' + with open(comp_yaml_path, 'r', encoding='utf-8') as f: + comp_yaml = yaml.safe_load(f) + + # 1. Set up real Core + SQLite Persistence + from langbot.pkg.utils import constants + + monkeypatch.setattr(constants, 'instance_id', 'test-instance-uuid') + + db_path = tmp_path / 'langbot_e2e.db' + app = SimpleNamespace( + logger=logging.getLogger('test-e2e-runner'), + instance_config=SimpleNamespace( + data={ + 'database': { + 'use': 'sqlite', + 'sqlite': {'path': str(db_path)}, + }, + 'system': { + 'jwt': {'secret': 'test-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(app) + app.persistence_mgr = persistence + await persistence.initialize() + + try: + # 2. Build Descriptor from example manifest + registry = RunnerRegistry(app) + app.runner_registry = registry + + raw_runner_data = { + 'plugin_author': 'LangBot', + 'plugin_name': 'WorkspaceRunnerExample', + 'runner_name': 'default', + 'manifest': { + 'id': comp_yaml['metadata']['name'], + 'name': comp_yaml['metadata']['name'], + 'component_kind': comp_yaml['kind'], + 'usages': comp_yaml['spec']['usages'], + 'supported_event_patterns': comp_yaml['spec'].get('events') or ['*'], + 'label': comp_yaml['metadata']['label'], + 'description': comp_yaml['metadata']['description'], + 'permissions': comp_yaml['spec']['permissions'], + 'config_schema': comp_yaml['spec']['config'], + }, + } + descriptor = registry._validate_and_build_descriptor(raw_runner_data) + assert descriptor is not None + assert descriptor.id == 'plugin:LangBot/WorkspaceRunnerExample/default' + assert descriptor.declared_workspace_operations() == ['bot.list', 'stats.basic'] + + # Mock registry.get to return this descriptor when agent service queries it + registry.get = AsyncMock(return_value=descriptor) + + # 3. Retrieve default Workspace created by persistence.initialize() + from langbot.pkg.entity.persistence import workspace as persistence_workspace + + ws_res = await persistence.execute_async(sqlalchemy.select(persistence_workspace.Workspace)) + workspace = ws_res.scalar_one() + workspace_uuid = getattr(workspace, 'uuid', workspace) + + app.application_api = ApplicationAPI(app) + + recorded_audits = [] + + async def record_audit(ws_uuid, **kwargs): + recorded_audits.append({'workspace_uuid': ws_uuid, **kwargs}) + + app.workspace_settings_service = SimpleNamespace( + effective_level=AsyncMock(return_value=2), + record=AsyncMock(side_effect=record_audit), + ) + app.llm_model_service = SimpleNamespace( + apply_default_model=AsyncMock(side_effect=lambda ctx, schema, params: params) + ) + app.pipeline_service = SimpleNamespace(_get_default_values_from_schema=Mock(return_value={})) + + bot_service = BotService(app) + app.bot_service = bot_service + + agent_service = AgentService(app) + app.agent_service = agent_service + + # Create a bot in the real SQLite database + bot_uuid = str(uuid.uuid4()) + await persistence.execute_async( + sqlalchemy.insert(persistence_bot.Bot).values( + uuid=bot_uuid, + workspace_uuid=workspace_uuid, + name='E2E Test Assistant Bot', + adapter='test_adapter', + adapter_config={}, + enable=True, + event_bindings=[], + plugin_processors=[], + description='Test Bot in SQLite', + ) + ) + + # 4. Create event processor using the example plugin runner with owner permissions + from langbot.pkg.api.authz import WorkspaceRole, permissions_for_role + from langbot.pkg.api.context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext + + ws_ctx = WorkspaceContext( + workspace_uuid=workspace_uuid, + membership_uuid=str(uuid.uuid4()), + role=WorkspaceRole.OWNER.value, + permissions=permissions_for_role(WorkspaceRole.OWNER), + ) + editor_context = RequestContext( + instance_uuid='test-instance-uuid', + placement_generation=1, + request_id='req-e2e', + auth_type='session', + principal=PrincipalContext(principal_type=PrincipalType.ACCOUNT, account_uuid='owner-user'), + workspace=ws_ctx, + ) + + created = await agent_service.create_agent( + editor_context, + { + 'name': 'E2E Workspace Event Processor', + 'kind': 'event_processor', + 'component_ref': descriptor.id, + 'parameters': {}, + }, + ) + agent_uuid = created['uuid'] + agent_info = await agent_service.get_agent(workspace_uuid, agent_uuid) + + assert agent_info['component_ref'] == descriptor.id + config = agent_info['config'] + assert 'allowed_workspace_operations' in config + assert set(config['allowed_workspace_operations']) == {'bot.list', 'stats.basic'} + + # 5. Runtime binding & ExecutionAPI setup + binding = InstallationBinding( + instance_uuid='instance-e2e', + workspace_uuid=workspace_uuid, + placement_generation=1, + installation_uuid=str(uuid.uuid4()), + runtime_revision=1, + artifact_digest='e' * 64, + ) + app.workspace_service = SimpleNamespace(get_execution_binding=AsyncMock(return_value=binding)) + + handler = RuntimeConnectionHandler(Mock(), AsyncMock(return_value=True), app) + handler.register_installation_binding(binding, plugin_author='LangBot', plugin_name='WorkspaceRunnerExample') + handler._current_action_context.set(binding) + app.plugin_connector = SimpleNamespace(handler=handler) + exec_api = handler.execution_api + + # 6. Test with default granted operations + run_id_granted = 'run-e2e-granted' + event = AgentEventEnvelope.model_validate( + { + 'event_id': run_id_granted, + 'event_type': 'message.received', + 'source': 'webui', + 'workspace_id': workspace_uuid, + 'input': {'text': 'test', 'contents': [{'type': 'text', 'text': 'test'}]}, + 'delivery': {'surface': 'webui'}, + } + ) + + agent_binding_granted = AgentBinding( + binding_id='bind-e2e', + runner_id=descriptor.id, + processor_type='agent', + processor_id=agent_uuid, + resource_policy=ResourcePolicy(allowed_workspace_operations=config['allowed_workspace_operations']), + ) + + exec_ctx = ExecutionContext(instance_uuid='instance-e2e', workspace_uuid=workspace_uuid, placement_generation=1) + builder = AgentResourceBuilder(app) + resources_granted = await builder.build_resources_from_binding( + exec_ctx, event, agent_binding_granted, descriptor + ) + + granted_ops = [item['id'] for item in resources_granted.get('workspace_operations', [])] + assert set(granted_ops) == {'bot.list', 'stats.basic'} + + session_reg = get_session_registry() + await session_reg.register( + run_id=run_id_granted, + runner_id=descriptor.id, + query_id=None, + plugin_identity=descriptor.get_plugin_id(), + resources=resources_granted, + workspace_id=workspace_uuid, + processor_type='agent', + processor_id=agent_uuid, + ) + + call_ctx_granted = ExecutionAPIContext(binding=binding, run_id=run_id_granted) + + # 6a. list_application_operations + list_res = await exec_api.invoke('execution.list_application_operations', call_ctx_granted, {}) + listed_ids = [op['id'] for op in list_res.get('operations', [])] + assert set(listed_ids) == {'bot.list', 'stats.basic'} + + # 6b. call_application_operation('bot.list') + call_res = await exec_api.invoke( + 'execution.call_application_operation', + call_ctx_granted, + {'operation_id': 'bot.list', 'arguments': {}}, + ) + assert call_res.get('ok') is True + bots_returned = call_res.get('result', []) + assert any(b['uuid'] == bot_uuid for b in bots_returned) + + # 7. Revoke authorization: update agent in SQLite to clear allowed_workspace_operations + await agent_service.update_agent( + editor_context, + agent_uuid, + { + 'config': { + **config, + 'allowed_workspace_operations': [], + } + }, + ) + + updated_agent = await agent_service.get_agent(workspace_uuid, agent_uuid) + assert updated_agent['config']['allowed_workspace_operations'] == [] + + # 8. Test execution after revocation + run_id_revoked = 'run-e2e-revoked' + agent_binding_revoked = AgentBinding( + binding_id='bind-e2e', + runner_id=descriptor.id, + processor_type='agent', + processor_id=agent_uuid, + resource_policy=ResourcePolicy(allowed_workspace_operations=[]), + ) + resources_revoked = await builder.build_resources_from_binding( + exec_ctx, event, agent_binding_revoked, descriptor + ) + assert resources_revoked.get('workspace_operations', []) == [] + + await session_reg.register( + run_id=run_id_revoked, + runner_id=descriptor.id, + query_id=None, + plugin_identity=descriptor.get_plugin_id(), + resources=resources_revoked, + workspace_id=workspace_uuid, + processor_type='agent', + processor_id=agent_uuid, + ) + + call_ctx_revoked = ExecutionAPIContext(binding=binding, run_id=run_id_revoked) + + # 8a. list_application_operations returns empty + list_revoked_res = await exec_api.invoke('execution.list_application_operations', call_ctx_revoked, {}) + assert list_revoked_res.get('operations', []) == [] + + # 8b. call_application_operation('bot.list') is rejected + call_revoked_res = await exec_api.invoke( + 'execution.call_application_operation', + call_ctx_revoked, + {'operation_id': 'bot.list', 'arguments': {}}, + ) + assert call_revoked_res.get('ok') is False + assert call_revoked_res.get('error', {}).get('code') == 'not_found' + finally: + await persistence.get_db_engine().dispose() diff --git a/tests/unit_tests/api/test_manifest_declared_workspace_operations.py b/tests/unit_tests/api/test_manifest_declared_workspace_operations.py index 1732a5545..bb1adc96e 100644 --- a/tests/unit_tests/api/test_manifest_declared_workspace_operations.py +++ b/tests/unit_tests/api/test_manifest_declared_workspace_operations.py @@ -194,9 +194,14 @@ def test_registry_real_new_sdk_manifest_loading_subprocess(): import sys from pathlib import Path - sdk_path = Path(__file__).resolve().parents[4] / 'langbot-plugin-sdk' / 'src' + sdk_env = os.environ.get('LANGBOT_PLUGIN_SDK_PATH') + if sdk_env: + sdk_candidate = Path(sdk_env).resolve() + sdk_path = sdk_candidate / 'src' if (sdk_candidate / 'src').is_dir() else sdk_candidate + else: + sdk_path = Path(__file__).resolve().parents[4] / 'langbot-plugin-sdk' / 'src' if not sdk_path.exists(): - pytest.skip('langbot-plugin-sdk repo not found') + pytest.skip(f'langbot-plugin-sdk not found at {sdk_path} (set LANGBOT_PLUGIN_SDK_PATH to override)') code = f""" import sys