From 45361d2bdbfdf2f4ada2cf9e4438ded10c1c6ea0 Mon Sep 17 00:00:00 2001 From: RockChinQ Date: Sun, 1 Nov 2026 13:08:43 +0800 Subject: [PATCH] fix(api-tools): fix registry SDK detection, fallback logic and local agent check (LANG-232) - Fix registry SDK detection: use 'workspace_operations' not in RunnerPermissions.model_fields instead of hasattr - Remove raw_manifest fallback from declared_workspace_operations and resource_builder so 0.7.19 safely disables declared grants - Unify is_local_agent check across descriptor.py and resource_builder.py, fail-closed when descriptor is None - Add unmocked tests for real installed 0.7.19 SDK and real new SDK - Add tests/demo_e2e_workspace_runner.py for reproducible end-to-end verification Co-authored-by: multica-agent --- src/langbot/pkg/agent/runner/descriptor.py | 22 +- src/langbot/pkg/agent/runner/registry.py | 2 +- .../pkg/agent/runner/resource_builder.py | 20 +- tests/demo_e2e_workspace_runner.py | 218 ++++++++++++++++++ ..._manifest_declared_workspace_operations.py | 91 ++++++-- 5 files changed, 308 insertions(+), 45 deletions(-) create mode 100644 tests/demo_e2e_workspace_runner.py diff --git a/src/langbot/pkg/agent/runner/descriptor.py b/src/langbot/pkg/agent/runner/descriptor.py index 64c155f33..5e2ba9df6 100644 --- a/src/langbot/pkg/agent/runner/descriptor.py +++ b/src/langbot/pkg/agent/runner/descriptor.py @@ -86,17 +86,12 @@ class RunnerDescriptor(pydantic.BaseModel): """Return list of workspace operations declared in runner manifest permissions. Uses getattr for compatibility with SDK 0.7.19 where workspace_operations does not exist. + In SDK 0.7.19 environment, this returns empty list so the feature is disabled safely. """ permissions = getattr(self, 'permissions', None) if isinstance(permissions, dict): return list(permissions.get('workspace_operations') or []) - ops = getattr(permissions, 'workspace_operations', None) - if ops is not None: - return list(ops) - raw_perms = getattr(self, 'raw_manifest', {}).get('permissions', {}) - if isinstance(raw_perms, dict) and 'workspace_operations' in raw_perms: - return list(raw_perms.get('workspace_operations') or []) - return [] + return list(getattr(permissions, 'workspace_operations', []) or []) @property def workspace_operations(self) -> list[str]: @@ -107,13 +102,14 @@ class RunnerDescriptor(pydantic.BaseModel): def is_local_agent(descriptor: RunnerDescriptor | None) -> bool: """Check if descriptor represents LocalAgent.""" if descriptor is None: - return True - if descriptor.id == 'defaultrunner' or not descriptor.id.startswith('plugin:'): + return False + runner_id = getattr(descriptor, 'id', '') + if runner_id == 'defaultrunner' or runner_id == 'plugin:langbot-team/LocalAgent/default': return True return ( getattr(descriptor, 'plugin_author', None) == 'langbot-team' and getattr(descriptor, 'plugin_name', None) == 'LocalAgent' - ) or getattr(descriptor, 'id', '') == 'plugin:langbot-team/LocalAgent/default' + ) def resolve_authorized_workspace_operations( @@ -122,13 +118,13 @@ def resolve_authorized_workspace_operations( ) -> list[str]: """Calculate effective authorized workspace operations at runtime. - LocalAgent (defaultrunner or non-plugin runner) is not constrained by manifest; - it receives all operations configured by the user. + LocalAgent is not constrained by manifest; it receives all operations + configured by the user. Plugin runners are restricted to the intersection of configured operations and operations declared in the runner's manifest permissions. """ configured = [str(op) for op in (configured_operations or [])] if is_local_agent(descriptor): return configured - declared = set(descriptor.declared_workspace_operations()) + declared = set(descriptor.declared_workspace_operations() if descriptor is not None else []) return [op for op in configured if op in declared] diff --git a/src/langbot/pkg/agent/runner/registry.py b/src/langbot/pkg/agent/runner/registry.py index e23503873..5259d3b0c 100644 --- a/src/langbot/pkg/agent/runner/registry.py +++ b/src/langbot/pkg/agent/runner/registry.py @@ -118,7 +118,7 @@ class RunnerRegistry: ) manifest_payload = manifest - if not hasattr(RunnerPermissions, 'workspace_operations'): + if 'workspace_operations' not in getattr(RunnerPermissions, 'model_fields', {}): if isinstance(manifest, dict) and 'permissions' in manifest and isinstance(manifest['permissions'], dict): manifest_payload = dict(manifest) perms = dict(manifest['permissions']) diff --git a/src/langbot/pkg/agent/runner/resource_builder.py b/src/langbot/pkg/agent/runner/resource_builder.py index 144d2eea6..8d22e3c0f 100644 --- a/src/langbot/pkg/agent/runner/resource_builder.py +++ b/src/langbot/pkg/agent/runner/resource_builder.py @@ -6,7 +6,7 @@ import typing from ...core import app from ...api.http.context import ExecutionContext -from .descriptor import RunnerDescriptor, resolve_authorized_workspace_operations +from .descriptor import RunnerDescriptor, is_local_agent, resolve_authorized_workspace_operations from .context_builder import ( AgentResources, ModelResource, @@ -150,22 +150,10 @@ class AgentResourceBuilder: - Runner manifest declares workspace_operations permission, OR - Builtin LocalAgent with user-configured operations. """ - is_local_agent = ( - getattr(descriptor, 'plugin_author', None) == 'langbot-team' - and getattr(descriptor, 'plugin_name', None) == 'LocalAgent' - ) or getattr(descriptor, 'id', '') == 'plugin:langbot-team/LocalAgent/default' + local_agent = is_local_agent(descriptor) + manifest_declares = bool(descriptor.declared_workspace_operations()) if descriptor else False - manifest_declares = False - perms = getattr(descriptor, 'permissions', None) - if perms is not None and getattr(perms, 'workspace_operations', None): - manifest_declares = True - else: - raw_manifest = getattr(descriptor, 'raw_manifest', None) or {} - manifest_perms = raw_manifest.get('permissions') or {} - if manifest_perms.get('workspace_operations'): - manifest_declares = True - - if not (is_local_agent or manifest_declares): + if not (local_agent or manifest_declares): return None allowed_ops = resolve_authorized_workspace_operations( diff --git a/tests/demo_e2e_workspace_runner.py b/tests/demo_e2e_workspace_runner.py new file mode 100644 index 000000000..8427a801b --- /dev/null +++ b/tests/demo_e2e_workspace_runner.py @@ -0,0 +1,218 @@ +"""Reproducible end-to-end verification script for runner workspace operations. + +Demonstrates: +1. Loading manifest permissions via RunnerRegistry._validate_and_build_descriptor +2. Default operations resolution via get_default_workspace_operations +3. Runtime resource building via AgentResourceBuilder with resolve_authorized_workspace_operations +4. Listing operations via execution.list_application_operations +5. Invoking operation via execution.call_application_operation (bot.list) +6. Recording audit logs +""" + +from __future__ import annotations + +import asyncio +import sys +from contextlib import asynccontextmanager +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import yaml + +# 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) + +from langbot_plugin.api.entities.builtin.runner.manifest import RunnerPermissions # 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.application.tool_grants import get_default_workspace_operations # noqa: E402 +from langbot.pkg.api.http.context import ExecutionContext # noqa: E402 +from langbot.pkg.plugin.handler import RuntimeConnectionHandler # noqa: E402 + + +async def run_demo(): + print('======================================================================') + print('LANG-232 端到端全链路运行验证 (E2E Workspace Operations Execution)') + print('======================================================================\n') + + # 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' + ) + with open(manifest_yaml_path, 'r', encoding='utf-8') as f: + comp_yaml = yaml.safe_load(f) + + print( + f'SDK RunnerPermissions.model_fields has workspace_operations: {"workspace_operations" in RunnerPermissions.model_fields}' + ) + + registry = RunnerRegistry(Mock()) + 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'], + '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 + declared = descriptor.declared_workspace_operations() + print(f'Runner ID: {descriptor.id}') + print(f'Declared workspace_operations: {declared}') + assert declared == ['bot.list', 'stats.basic'] + + # Step 2: Default Selection + print('\n--- Step 2: 默认勾选范围 (get_default_workspace_operations) ---') + app_dummy = SimpleNamespace(application_api=None) + defaults = get_default_workspace_operations(app_dummy, declared) + print(f'Default selected (declared ∩ exposure == "all"): {defaults}') + assert defaults == ['bot.list', 'stats.basic'] + + # Step 3: Runtime Binding & Resource Building + print('\n--- Step 3: 运行时交集授权与资源构建 (AgentResourceBuilder) ---') + 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='a' * 64, + ) + recorded_audits = [] + + async def record_audit(workspace_uuid, **kwargs): + recorded_audits.append({'workspace_uuid': workspace_uuid, **kwargs}) + + @asynccontextmanager + async def tenant_scope(workspace): + yield + + host = SimpleNamespace( + logger=Mock(), + workspace_service=SimpleNamespace(get_execution_binding=AsyncMock(return_value=binding)), + persistence_mgr=SimpleNamespace(tenant_scope=tenant_scope), + workspace_settings_service=SimpleNamespace( + effective_level=AsyncMock(return_value=2), + record=AsyncMock(side_effect=record_audit), + ), + bot_service=SimpleNamespace( + get_bots=AsyncMock(return_value=[{'bot_uuid': 'bot-test-01', 'name': 'Demo Assistant Bot'}]) + ), + recorded_audits=recorded_audits, + skill_mgr=None, + ) + host.application_api = ApplicationAPI(host) + + handler = RuntimeConnectionHandler(Mock(), AsyncMock(return_value=True), host) + handler.register_installation_binding(binding, plugin_author='LangBot', plugin_name='WorkspaceRunnerExample') + handler._current_action_context.set(binding) + host.plugin_connector = SimpleNamespace(handler=handler) + exec_api = handler.execution_api + + run_id = 'run-demo-e2e-232' + event = AgentEventEnvelope.model_validate( + { + 'event_id': run_id, + 'event_type': 'message.received', + 'source': 'webui', + 'workspace_id': 'workspace-a', + 'input': {'text': 'list bots', 'contents': [{'type': 'text', 'text': 'list bots'}]}, + 'delivery': {'surface': 'webui'}, + } + ) + + # Suppose user configured declared ops + extra un-declared op ('audit.log.list') + configured_by_user = ['bot.list', 'stats.basic', 'audit.log.list'] + agent_binding = AgentBinding( + binding_id='bind-demo', + runner_id=descriptor.id, + processor_type='agent', + processor_id='agent-demo-001', + resource_policy=ResourcePolicy(allowed_workspace_operations=configured_by_user), + ) + ctx = ExecutionContext(instance_uuid='instance-a', workspace_uuid='workspace-a', placement_generation=1) + builder = AgentResourceBuilder(host) + resources = await builder.build_resources_from_binding(ctx, event, agent_binding, descriptor) + + granted_ids = [item['id'] for item in resources.get('workspace_operations', [])] + print(f'Configured: {configured_by_user}') + print(f'Runtime Granted (configured ∩ declared): {granted_ids}') + assert granted_ids == ['bot.list', 'stats.basic'] + + # Step 4: Register Run Session + registry_session = get_session_registry() + await registry_session.register( + run_id=run_id, + runner_id=descriptor.id, + query_id=None, + plugin_identity=descriptor.get_plugin_id(), + resources=resources, + workspace_id='workspace-a', + processor_type='agent', + processor_id='agent-demo-001', + ) + exec_context = ExecutionAPIContext(binding=binding, run_id=run_id) + + # Step 5: Execution API list_application_operations + print('\n--- Step 4: api.workspace.list() 调用 ---') + list_res = await exec_api.invoke('execution.list_application_operations', exec_context, {}) + ops = [op['id'] for op in list_res.get('operations', [])] + print(f'Returned operations count: {len(ops)}') + print(f'Operations: {ops}') + assert set(ops) == {'bot.list', 'stats.basic'} + + # Step 6: Execution API call_application_operation + print('\n--- Step 5: api.workspace.call("bot.list") 调用 ---') + call_res = await exec_api.invoke( + 'execution.call_application_operation', + exec_context, + {'operation_id': 'bot.list', 'arguments': {}}, + ) + print(f'Call response ok: {call_res.get("ok")}') + print(f'Call result data: {call_res.get("result")}') + assert call_res.get('ok') is True + assert isinstance(call_res.get('result'), list) + assert len(call_res.get('result', [])) == 1 + + # Step 7: Audit Logging + print('\n--- Step 6: 审计记录 (Audit Records) ---') + print(f'Recorded audit count: {len(recorded_audits)}') + for idx, item in enumerate(recorded_audits, 1): + print( + f' [{idx}] workspace: {item.get("workspace_uuid")}, route: {item.get("route")}, status: {item.get("status")}' + ) + assert len(recorded_audits) >= 1 + assert recorded_audits[-1]['route'] == 'bot.list' + + print('\n======================================================================') + print('端到端全链路运行成功验证完成!') + print('======================================================================') + + +if __name__ == '__main__': + asyncio.run(run_demo()) 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 27f8c9cfa..1732a5545 100644 --- a/tests/unit_tests/api/test_manifest_declared_workspace_operations.py +++ b/tests/unit_tests/api/test_manifest_declared_workspace_operations.py @@ -92,13 +92,14 @@ def test_resolve_authorized_workspace_operations_local_agent(): 'permissions': {}, # No manifest declarations } ) - assert is_local_agent(desc_builtin) + assert is_local_agent(desc_builtin) is True configured = ['bot.list', 'bot.create', 'stats.basic'] effective = resolve_authorized_workspace_operations(desc_builtin, configured) assert effective == configured - # None descriptor or defaultrunner - assert resolve_authorized_workspace_operations(None, configured) == configured + # None descriptor does not pass is_local_agent and returns empty + assert is_local_agent(None) is False + assert resolve_authorized_workspace_operations(None, configured) == [] def test_resolve_authorized_workspace_operations_plugin_intersection_and_narrowing(): @@ -144,20 +145,15 @@ def test_resolve_authorized_workspace_operations_plugin_intersection_and_narrowi assert effective_v2 == ['bot.list'] -def test_registry_sdk_0719_manifest_loading_compat(monkeypatch): - """Test registry strips workspace_operations when running with SDK 0.7.19 without workspace_operations.""" - app_mock = MagicMock() - registry = RunnerRegistry(app_mock) - - # Simulate SDK 0.7.19 where RunnerPermissions does not have workspace_operations +def test_registry_real_installed_sdk_0719_manifest_loading(): + """Test registry with real installed SDK 0.7.19 without mocking: strips workspace_operations, returns empty declared.""" import langbot.pkg.agent.runner.registry as reg_module - class MockOldRunnerPermissions(pydantic.BaseModel): - models: list[str] = [] - tools: list[str] = [] - model_config = pydantic.ConfigDict(extra='forbid') + # Verify if running against SDK 0.7.19 + is_old_sdk = 'workspace_operations' not in getattr(reg_module.RunnerPermissions, 'model_fields', {}) - monkeypatch.setattr(reg_module, 'RunnerPermissions', MockOldRunnerPermissions) + app_mock = MagicMock() + registry = RunnerRegistry(app_mock) raw_runner_data = { 'plugin_author': 'test-author', @@ -176,11 +172,76 @@ def test_registry_sdk_0719_manifest_loading_compat(monkeypatch): }, } - # Should not raise pydantic.ValidationError (extra forbidden) + # Must validate successfully without raising extra_forbidden desc = registry._validate_and_build_descriptor(raw_runner_data) assert desc is not None assert desc.id == 'plugin:test-author/test-plugin/default' + if is_old_sdk: + # In 0.7.19, declared workspace operations must be empty (feature disabled cleanly) + assert desc.declared_workspace_operations() == [] + assert resolve_authorized_workspace_operations(desc, ['bot.list', 'stats.basic']) == [] + else: + # In new SDK, declared workspace operations are preserved + assert desc.declared_workspace_operations() == ['bot.list', 'stats.basic'] + assert resolve_authorized_workspace_operations(desc, ['bot.list', 'stats.basic']) == ['bot.list', 'stats.basic'] + + +def test_registry_real_new_sdk_manifest_loading_subprocess(): + """Test registry with real new SDK from langbot-plugin-sdk repo in a clean subprocess.""" + import os + import subprocess + import sys + from pathlib import Path + + sdk_path = Path(__file__).resolve().parents[4] / 'langbot-plugin-sdk' / 'src' + if not sdk_path.exists(): + pytest.skip('langbot-plugin-sdk repo not found') + + code = f""" +import sys +sys.path.insert(0, {repr(str(sdk_path))}) + +from langbot_plugin.api.entities.builtin.runner.manifest import RunnerPermissions +assert "workspace_operations" in RunnerPermissions.model_fields, "New SDK must have workspace_operations" + +from langbot.pkg.agent.runner.registry import RunnerRegistry +from langbot.pkg.agent.runner.descriptor import resolve_authorized_workspace_operations +from unittest.mock import Mock + +registry = RunnerRegistry(Mock()) +raw_runner_data = {{ + 'plugin_author': 'test-author', + 'plugin_name': 'test-plugin', + 'runner_name': 'default', + 'manifest': {{ + 'id': 'default', + 'name': 'default', + 'component_kind': 'Runner', + 'usages': ['agent'], + 'label': {{'en_US': 'Test'}}, + 'permissions': {{ + 'workspace_operations': ['bot.list', 'stats.basic'], + }}, + 'config_schema': [], + }}, +}} +desc = registry._validate_and_build_descriptor(raw_runner_data) +assert desc is not None +assert desc.declared_workspace_operations() == ['bot.list', 'stats.basic'], f"Got {{desc.declared_workspace_operations()}}" +authorized = resolve_authorized_workspace_operations(desc, ['bot.list', 'stats.basic', 'other']) +assert authorized == ['bot.list', 'stats.basic'], f"Got {{authorized}}" +print("REAL_NEW_SDK_PASSED") +""" + res = subprocess.run( + [sys.executable, '-c', code], + env={**os.environ, 'PYTHONPATH': f'{sdk_path}:{os.environ.get("PYTHONPATH", "")}'}, + capture_output=True, + text=True, + ) + assert res.returncode == 0, f'STDOUT:\n{res.stdout}\nSTDERR:\n{res.stderr}' + assert 'REAL_NEW_SDK_PASSED' in res.stdout + @pytest.mark.asyncio async def test_registry_pipeline_metadata_includes_workspace_operations():