mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-24 01:15:24 +08:00
fix(pipeline): decouple Runner from extension permissions (LANG-54) (#2723)
* fix(pipeline): decouple runner from extension allowlist Co-authored-by: multica-agent <[email protected]> * fix(pipeline): complete runner diagnostics and real runtime coverage Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
co-authored by
multica-agent
parent
5a6ddbff6f
commit
d9e4e7d9df
@@ -8,7 +8,7 @@ import pytest
|
||||
|
||||
from langbot.pkg.agent.runner.registry import RunnerRegistry
|
||||
from langbot.pkg.agent.runner.descriptor import RunnerDescriptor
|
||||
from langbot.pkg.agent.runner.errors import RunnerNotFoundError, RunnerNotAuthorizedError
|
||||
from langbot.pkg.agent.runner.errors import RunnerNotFoundError
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
|
||||
|
||||
@@ -242,8 +242,9 @@ class TestRegistryGet:
|
||||
assert ap.plugin_connector.list_runners.await_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_runner_with_bound_plugins_filter(self):
|
||||
"""Get runner with bound plugins authorization."""
|
||||
@pytest.mark.parametrize('plugins', [None, [], ['langbot-team/LocalAgent']])
|
||||
async def test_get_runner_independent_of_extension_filter(self, plugins):
|
||||
"""Selecting a Runner must not grant or require extension authorization."""
|
||||
ap = FakeApplication()
|
||||
registry = RunnerRegistry(ap)
|
||||
|
||||
@@ -255,13 +256,13 @@ class TestRegistryGet:
|
||||
)
|
||||
assert descriptor is not None
|
||||
|
||||
# Not authorized - plugin not in bound list
|
||||
with pytest.raises(RunnerNotAuthorizedError):
|
||||
await registry.get(
|
||||
TEST_CONTEXT,
|
||||
'plugin:alice/my-agent/custom',
|
||||
bound_plugins=['langbot-team/LocalAgent'],
|
||||
)
|
||||
descriptor = await registry.get(
|
||||
TEST_CONTEXT,
|
||||
'plugin:alice/my-agent/custom',
|
||||
bound_plugins=plugins,
|
||||
)
|
||||
assert descriptor.id == 'plugin:alice/my-agent/custom'
|
||||
assert plugins in (None, [], ['langbot-team/LocalAgent'])
|
||||
|
||||
|
||||
class TestRegistryMetadataForPipeline:
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import ast
|
||||
|
||||
import yaml
|
||||
|
||||
from langbot_plugin.api.entities.builtin.runner.manifest import RunnerManifest
|
||||
from langbot_plugin.api.definition.components.manifest import ComponentManifest
|
||||
|
||||
from tests.e2e.test_runner_plugin_runtime import _write_qa_runner_plugin
|
||||
|
||||
|
||||
def test_real_runtime_fixture_contains_all_four_component_kinds(tmp_path):
|
||||
_write_qa_runner_plugin(tmp_path)
|
||||
manifest = yaml.safe_load((tmp_path / 'manifest.yaml').read_text())
|
||||
assert set(manifest['spec']['components']) == {'Runner', 'Tool', 'Command', 'EventListener'}
|
||||
kinds = set()
|
||||
for path in (tmp_path / 'components').rglob('*.yaml'):
|
||||
raw = yaml.safe_load(path.read_text())
|
||||
component = ComponentManifest(
|
||||
owner='e2e/agent-runner-qa', manifest=raw, rel_path=str(path.relative_to(tmp_path))
|
||||
)
|
||||
kinds.add(component.kind)
|
||||
if component.kind == 'Runner':
|
||||
spec = raw['spec']
|
||||
runner = RunnerManifest(
|
||||
id=f'plugin:e2e/agent-runner-qa/{raw["metadata"]["name"]}',
|
||||
name=raw['metadata']['name'],
|
||||
label=raw['metadata']['label'],
|
||||
usages=spec['usages'],
|
||||
supported_event_patterns=spec.get('events') or ['*'],
|
||||
capabilities=spec.get('capabilities') or {},
|
||||
permissions=spec.get('permissions') or {},
|
||||
config_schema=spec.get('config') or [],
|
||||
)
|
||||
if path.stem == 'default':
|
||||
assert runner.capabilities.tool_calling is True
|
||||
assert set(runner.permissions.tools) == {'detail', 'call'}
|
||||
assert kinds == {'Runner', 'Tool', 'Command', 'EventListener'}
|
||||
for path in tmp_path.rglob('*.py'):
|
||||
ast.parse(path.read_text(), filename=str(path))
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Runner selection must survive restricted extension preferences on disk."""
|
||||
|
||||
from types import SimpleNamespace as NS
|
||||
import json
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
|
||||
from langbot.pkg.api.http.service.pipeline import PipelineService
|
||||
from langbot.pkg.entity.persistence.pipeline import LegacyPipeline
|
||||
from langbot.pkg.workspace.errors import WorkspaceNotFoundError
|
||||
from tests.unit_tests.api.service.test_pipeline_migration import env, context, RID # noqa: F401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_and_mcp_share_real_persistence_without_expanding_extensions(env, monkeypatch): # noqa: F811
|
||||
from quart import Quart
|
||||
from langbot.pkg.api.http.controller.groups.pipelines.pipelines import PipelinesRouterGroup
|
||||
from langbot.pkg.api.mcp.server import LangBotMCPServer
|
||||
|
||||
monkeypatch.setattr('langbot.pkg.api.http.service.workspace_limits.workspace_limits', AsyncMock(return_value={}))
|
||||
monkeypatch.setattr('langbot.pkg.api.mcp.server.get_request_context', context)
|
||||
monkeypatch.setattr('langbot.pkg.api.mcp.server.get_workspace_language', AsyncMock(return_value='en_US'))
|
||||
monkeypatch.setattr('langbot.pkg.api.http.controller.group.get_workspace_language', AsyncMock(return_value='en_US'))
|
||||
env.ap.ver_mgr = NS(get_current_version=Mock(return_value='4.10'))
|
||||
env.ap.pipeline_mgr.load_pipeline = AsyncMock()
|
||||
env.ap.pipeline_mgr.remove_pipeline = AsyncMock()
|
||||
env.ap.pipeline_service = PipelineService(env.ap)
|
||||
app = Quart(__name__)
|
||||
routes = PipelinesRouterGroup(env.ap, app)
|
||||
monkeypatch.setattr(routes, '_authenticate_api_key', AsyncMock(return_value=context()))
|
||||
monkeypatch.setattr(routes, '_record_operation', AsyncMock())
|
||||
await routes.initialize()
|
||||
http = app.test_client()
|
||||
mcp = LangBotMCPServer(env.ap)
|
||||
headers = {'X-API-Key': 'isolated-test-key'}
|
||||
config = {
|
||||
'ai': {
|
||||
'runner': {'id': RID, 'expire-time': 0},
|
||||
'runner_config': {RID: {'unknown': {'keep': [None, False, ' raw ']}}},
|
||||
}
|
||||
}
|
||||
prefs = {'enable_all_plugins': False, 'plugins': [], 'enable_all_mcp_servers': False, 'mcp_servers': []}
|
||||
async with env.engine.begin() as conn:
|
||||
await conn.execute(
|
||||
sa.update(LegacyPipeline)
|
||||
.where(LegacyPipeline.uuid == 'one')
|
||||
.values(config=config, extensions_preferences=prefs)
|
||||
)
|
||||
response = await http.put('/api/v1/pipelines/one', headers=headers, json={'config': config})
|
||||
assert response.status_code == 200, await response.get_data(as_text=True)
|
||||
assert (await response.get_json())['code'] == 0
|
||||
result = await mcp.mcp.call_tool('get_pipeline', {'pipeline_uuid': 'one'})
|
||||
saved = json.loads(result[0][0].text)
|
||||
assert saved['config'] == config
|
||||
assert saved['extensions_preferences'] == prefs
|
||||
config['ai']['runner_config'][RID]['edited'] = 'via MCP'
|
||||
result = await mcp.mcp.call_tool('update_pipeline', {'pipeline_uuid': 'one', 'pipeline_data': {'config': config}})
|
||||
assert json.loads(result[0][0].text) == {'ok': True}
|
||||
copied_response = await http.post('/api/v1/pipelines/one/copy', headers=headers)
|
||||
assert copied_response.status_code == 200, await copied_response.get_data(as_text=True)
|
||||
copied = (await copied_response.get_json())['data']['uuid']
|
||||
for pipeline_id in ('one', copied):
|
||||
response = await http.get(f'/api/v1/pipelines/{pipeline_id}', headers=headers)
|
||||
saved = (await response.get_json())['data']['pipeline']
|
||||
assert saved['config'] == config
|
||||
assert saved['extensions_preferences']['plugins'] == []
|
||||
assert saved['extensions_preferences']['enable_all_plugins'] is False
|
||||
async with env.engine.connect() as conn:
|
||||
row = (
|
||||
await conn.execute(
|
||||
sa.select(LegacyPipeline.config, LegacyPipeline.extensions_preferences).where(
|
||||
LegacyPipeline.uuid == pipeline_id
|
||||
)
|
||||
)
|
||||
).one()
|
||||
assert row.config == config
|
||||
assert row.extensions_preferences['plugins'] == []
|
||||
assert row.extensions_preferences['enable_all_plugins'] is False
|
||||
body = {'name': 'HTTP creation', 'description': '', 'config': config, 'extensions_preferences': prefs}
|
||||
response = await http.post('/api/v1/pipelines', headers=headers, json=body)
|
||||
assert response.status_code == 200, await response.get_data(as_text=True)
|
||||
http_id = (await response.get_json())['data']['uuid']
|
||||
body['name'] = 'MCP creation'
|
||||
result = await mcp.mcp.call_tool('create_pipeline', {'pipeline_data': body})
|
||||
mcp_id = json.loads(result[0][0].text)['uuid']
|
||||
for pipeline_id in (http_id, mcp_id):
|
||||
saved = await env.ap.pipeline_service.get_pipeline(context(), pipeline_id, include_secret=True)
|
||||
assert saved['config'] == config
|
||||
assert saved['extensions_preferences'] == prefs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_and_copy_do_not_expand_extension_permissions(env, monkeypatch): # noqa: F811
|
||||
monkeypatch.setattr(
|
||||
'langbot.pkg.api.http.service.workspace_limits.workspace_limits',
|
||||
AsyncMock(return_value={}),
|
||||
)
|
||||
env.ap.ver_mgr = NS(get_current_version=Mock(return_value='4.10'))
|
||||
env.ap.pipeline_mgr.load_pipeline = AsyncMock()
|
||||
env.ap.pipeline_mgr.remove_pipeline = AsyncMock()
|
||||
service = PipelineService(env.ap)
|
||||
config = {'ai': {'runner': {'id': RID}, 'runner_config': {RID: {}}}}
|
||||
prefs = {'enable_all_plugins': False, 'plugins': [], 'enable_all_mcp_servers': False, 'mcp_servers': []}
|
||||
# Existing, pre-fix configuration: selected Runner with no extensions.
|
||||
async with env.engine.begin() as conn:
|
||||
await conn.execute(
|
||||
sa.update(LegacyPipeline)
|
||||
.where(LegacyPipeline.uuid == 'one')
|
||||
.values(
|
||||
config=config,
|
||||
extensions_preferences=prefs,
|
||||
)
|
||||
)
|
||||
await service.update_pipeline(context(), 'one', {'config': config, 'name': 'Edited'})
|
||||
copied = await service.copy_pipeline(context(), 'one')
|
||||
for pid in ('one', copied):
|
||||
saved = await service.get_pipeline(context(), pid, include_secret=True)
|
||||
assert saved['config'] == config
|
||||
assert saved['extensions_preferences']['plugins'] == []
|
||||
assert saved['extensions_preferences']['enable_all_plugins'] is False
|
||||
with pytest.raises(WorkspaceNotFoundError):
|
||||
await service.copy_pipeline(context(), 'foreign')
|
||||
@@ -32,7 +32,8 @@ def configure_connector(connector) -> None:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_list_filter_by_component_kinds():
|
||||
@pytest.mark.parametrize('independent_kind', ['KnowledgeEngine', 'Runner'])
|
||||
async def test_plugin_list_filter_by_component_kinds(independent_kind):
|
||||
"""Test that plugins can be filtered by component kinds."""
|
||||
from langbot.pkg.plugin.connector import PluginRuntimeConnector
|
||||
|
||||
@@ -70,7 +71,7 @@ async def test_plugin_list_filter_by_component_kinds():
|
||||
}
|
||||
}
|
||||
},
|
||||
'components': [{'manifest': {'manifest': {'kind': 'KnowledgeEngine', 'metadata': {'name': 'retriever1'}}}}],
|
||||
'components': [{'manifest': {'manifest': {'kind': independent_kind, 'metadata': {'name': 'retriever1'}}}}],
|
||||
},
|
||||
{
|
||||
'debug': False,
|
||||
@@ -107,7 +108,7 @@ async def test_plugin_list_filter_by_component_kinds():
|
||||
}
|
||||
},
|
||||
'components': [
|
||||
{'manifest': {'manifest': {'kind': 'KnowledgeEngine', 'metadata': {'name': 'retriever2'}}}},
|
||||
{'manifest': {'manifest': {'kind': independent_kind, 'metadata': {'name': 'retriever2'}}}},
|
||||
{'manifest': {'manifest': {'kind': 'Tool', 'metadata': {'name': 'tool2'}}}},
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user