mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-11-01 21:35:25 +08:00
476 lines
17 KiB
Python
476 lines
17 KiB
Python
"""Unit tests for runner workspace operations delivery and ExecutionAPI list/call."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, Mock
|
|
|
|
import pytest
|
|
from langbot_plugin.entities.io.context import InstallationBinding
|
|
|
|
from langbot.pkg.agent.runner.descriptor import RunnerDescriptor
|
|
from langbot.pkg.agent.runner.host_models import AgentBinding, AgentEventEnvelope, ResourcePolicy
|
|
from langbot.pkg.agent.runner.resource_builder import AgentResourceBuilder
|
|
from langbot.pkg.agent.runner.session_registry import get_session_registry
|
|
from langbot.pkg.api.application.api import ApplicationAPI
|
|
from langbot.pkg.api.application.contracts import ApplicationAPIException, Input
|
|
from langbot.pkg.api.application.execution import ExecutionAPIContext
|
|
from langbot.pkg.api.http.context import ExecutionContext
|
|
from langbot.pkg.plugin.handler import RuntimeConnectionHandler
|
|
|
|
|
|
class DummyListOutput(Input):
|
|
pass
|
|
|
|
|
|
@pytest.fixture
|
|
def test_env(monkeypatch):
|
|
monkeypatch.setattr('langbot.pkg.api.application.api.get_workspace_language', AsyncMock(return_value='en_US'))
|
|
|
|
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})
|
|
|
|
from contextlib import asynccontextmanager
|
|
|
|
@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-1', 'name': 'Test Bot'}])),
|
|
recorded_audits=recorded_audits,
|
|
)
|
|
api = ApplicationAPI(host)
|
|
host.application_api = api
|
|
|
|
handler = RuntimeConnectionHandler(Mock(), AsyncMock(return_value=True), host)
|
|
handler.register_installation_binding(binding, plugin_author='langbot-team', plugin_name='LocalAgent')
|
|
host.plugin_connector = SimpleNamespace(handler=handler)
|
|
exec_api = handler.execution_api
|
|
|
|
return SimpleNamespace(
|
|
host=host,
|
|
handler=handler,
|
|
binding=binding,
|
|
exec_api=exec_api,
|
|
recorded_audits=recorded_audits,
|
|
)
|
|
|
|
|
|
def make_local_agent_descriptor() -> RunnerDescriptor:
|
|
return RunnerDescriptor.model_validate(
|
|
{
|
|
'id': 'plugin:langbot-team/LocalAgent/default',
|
|
'source': 'plugin',
|
|
'plugin_author': 'langbot-team',
|
|
'plugin_name': 'LocalAgent',
|
|
'runner_name': 'default',
|
|
'label': {'en_US': 'Local Agent'},
|
|
'usages': ['agent'],
|
|
'permissions': {
|
|
'models': ['invoke'],
|
|
'tools': ['detail', 'call'],
|
|
},
|
|
}
|
|
)
|
|
|
|
|
|
def make_external_runner_descriptor(declares_ws_ops: bool = False) -> RunnerDescriptor:
|
|
data = {
|
|
'id': 'plugin:custom-author/custom-runner/default',
|
|
'source': 'plugin',
|
|
'plugin_author': 'custom-author',
|
|
'plugin_name': 'custom-runner',
|
|
'runner_name': 'default',
|
|
'label': {'en_US': 'Custom Runner'},
|
|
'usages': ['agent'],
|
|
'permissions': {
|
|
'models': ['invoke'],
|
|
'tools': ['detail', 'call'],
|
|
},
|
|
'raw_manifest': {
|
|
'permissions': {
|
|
'workspace_operations': ['bot.list'] if declares_ws_ops else [],
|
|
}
|
|
},
|
|
}
|
|
return RunnerDescriptor.model_validate(data)
|
|
|
|
|
|
def make_event(event_id: str = 'event-1', workspace_id: str = 'workspace-a') -> AgentEventEnvelope:
|
|
return AgentEventEnvelope.model_validate(
|
|
{
|
|
'event_id': event_id,
|
|
'event_type': 'message.received',
|
|
'source': 'webui',
|
|
'workspace_id': workspace_id,
|
|
'input': {'text': 'hello', 'contents': [{'type': 'text', 'text': 'hello'}]},
|
|
'delivery': {'surface': 'webui'},
|
|
}
|
|
)
|
|
|
|
|
|
# 1. 授权集合为空时,资源中没有 workspace_operations,或者是空列表
|
|
@pytest.mark.asyncio
|
|
async def test_empty_grants_workspace_operations_resource(test_env):
|
|
builder = AgentResourceBuilder(test_env.host)
|
|
event = make_event()
|
|
binding = AgentBinding(
|
|
binding_id='bind-1',
|
|
runner_id='plugin:langbot-team/LocalAgent/default',
|
|
processor_type='agent',
|
|
processor_id='agent-1',
|
|
resource_policy=ResourcePolicy(allowed_workspace_operations=[]),
|
|
)
|
|
descriptor = make_local_agent_descriptor()
|
|
ctx = ExecutionContext(instance_uuid='instance-a', workspace_uuid='workspace-a', placement_generation=1)
|
|
|
|
resources = await builder.build_resources_from_binding(
|
|
ctx,
|
|
event,
|
|
binding,
|
|
descriptor,
|
|
)
|
|
|
|
# LocalAgent qualifies, so it receives an empty list when allowed operations is empty
|
|
assert 'workspace_operations' in resources
|
|
assert resources['workspace_operations'] == []
|
|
|
|
# Non-declaring runner does not receive the field at all
|
|
non_declaring = make_external_runner_descriptor(declares_ws_ops=False)
|
|
resources_external = await builder.build_resources_from_binding(
|
|
ctx,
|
|
event,
|
|
binding,
|
|
non_declaring,
|
|
)
|
|
assert 'workspace_operations' not in resources_external
|
|
|
|
|
|
# 2. 勾选 3 个操作时,list 恰好返回这 3 个,并带完整 schema
|
|
@pytest.mark.asyncio
|
|
async def test_list_application_operations_returns_exact_three_with_full_schema(test_env):
|
|
run_id = 'run-exact-3'
|
|
ops_to_grant = ['bot.list', 'bot.create', 'adapter.list']
|
|
|
|
builder = AgentResourceBuilder(test_env.host)
|
|
event = make_event(event_id=run_id)
|
|
binding = AgentBinding(
|
|
binding_id='bind-1',
|
|
runner_id='plugin:langbot-team/LocalAgent/default',
|
|
processor_type='agent',
|
|
processor_id='agent-1',
|
|
resource_policy=ResourcePolicy(allowed_workspace_operations=ops_to_grant),
|
|
)
|
|
descriptor = make_local_agent_descriptor()
|
|
ctx = ExecutionContext(instance_uuid='instance-a', workspace_uuid='workspace-a', placement_generation=1)
|
|
|
|
resources = await builder.build_resources_from_binding(ctx, event, binding, descriptor)
|
|
assert 'workspace_operations' in resources
|
|
assert len(resources['workspace_operations']) == 3
|
|
|
|
# Register session
|
|
registry = get_session_registry()
|
|
await registry.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-1',
|
|
)
|
|
|
|
exec_context = ExecutionAPIContext(
|
|
binding=test_env.binding,
|
|
run_id=run_id,
|
|
claimed_identity='langbot-team/LocalAgent',
|
|
)
|
|
|
|
result = await test_env.exec_api.invoke('execution.list_application_operations', exec_context, {})
|
|
assert 'operations' in result
|
|
assert 'domains' in result
|
|
|
|
ops = result['operations']
|
|
assert len(ops) == 3
|
|
op_ids = {op['id'] for op in ops}
|
|
assert op_ids == set(ops_to_grant)
|
|
|
|
# Verify full schema in each operation
|
|
for op in ops:
|
|
assert isinstance(op['id'], str)
|
|
assert isinstance(op['domain'], str)
|
|
assert isinstance(op['label'], dict)
|
|
assert isinstance(op['summary'], str)
|
|
assert isinstance(op['input_schema'], dict)
|
|
assert isinstance(op['output_schema'], dict)
|
|
assert op['risk'] in ('read', 'write', 'dangerous')
|
|
assert isinstance(op['side_effect'], bool)
|
|
|
|
# Verify domains summary
|
|
domain_names = {d['domain'] for d in result['domains']}
|
|
assert domain_names == {'bot', 'adapter'}
|
|
|
|
|
|
# 3. call 调用已授权的操作成功,审计记录带有处理器和 run_id
|
|
@pytest.mark.asyncio
|
|
async def test_call_application_operation_success_and_audit(test_env):
|
|
run_id = 'run-call-auth-1'
|
|
ops_to_grant = ['bot.list']
|
|
|
|
builder = AgentResourceBuilder(test_env.host)
|
|
event = make_event(event_id=run_id)
|
|
binding = AgentBinding(
|
|
binding_id='bind-1',
|
|
runner_id='plugin:langbot-team/LocalAgent/default',
|
|
processor_type='agent',
|
|
processor_id='agent-uuid-999',
|
|
resource_policy=ResourcePolicy(allowed_workspace_operations=ops_to_grant),
|
|
)
|
|
descriptor = make_local_agent_descriptor()
|
|
ctx = ExecutionContext(instance_uuid='instance-a', workspace_uuid='workspace-a', placement_generation=1)
|
|
|
|
resources = await builder.build_resources_from_binding(ctx, event, binding, descriptor)
|
|
|
|
registry = get_session_registry()
|
|
await registry.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-uuid-999',
|
|
)
|
|
|
|
exec_context = ExecutionAPIContext(
|
|
binding=test_env.binding,
|
|
run_id=run_id,
|
|
claimed_identity='langbot-team/LocalAgent',
|
|
)
|
|
|
|
# Invoke authorized operation
|
|
call_result = await test_env.exec_api.invoke(
|
|
'execution.call_application_operation',
|
|
exec_context,
|
|
{'operation_id': 'bot.list', 'arguments': {}},
|
|
)
|
|
assert call_result['ok'] is True
|
|
assert isinstance(call_result['result'], list)
|
|
|
|
# Verify audit record
|
|
assert len(test_env.recorded_audits) > 0
|
|
audit = test_env.recorded_audits[-1]
|
|
assert audit['route'] == 'bot.list'
|
|
assert audit['principal_type'] == 'processor'
|
|
assert audit['detail']['processor_kind'] == 'agent'
|
|
assert audit['detail']['processor_uuid'] == 'agent-uuid-999'
|
|
assert audit['detail']['run_id'] == run_id
|
|
|
|
|
|
# 4. call 调用未授权的操作,返回 not_found
|
|
@pytest.mark.asyncio
|
|
async def test_call_application_operation_unauthorized_returns_not_found(test_env):
|
|
run_id = 'run-call-unauth'
|
|
ops_to_grant = ['bot.list']
|
|
|
|
builder = AgentResourceBuilder(test_env.host)
|
|
event = make_event(event_id=run_id)
|
|
binding = AgentBinding(
|
|
binding_id='bind-1',
|
|
runner_id='plugin:langbot-team/LocalAgent/default',
|
|
processor_type='agent',
|
|
processor_id='agent-uuid-999',
|
|
resource_policy=ResourcePolicy(allowed_workspace_operations=ops_to_grant),
|
|
)
|
|
descriptor = make_local_agent_descriptor()
|
|
ctx = ExecutionContext(instance_uuid='instance-a', workspace_uuid='workspace-a', placement_generation=1)
|
|
|
|
resources = await builder.build_resources_from_binding(ctx, event, binding, descriptor)
|
|
|
|
registry = get_session_registry()
|
|
await registry.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-uuid-999',
|
|
)
|
|
|
|
exec_context = ExecutionAPIContext(
|
|
binding=test_env.binding,
|
|
run_id=run_id,
|
|
claimed_identity='langbot-team/LocalAgent',
|
|
)
|
|
|
|
# Attempt to call 'bot.delete' which is NOT in granted list
|
|
call_result = await test_env.exec_api.invoke(
|
|
'execution.call_application_operation',
|
|
exec_context,
|
|
{'operation_id': 'bot.delete', 'arguments': {'bot_uuid': 'b-1'}},
|
|
)
|
|
assert call_result['ok'] is False
|
|
assert call_result['error']['code'] == 'not_found'
|
|
|
|
|
|
# 5. 在 run 之外调用被拒绝
|
|
@pytest.mark.asyncio
|
|
async def test_call_outside_run_is_rejected(test_env):
|
|
exec_context_outside_run = ExecutionAPIContext(
|
|
binding=test_env.binding,
|
|
run_id=None,
|
|
claimed_identity='langbot-team/LocalAgent',
|
|
)
|
|
|
|
with pytest.raises(ApplicationAPIException) as exc_info:
|
|
await test_env.exec_api.invoke('execution.list_application_operations', exec_context_outside_run, {})
|
|
assert exc_info.value.error.code == 'permission_denied'
|
|
|
|
with pytest.raises(ApplicationAPIException) as exc_info2:
|
|
await test_env.exec_api.invoke(
|
|
'execution.call_application_operation',
|
|
exec_context_outside_run,
|
|
{'operation_id': 'bot.list', 'arguments': {}},
|
|
)
|
|
assert exc_info2.value.error.code == 'permission_denied'
|
|
|
|
|
|
# 6. 跨工作区的 run 被拒绝
|
|
@pytest.mark.asyncio
|
|
async def test_cross_workspace_run_is_rejected(test_env):
|
|
run_id = 'run-cross-ws'
|
|
registry = get_session_registry()
|
|
await registry.register(
|
|
run_id=run_id,
|
|
runner_id='plugin:langbot-team/LocalAgent/default',
|
|
query_id=None,
|
|
plugin_identity='langbot-team/LocalAgent',
|
|
resources={
|
|
'models': [],
|
|
'tools': [],
|
|
'knowledge_bases': [],
|
|
'skills': [],
|
|
'storage': {},
|
|
'platform_capabilities': {},
|
|
},
|
|
workspace_id='workspace-OTHER', # Different workspace
|
|
)
|
|
|
|
# Inbound context belongs to workspace-a
|
|
exec_context = ExecutionAPIContext(
|
|
binding=test_env.binding, # workspace-a
|
|
run_id=run_id,
|
|
claimed_identity='langbot-team/LocalAgent',
|
|
)
|
|
|
|
with pytest.raises(ApplicationAPIException) as exc_info:
|
|
await test_env.exec_api.invoke('execution.list_application_operations', exec_context, {})
|
|
assert exc_info.value.error.code == 'permission_denied'
|
|
assert 'does not belong to this installation Workspace' in exc_info.value.error.message
|
|
|
|
|
|
# 7. 运行期间修改配置,不影响本次运行
|
|
@pytest.mark.asyncio
|
|
async def test_runtime_config_change_does_not_affect_active_run(test_env):
|
|
run_id = 'run-frozen-test'
|
|
ops_initial = ['bot.list']
|
|
|
|
builder = AgentResourceBuilder(test_env.host)
|
|
event = make_event(event_id=run_id)
|
|
binding = AgentBinding(
|
|
binding_id='bind-1',
|
|
runner_id='plugin:langbot-team/LocalAgent/default',
|
|
processor_type='agent',
|
|
processor_id='agent-1',
|
|
resource_policy=ResourcePolicy(allowed_workspace_operations=ops_initial),
|
|
)
|
|
descriptor = make_local_agent_descriptor()
|
|
ctx = ExecutionContext(instance_uuid='instance-a', workspace_uuid='workspace-a', placement_generation=1)
|
|
|
|
# Build resources at start of run
|
|
resources = await builder.build_resources_from_binding(ctx, event, binding, descriptor)
|
|
|
|
registry = get_session_registry()
|
|
await registry.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-1',
|
|
)
|
|
|
|
# Now modify the binding's resource policy during runtime (simulate config change)
|
|
binding.resource_policy.allowed_workspace_operations = ['bot.list', 'bot.delete']
|
|
|
|
exec_context = ExecutionAPIContext(
|
|
binding=test_env.binding,
|
|
run_id=run_id,
|
|
claimed_identity='langbot-team/LocalAgent',
|
|
)
|
|
|
|
# list_application_operations still returns only the frozen 1 operation
|
|
list_res = await test_env.exec_api.invoke('execution.list_application_operations', exec_context, {})
|
|
assert len(list_res['operations']) == 1
|
|
assert list_res['operations'][0]['id'] == 'bot.list'
|
|
|
|
# bot.delete is still not authorized in this run
|
|
call_res = await test_env.exec_api.invoke(
|
|
'execution.call_application_operation',
|
|
exec_context,
|
|
{'operation_id': 'bot.delete', 'arguments': {'bot_uuid': 'b-1'}},
|
|
)
|
|
assert call_res['ok'] is False
|
|
assert call_res['error']['code'] == 'not_found'
|
|
|
|
|
|
# 8. tools 列表中没有任何工作区操作
|
|
@pytest.mark.asyncio
|
|
async def test_tools_list_never_contains_workspace_operations(test_env):
|
|
builder = AgentResourceBuilder(test_env.host)
|
|
event = make_event()
|
|
binding = AgentBinding(
|
|
binding_id='bind-1',
|
|
runner_id='plugin:langbot-team/LocalAgent/default',
|
|
processor_type='agent',
|
|
processor_id='agent-1',
|
|
resource_policy=ResourcePolicy(
|
|
allowed_tool_names=['echo_tool'],
|
|
allow_all_tools=False,
|
|
allowed_workspace_operations=['bot.list', 'bot.create', 'adapter.list'],
|
|
),
|
|
)
|
|
descriptor = make_local_agent_descriptor()
|
|
ctx = ExecutionContext(instance_uuid='instance-a', workspace_uuid='workspace-a', placement_generation=1)
|
|
|
|
resources = await builder.build_resources_from_binding(ctx, event, binding, descriptor)
|
|
|
|
tool_names = [t.get('tool_name') for t in resources.get('tools', [])]
|
|
for ws_op in ('bot.list', 'bot.create', 'adapter.list'):
|
|
assert ws_op not in tool_names, f'Workspace operation {ws_op} leaked into tools!'
|