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:
RockChinQ
2026-10-19 23:07:11 +08:00
committed by GitHub
co-authored by multica-agent
parent 5a6ddbff6f
commit d9e4e7d9df
24 changed files with 1012 additions and 35 deletions
+11 -10
View File
@@ -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'}}}},
],
},