mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-11-02 05:45:20 +08:00
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 <[email protected]>
This commit is contained in:
co-authored by
multica-agent
parent
111aabd985
commit
45361d2bdb
@@ -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]
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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())
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user