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:
RockChinQ
2026-11-01 13:08:43 +08:00
co-authored by multica-agent
parent 111aabd985
commit 45361d2bdb
5 changed files with 308 additions and 45 deletions
+9 -13
View File
@@ -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]
+1 -1
View File
@@ -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(
+218
View File
@@ -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():