mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-17 09:56:06 +00:00
feat(agent-runner): enforce 4.x host-owned execution
This commit is contained in:
@@ -39,7 +39,8 @@ def _agent_row(
|
||||
emoji='A',
|
||||
kind=AGENT_KIND_AGENT,
|
||||
component_ref='plugin:test/runner/default',
|
||||
config=config or {
|
||||
config=config
|
||||
or {
|
||||
'runner': {'id': 'plugin:test/runner/default', 'expire-time': 0},
|
||||
'runner_config': {'plugin:test/runner/default': {'temperature': 0.2}},
|
||||
},
|
||||
@@ -71,11 +72,7 @@ def _compiled_params(statement):
|
||||
|
||||
|
||||
def _compiled_update_values(statement):
|
||||
return {
|
||||
key: value
|
||||
for key, value in statement.compile().params.items()
|
||||
if not key.startswith('uuid_')
|
||||
}
|
||||
return {key: value for key, value in statement.compile().params.items() if not key.startswith('uuid_')}
|
||||
|
||||
|
||||
def _make_app():
|
||||
@@ -224,6 +221,7 @@ class TestAgentServiceCreateUpdateDelete:
|
||||
'name': 'Support Agent',
|
||||
'description': 'Handles support events',
|
||||
'emoji': 'S',
|
||||
'component_ref': 'plugin:caller/must-not-win/default',
|
||||
}
|
||||
)
|
||||
|
||||
@@ -240,6 +238,122 @@ class TestAgentServiceCreateUpdateDelete:
|
||||
assert insert_values['supported_event_patterns'] == AGENT_DEFAULT_EVENT_PATTERNS
|
||||
app.pipeline_service._get_default_values_from_schema.assert_called_once_with(runner.config_schema)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'config',
|
||||
[
|
||||
None,
|
||||
[],
|
||||
{'runner': {'id': 'plugin:test/runner/default'}},
|
||||
{'runner': {'id': 123}, 'runner_config': {}},
|
||||
{
|
||||
'runner': {'id': 'plugin:test/runner/default'},
|
||||
'runner_config': {'plugin:test/runner/default': ['invalid']},
|
||||
},
|
||||
{
|
||||
'runner': {'id': 'plugin:test/runner/default'},
|
||||
'runner_config': {},
|
||||
},
|
||||
],
|
||||
)
|
||||
async def test_create_agent_rejects_malformed_4x_runner_config(self, config):
|
||||
app = _make_app()
|
||||
|
||||
with pytest.raises(ValueError, match='Agent config|runner_config'):
|
||||
await AgentService(app).create_agent({'name': 'Invalid Agent', 'config': config})
|
||||
|
||||
app.persistence_mgr.execute_async.assert_not_awaited()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('field_name', 'invalid_value'),
|
||||
[
|
||||
('enable-all-tools', 0),
|
||||
('enable-all-tools', None),
|
||||
('enable-all-tools', 'false'),
|
||||
('mcp-resource-agent-read-enabled', 0),
|
||||
('mcp-resource-agent-read-enabled', None),
|
||||
('mcp-resource-agent-read-enabled', 'false'),
|
||||
],
|
||||
)
|
||||
async def test_create_agent_rejects_non_boolean_security_fields_before_write(
|
||||
self,
|
||||
field_name,
|
||||
invalid_value,
|
||||
):
|
||||
app = _make_app()
|
||||
runner_id = 'plugin:test/runner/default'
|
||||
|
||||
with pytest.raises(ValueError, match=f'{field_name}.*boolean'):
|
||||
await AgentService(app).create_agent(
|
||||
{
|
||||
'name': 'Invalid Agent',
|
||||
'config': {
|
||||
'runner': {'id': runner_id},
|
||||
'runner_config': {runner_id: {field_name: invalid_value}},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
app.persistence_mgr.execute_async.assert_not_awaited()
|
||||
|
||||
@pytest.mark.parametrize('invalid_value', [0, None, 'false'])
|
||||
async def test_create_agent_rejects_non_boolean_mcp_resource_enabled_before_write(self, invalid_value):
|
||||
app = _make_app()
|
||||
runner_id = 'plugin:test/runner/default'
|
||||
|
||||
with pytest.raises(ValueError, match=r'mcp-resources\[0\]\.enabled.*boolean'):
|
||||
await AgentService(app).create_agent(
|
||||
{
|
||||
'name': 'Invalid Agent',
|
||||
'config': {
|
||||
'runner': {'id': runner_id},
|
||||
'runner_config': {
|
||||
runner_id: {
|
||||
'mcp-resources': [
|
||||
{'uri': 'file:///README.md', 'enabled': invalid_value},
|
||||
]
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
app.persistence_mgr.execute_async.assert_not_awaited()
|
||||
|
||||
async def test_create_agent_derives_empty_component_ref_from_empty_runner(self):
|
||||
app = _make_app()
|
||||
app.persistence_mgr.execute_async = AsyncMock(return_value=Mock())
|
||||
|
||||
await AgentService(app).create_agent(
|
||||
{
|
||||
'name': 'Unconfigured Agent',
|
||||
'component_ref': 'plugin:caller/must-not-win/default',
|
||||
'config': {
|
||||
'runner': {'id': ''},
|
||||
'runner_config': {},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
insert_values = _compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
|
||||
assert insert_values['component_ref'] is None
|
||||
|
||||
async def test_update_agent_rejects_malformed_4x_runner_config_before_write(self):
|
||||
app = _make_app()
|
||||
app.persistence_mgr.execute_async = AsyncMock(return_value=_result(first_item=_agent_row(agent_uuid='agent-1')))
|
||||
|
||||
with pytest.raises(ValueError, match='runner_config'):
|
||||
await AgentService(app).update_agent(
|
||||
'agent-1',
|
||||
{
|
||||
'config': {
|
||||
'runner': {'id': 'plugin:test/runner/default'},
|
||||
'runner_config': {'plugin:test/runner/default': 'invalid'},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
assert app.persistence_mgr.execute_async.await_count == 1
|
||||
|
||||
async def test_update_agent_protects_immutable_fields_and_recalculates_component_ref(self):
|
||||
app = _make_app()
|
||||
app.persistence_mgr.execute_async = AsyncMock(
|
||||
@@ -261,6 +375,7 @@ class TestAgentServiceCreateUpdateDelete:
|
||||
'created_at': '2020-01-01T00:00:00',
|
||||
'updated_at': '2020-01-01T00:00:00',
|
||||
'capability': {'message_only': True},
|
||||
'component_ref': 'plugin:caller/must-not-win/default',
|
||||
'name': 'Updated Agent',
|
||||
'config': new_config,
|
||||
'supported_event_patterns': [],
|
||||
@@ -275,6 +390,67 @@ class TestAgentServiceCreateUpdateDelete:
|
||||
'component_ref': 'plugin:test/new-runner/default',
|
||||
}
|
||||
|
||||
async def test_update_agent_ignores_component_ref_without_config(self):
|
||||
app = _make_app()
|
||||
app.persistence_mgr.execute_async = AsyncMock(
|
||||
side_effect=[
|
||||
_result(first_item=_agent_row(agent_uuid='agent-1')),
|
||||
Mock(),
|
||||
]
|
||||
)
|
||||
|
||||
await AgentService(app).update_agent(
|
||||
'agent-1',
|
||||
{
|
||||
'name': 'Updated Agent',
|
||||
'component_ref': 'plugin:caller/must-not-win/default',
|
||||
},
|
||||
)
|
||||
|
||||
update_values = _compiled_update_values(app.persistence_mgr.execute_async.await_args_list[1].args[0])
|
||||
assert update_values == {
|
||||
'name': 'Updated Agent',
|
||||
'component_ref': 'plugin:test/runner/default',
|
||||
}
|
||||
|
||||
async def test_update_agent_component_ref_only_repairs_from_existing_config(self):
|
||||
app = _make_app()
|
||||
app.persistence_mgr.execute_async = AsyncMock(
|
||||
side_effect=[
|
||||
_result(first_item=_agent_row(agent_uuid='agent-1')),
|
||||
Mock(),
|
||||
]
|
||||
)
|
||||
|
||||
await AgentService(app).update_agent(
|
||||
'agent-1',
|
||||
{'component_ref': 'plugin:caller/must-not-win/default'},
|
||||
)
|
||||
|
||||
update_values = _compiled_update_values(app.persistence_mgr.execute_async.await_args_list[1].args[0])
|
||||
assert update_values == {'component_ref': 'plugin:test/runner/default'}
|
||||
|
||||
async def test_update_agent_clears_component_ref_for_empty_runner(self):
|
||||
app = _make_app()
|
||||
app.persistence_mgr.execute_async = AsyncMock(
|
||||
side_effect=[
|
||||
_result(first_item=_agent_row(agent_uuid='agent-1')),
|
||||
Mock(),
|
||||
]
|
||||
)
|
||||
config = {'runner': {'id': ''}, 'runner_config': {}}
|
||||
|
||||
await AgentService(app).update_agent(
|
||||
'agent-1',
|
||||
{
|
||||
'component_ref': 'plugin:caller/must-not-win/default',
|
||||
'config': config,
|
||||
},
|
||||
)
|
||||
|
||||
update_values = _compiled_update_values(app.persistence_mgr.execute_async.await_args_list[1].args[0])
|
||||
assert update_values == {'config': config, 'component_ref': None}
|
||||
|
||||
async def test_pipeline_kind_create_update_delete_delegate_to_pipeline_service(self):
|
||||
app = _make_app()
|
||||
app.persistence_mgr.execute_async = AsyncMock(return_value=_result(first_item=None))
|
||||
|
||||
@@ -231,6 +231,63 @@ class TestPipelineServiceCreatePipeline:
|
||||
with pytest.raises(ValueError, match='Maximum number of pipelines'):
|
||||
await service.create_pipeline({'name': 'New Pipeline'})
|
||||
|
||||
@pytest.mark.parametrize('invalid_preferences', [None, [], 'all', 0, False])
|
||||
async def test_create_pipeline_rejects_non_object_extension_preferences(
|
||||
self,
|
||||
invalid_preferences,
|
||||
):
|
||||
service = PipelineService(SimpleNamespace())
|
||||
|
||||
with pytest.raises(ValueError, match='extensions_preferences must be an object'):
|
||||
await service.create_pipeline(
|
||||
{
|
||||
'name': 'Invalid Pipeline',
|
||||
'extensions_preferences': invalid_preferences,
|
||||
}
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize('invalid_value', [0, None, 'false'])
|
||||
async def test_create_pipeline_rejects_non_boolean_runner_security_field(self, invalid_value):
|
||||
service = PipelineService(SimpleNamespace())
|
||||
runner_id = 'plugin:test/runner/default'
|
||||
|
||||
with pytest.raises(ValueError, match='enable-all-tools.*boolean'):
|
||||
await service.create_pipeline(
|
||||
{
|
||||
'name': 'Invalid Pipeline',
|
||||
'config': {
|
||||
'ai': {
|
||||
'runner': {'id': runner_id},
|
||||
'runner_config': {runner_id: {'enable-all-tools': invalid_value}},
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize('invalid_value', [0, None, 'false'])
|
||||
async def test_create_pipeline_rejects_non_boolean_mcp_resource_enabled(self, invalid_value):
|
||||
service = PipelineService(SimpleNamespace())
|
||||
runner_id = 'plugin:test/runner/default'
|
||||
|
||||
with pytest.raises(ValueError, match=r'mcp-resources\[0\]\.enabled.*boolean'):
|
||||
await service.create_pipeline(
|
||||
{
|
||||
'name': 'Invalid Pipeline',
|
||||
'config': {
|
||||
'ai': {
|
||||
'runner': {'id': runner_id},
|
||||
'runner_config': {
|
||||
runner_id: {
|
||||
'mcp-resources': [
|
||||
{'uri': 'file:///README.md', 'enabled': invalid_value},
|
||||
]
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
async def test_create_pipeline_no_limit(self):
|
||||
"""Creates pipeline without limit when max_pipelines=-1."""
|
||||
# Setup
|
||||
@@ -387,6 +444,43 @@ class TestPipelineServiceUpdatePipeline:
|
||||
assert ['should-be-removed'] not in update_params.values()
|
||||
assert not any(value is True for value in update_params.values())
|
||||
|
||||
@pytest.mark.parametrize('invalid_preferences', [None, [], 'all', 0, False])
|
||||
async def test_update_pipeline_rejects_non_object_extension_preferences_before_write(
|
||||
self,
|
||||
invalid_preferences,
|
||||
):
|
||||
ap = SimpleNamespace(persistence_mgr=SimpleNamespace(execute_async=AsyncMock()))
|
||||
service = PipelineService(ap)
|
||||
|
||||
with pytest.raises(ValueError, match='extensions_preferences must be an object'):
|
||||
await service.update_pipeline(
|
||||
'test-uuid',
|
||||
{'extensions_preferences': invalid_preferences},
|
||||
)
|
||||
|
||||
ap.persistence_mgr.execute_async.assert_not_awaited()
|
||||
|
||||
@pytest.mark.parametrize('invalid_value', [0, None, 'false'])
|
||||
async def test_update_pipeline_rejects_non_boolean_runner_security_field_before_write(self, invalid_value):
|
||||
ap = SimpleNamespace(persistence_mgr=SimpleNamespace(execute_async=AsyncMock()))
|
||||
service = PipelineService(ap)
|
||||
runner_id = 'plugin:test/runner/default'
|
||||
|
||||
with pytest.raises(ValueError, match='mcp-resource-agent-read-enabled.*boolean'):
|
||||
await service.update_pipeline(
|
||||
'test-uuid',
|
||||
{
|
||||
'config': {
|
||||
'ai': {
|
||||
'runner': {'id': runner_id},
|
||||
'runner_config': {runner_id: {'mcp-resource-agent-read-enabled': invalid_value}},
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
ap.persistence_mgr.execute_async.assert_not_awaited()
|
||||
|
||||
async def test_update_pipeline_name_does_not_rewrite_bot_routes(self):
|
||||
"""Bot event bindings remain independent from pipeline display names."""
|
||||
# Setup
|
||||
@@ -624,6 +718,98 @@ class TestPipelineServiceUpdatePipelineExtensions:
|
||||
with pytest.raises(ValueError, match='Pipeline nonexistent-uuid not found'):
|
||||
await service.update_pipeline_extensions('nonexistent-uuid', [])
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('field', 'invalid_value'),
|
||||
[
|
||||
('bound_plugins', 'author/plugin'),
|
||||
('bound_plugins', [{'author': 'author'}]),
|
||||
('bound_mcp_servers', 'server-1'),
|
||||
('bound_mcp_servers', ['server-1', 2]),
|
||||
('bound_skills', 'skill-1'),
|
||||
('bound_skills', ['skill-1', None]),
|
||||
('bound_mcp_resources', {'uri': 'file:///README.md'}),
|
||||
('bound_mcp_resources', [{'uri': 'file:///README.md'}, 'bad']),
|
||||
],
|
||||
)
|
||||
async def test_update_extensions_rejects_malformed_binding_lists_before_query(
|
||||
self,
|
||||
field,
|
||||
invalid_value,
|
||||
):
|
||||
ap = SimpleNamespace(persistence_mgr=SimpleNamespace(execute_async=AsyncMock()))
|
||||
service = PipelineService(ap)
|
||||
kwargs = {field: invalid_value}
|
||||
if field != 'bound_plugins':
|
||||
kwargs['bound_plugins'] = []
|
||||
|
||||
with pytest.raises(ValueError, match=field):
|
||||
await service.update_pipeline_extensions('test-uuid', **kwargs)
|
||||
|
||||
ap.persistence_mgr.execute_async.assert_not_awaited()
|
||||
|
||||
@pytest.mark.parametrize('invalid_value', [0, 'false'])
|
||||
async def test_update_extensions_rejects_non_boolean_resource_read_before_query(self, invalid_value):
|
||||
ap = SimpleNamespace(persistence_mgr=SimpleNamespace(execute_async=AsyncMock()))
|
||||
service = PipelineService(ap)
|
||||
|
||||
with pytest.raises(ValueError, match='mcp_resource_agent_read_enabled.*boolean'):
|
||||
await service.update_pipeline_extensions(
|
||||
'test-uuid',
|
||||
[],
|
||||
mcp_resource_agent_read_enabled=invalid_value,
|
||||
)
|
||||
|
||||
ap.persistence_mgr.execute_async.assert_not_awaited()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'field',
|
||||
[
|
||||
'enable_all_plugins',
|
||||
'enable_all_mcp_servers',
|
||||
'enable_all_skills',
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize('invalid_value', [0, None, 'false'])
|
||||
async def test_update_extensions_rejects_non_boolean_enable_all_flags_before_query(
|
||||
self,
|
||||
field,
|
||||
invalid_value,
|
||||
):
|
||||
ap = SimpleNamespace(persistence_mgr=SimpleNamespace(execute_async=AsyncMock()))
|
||||
service = PipelineService(ap)
|
||||
|
||||
with pytest.raises(ValueError, match=rf'{field}.*boolean'):
|
||||
await service.update_pipeline_extensions(
|
||||
'test-uuid',
|
||||
[],
|
||||
**{field: invalid_value},
|
||||
)
|
||||
|
||||
ap.persistence_mgr.execute_async.assert_not_awaited()
|
||||
|
||||
@pytest.mark.parametrize('invalid_value', [0, None, 'false'])
|
||||
async def test_update_extensions_rejects_non_boolean_attachment_enabled_before_query(
|
||||
self,
|
||||
invalid_value,
|
||||
):
|
||||
ap = SimpleNamespace(persistence_mgr=SimpleNamespace(execute_async=AsyncMock()))
|
||||
service = PipelineService(ap)
|
||||
|
||||
with pytest.raises(ValueError, match=r'bound_mcp_resources.*enabled.*boolean'):
|
||||
await service.update_pipeline_extensions(
|
||||
'test-uuid',
|
||||
[],
|
||||
bound_mcp_resources=[
|
||||
{
|
||||
'server_uuid': 'server-1',
|
||||
'uri': 'file:///README.md',
|
||||
'enabled': invalid_value,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
ap.persistence_mgr.execute_async.assert_not_awaited()
|
||||
|
||||
async def test_update_extensions_sets_plugins(self):
|
||||
"""Updates plugins in extensions_preferences."""
|
||||
# Setup
|
||||
@@ -667,7 +853,7 @@ class TestPipelineServiceUpdatePipelineExtensions:
|
||||
)
|
||||
|
||||
# Execute
|
||||
bound_plugins = [{'plugin_uuid': 'plugin-1'}]
|
||||
bound_plugins = [{'author': 'test', 'name': 'plugin-1'}]
|
||||
await service.update_pipeline_extensions(
|
||||
'test-uuid',
|
||||
bound_plugins=bound_plugins,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
from importlib import import_module
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
import quart
|
||||
|
||||
core_app_module = types.ModuleType('langbot.pkg.core.app')
|
||||
core_app_module.Application = object
|
||||
sys.modules.setdefault('langbot.pkg.core.app', core_app_module)
|
||||
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
async def _create_test_client(agent_service: SimpleNamespace):
|
||||
app = quart.Quart(__name__)
|
||||
user_service = SimpleNamespace(
|
||||
verify_jwt_token=AsyncMock(return_value='test@example.com'),
|
||||
get_user_by_email=AsyncMock(return_value=SimpleNamespace(user='test@example.com')),
|
||||
)
|
||||
ap = SimpleNamespace(agent_service=agent_service, user_service=user_service)
|
||||
AgentsRouterGroup = import_module('langbot.pkg.api.http.controller.groups.agents').AgentsRouterGroup
|
||||
group = AgentsRouterGroup(ap, app)
|
||||
await group.initialize()
|
||||
return app.test_client()
|
||||
|
||||
|
||||
async def test_create_agent_returns_bad_request_for_invalid_runner_config():
|
||||
message = 'agent config runner_config must be an object'
|
||||
agent_service = SimpleNamespace(create_agent=AsyncMock(side_effect=ValueError(message)))
|
||||
client = await _create_test_client(agent_service)
|
||||
|
||||
response = await client.post(
|
||||
'/api/v1/agents',
|
||||
json={'name': 'Invalid Agent', 'config': {'runner_config': []}},
|
||||
headers={'Authorization': 'Bearer test-token'},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert await response.get_json() == {'code': -1, 'msg': message}
|
||||
agent_service.create_agent.assert_awaited_once_with(
|
||||
{'name': 'Invalid Agent', 'config': {'runner_config': []}},
|
||||
)
|
||||
|
||||
|
||||
async def test_update_agent_returns_bad_request_for_invalid_runner_config():
|
||||
message = 'agent config runner.id must be a string'
|
||||
agent_service = SimpleNamespace(update_agent=AsyncMock(side_effect=ValueError(message)))
|
||||
client = await _create_test_client(agent_service)
|
||||
|
||||
response = await client.put(
|
||||
'/api/v1/agents/agent-1',
|
||||
json={'config': {'runner': {'id': 7}}},
|
||||
headers={'Authorization': 'Bearer test-token'},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert await response.get_json() == {'code': -1, 'msg': message}
|
||||
agent_service.update_agent.assert_awaited_once_with(
|
||||
'agent-1',
|
||||
{'config': {'runner': {'id': 7}}},
|
||||
)
|
||||
@@ -0,0 +1,213 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
from importlib import import_module
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
import quart
|
||||
|
||||
core_app_module = types.ModuleType('langbot.pkg.core.app')
|
||||
core_app_module.Application = object
|
||||
sys.modules.setdefault('langbot.pkg.core.app', core_app_module)
|
||||
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
async def _create_test_client(pipeline_service: SimpleNamespace, **extra_ap):
|
||||
app = quart.Quart(__name__)
|
||||
user_service = SimpleNamespace(
|
||||
verify_jwt_token=AsyncMock(return_value='test@example.com'),
|
||||
get_user_by_email=AsyncMock(return_value=SimpleNamespace(user='test@example.com')),
|
||||
)
|
||||
ap = SimpleNamespace(pipeline_service=pipeline_service, user_service=user_service, **extra_ap)
|
||||
router_class = import_module('langbot.pkg.api.http.controller.groups.pipelines.pipelines').PipelinesRouterGroup
|
||||
group = router_class(ap, app)
|
||||
await group.initialize()
|
||||
return app.test_client()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('method', 'path', 'service_method'),
|
||||
[
|
||||
('post', '/api/v1/pipelines', 'create_pipeline'),
|
||||
('put', '/api/v1/pipelines/pipeline-1', 'update_pipeline'),
|
||||
],
|
||||
)
|
||||
async def test_pipeline_writes_return_bad_request_for_invalid_runner_security_field(
|
||||
method,
|
||||
path,
|
||||
service_method,
|
||||
):
|
||||
message = "Pipeline runner_config['runner-1'] field 'enable-all-tools' must be a boolean"
|
||||
pipeline_service = SimpleNamespace(
|
||||
create_pipeline=AsyncMock(),
|
||||
update_pipeline=AsyncMock(),
|
||||
update_pipeline_extensions=AsyncMock(),
|
||||
)
|
||||
getattr(pipeline_service, service_method).side_effect = ValueError(message)
|
||||
client = await _create_test_client(pipeline_service)
|
||||
|
||||
response = await getattr(client, method)(
|
||||
path,
|
||||
json={'config': {'ai': {'runner': {'id': 'runner-1'}}}},
|
||||
headers={'Authorization': 'Bearer test-token'},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert await response.get_json() == {'code': -1, 'msg': message}
|
||||
|
||||
|
||||
@pytest.mark.parametrize('invalid_value', [0, None, 'false'])
|
||||
async def test_pipeline_extensions_reject_non_boolean_resource_read(invalid_value):
|
||||
pipeline_service = SimpleNamespace(
|
||||
create_pipeline=AsyncMock(),
|
||||
update_pipeline=AsyncMock(),
|
||||
update_pipeline_extensions=AsyncMock(),
|
||||
)
|
||||
client = await _create_test_client(pipeline_service)
|
||||
|
||||
response = await client.put(
|
||||
'/api/v1/pipelines/pipeline-1/extensions',
|
||||
json={'mcp_resource_agent_read_enabled': invalid_value},
|
||||
headers={'Authorization': 'Bearer test-token'},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert await response.get_json() == {
|
||||
'code': -1,
|
||||
'msg': "Pipeline extension field 'mcp_resource_agent_read_enabled' must be a boolean",
|
||||
}
|
||||
pipeline_service.update_pipeline_extensions.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'field',
|
||||
[
|
||||
'enable_all_plugins',
|
||||
'enable_all_mcp_servers',
|
||||
'enable_all_skills',
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize('invalid_value', [0, None, 'false'])
|
||||
async def test_pipeline_extensions_reject_non_boolean_enable_all_flags(field, invalid_value):
|
||||
pipeline_service = SimpleNamespace(
|
||||
create_pipeline=AsyncMock(),
|
||||
update_pipeline=AsyncMock(),
|
||||
update_pipeline_extensions=AsyncMock(),
|
||||
)
|
||||
client = await _create_test_client(pipeline_service)
|
||||
|
||||
response = await client.put(
|
||||
'/api/v1/pipelines/pipeline-1/extensions',
|
||||
json={field: invalid_value},
|
||||
headers={'Authorization': 'Bearer test-token'},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert await response.get_json() == {
|
||||
'code': -1,
|
||||
'msg': f"Pipeline extension field '{field}' must be a boolean",
|
||||
}
|
||||
pipeline_service.update_pipeline_extensions.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('field', 'invalid_value'),
|
||||
[
|
||||
('bound_plugins', 'author/plugin'),
|
||||
('bound_mcp_servers', 'server-1'),
|
||||
('bound_skills', 'skill-1'),
|
||||
('bound_mcp_resources', {'uri': 'file:///README.md'}),
|
||||
],
|
||||
)
|
||||
async def test_pipeline_extensions_return_bad_request_for_malformed_binding_lists(
|
||||
field,
|
||||
invalid_value,
|
||||
):
|
||||
message = f"Pipeline extension field '{field}' must be a list"
|
||||
pipeline_service = SimpleNamespace(
|
||||
create_pipeline=AsyncMock(),
|
||||
update_pipeline=AsyncMock(),
|
||||
update_pipeline_extensions=AsyncMock(side_effect=ValueError(message)),
|
||||
)
|
||||
client = await _create_test_client(pipeline_service)
|
||||
|
||||
response = await client.put(
|
||||
'/api/v1/pipelines/pipeline-1/extensions',
|
||||
json={field: invalid_value},
|
||||
headers={'Authorization': 'Bearer test-token'},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert await response.get_json() == {'code': -1, 'msg': message}
|
||||
|
||||
|
||||
@pytest.mark.parametrize('invalid_value', [0, None, 'false'])
|
||||
async def test_pipeline_extensions_get_normalizes_malformed_enable_all_flags(invalid_value):
|
||||
pipeline_service = SimpleNamespace(
|
||||
get_pipeline=AsyncMock(
|
||||
return_value={
|
||||
'extensions_preferences': {
|
||||
'enable_all_plugins': invalid_value,
|
||||
'enable_all_mcp_servers': invalid_value,
|
||||
'enable_all_skills': invalid_value,
|
||||
'mcp_resource_agent_read_enabled': invalid_value,
|
||||
}
|
||||
}
|
||||
),
|
||||
)
|
||||
client = await _create_test_client(
|
||||
pipeline_service,
|
||||
plugin_connector=SimpleNamespace(list_plugins=AsyncMock(return_value=[])),
|
||||
mcp_service=SimpleNamespace(get_mcp_servers=AsyncMock(return_value=[])),
|
||||
skill_service=SimpleNamespace(list_skills=AsyncMock(return_value=[])),
|
||||
logger=SimpleNamespace(warning=AsyncMock()),
|
||||
)
|
||||
|
||||
response = await client.get(
|
||||
'/api/v1/pipelines/pipeline-1/extensions',
|
||||
headers={'Authorization': 'Bearer test-token'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = await response.get_json()
|
||||
assert payload['data']['enable_all_plugins'] is False
|
||||
assert payload['data']['enable_all_mcp_servers'] is False
|
||||
assert payload['data']['enable_all_skills'] is False
|
||||
assert payload['data']['mcp_resource_agent_read_enabled'] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize('invalid_preferences', [None, [], 'all', 0, False])
|
||||
async def test_pipeline_extensions_get_malformed_root_is_fail_closed(invalid_preferences):
|
||||
pipeline_service = SimpleNamespace(
|
||||
get_pipeline=AsyncMock(
|
||||
return_value={'extensions_preferences': invalid_preferences}
|
||||
),
|
||||
)
|
||||
client = await _create_test_client(
|
||||
pipeline_service,
|
||||
plugin_connector=SimpleNamespace(list_plugins=AsyncMock(return_value=[])),
|
||||
mcp_service=SimpleNamespace(get_mcp_servers=AsyncMock(return_value=[])),
|
||||
skill_service=SimpleNamespace(list_skills=AsyncMock(return_value=[])),
|
||||
logger=SimpleNamespace(warning=AsyncMock()),
|
||||
)
|
||||
|
||||
response = await client.get(
|
||||
'/api/v1/pipelines/pipeline-1/extensions',
|
||||
headers={'Authorization': 'Bearer test-token'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = await response.get_json()
|
||||
assert payload['data']['enable_all_plugins'] is False
|
||||
assert payload['data']['enable_all_mcp_servers'] is False
|
||||
assert payload['data']['enable_all_skills'] is False
|
||||
assert payload['data']['mcp_resource_agent_read_enabled'] is False
|
||||
assert payload['data']['bound_plugins'] == []
|
||||
assert payload['data']['bound_mcp_servers'] == []
|
||||
assert payload['data']['bound_skills'] == []
|
||||
assert payload['data']['bound_mcp_resources'] == []
|
||||
@@ -0,0 +1,301 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
from importlib import import_module
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
import quart
|
||||
|
||||
core_app_module = types.ModuleType('langbot.pkg.core.app')
|
||||
core_app_module.Application = object
|
||||
sys.modules.setdefault('langbot.pkg.core.app', core_app_module)
|
||||
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
async def _create_test_client(tool_mgr: SimpleNamespace, pipeline_service: SimpleNamespace):
|
||||
app = quart.Quart(__name__)
|
||||
user_service = SimpleNamespace(
|
||||
verify_jwt_token=AsyncMock(return_value='test@example.com'),
|
||||
get_user_by_email=AsyncMock(return_value=SimpleNamespace(user='test@example.com')),
|
||||
)
|
||||
ap = SimpleNamespace(
|
||||
tool_mgr=tool_mgr,
|
||||
pipeline_service=pipeline_service,
|
||||
user_service=user_service,
|
||||
)
|
||||
router_class = import_module('langbot.pkg.api.http.controller.groups.resources.tools').ToolsRouterGroup
|
||||
group = router_class(ap, app)
|
||||
await group.initialize()
|
||||
return app.test_client()
|
||||
|
||||
|
||||
async def test_global_tool_selector_uses_unambiguous_host_catalog():
|
||||
tool_mgr = SimpleNamespace(
|
||||
get_resolved_tool_catalog=AsyncMock(return_value=[{'name': 'unique_tool', 'source': 'builtin'}])
|
||||
)
|
||||
pipeline_service = SimpleNamespace(get_pipeline=AsyncMock())
|
||||
client = await _create_test_client(tool_mgr, pipeline_service)
|
||||
|
||||
response = await client.get(
|
||||
'/api/v1/tools',
|
||||
headers={'Authorization': 'Bearer test-token'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = await response.get_json()
|
||||
assert payload['data']['tools'] == [{'name': 'unique_tool', 'source': 'builtin'}]
|
||||
tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with(
|
||||
None,
|
||||
None,
|
||||
include_skill_authoring=True,
|
||||
)
|
||||
pipeline_service.get_pipeline.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_pipeline_tool_selector_resolves_only_bound_sources():
|
||||
tool_mgr = SimpleNamespace(
|
||||
get_resolved_tool_catalog=AsyncMock(
|
||||
return_value=[
|
||||
{
|
||||
'name': 'shared_tool',
|
||||
'source': 'mcp',
|
||||
'source_id': 'bound-mcp',
|
||||
}
|
||||
]
|
||||
)
|
||||
)
|
||||
pipeline_service = SimpleNamespace(
|
||||
get_pipeline=AsyncMock(
|
||||
return_value={
|
||||
'extensions_preferences': {
|
||||
'enable_all_plugins': False,
|
||||
'plugins': [{'author': 'allowed', 'name': 'plugin'}],
|
||||
'enable_all_mcp_servers': False,
|
||||
'mcp_servers': ['bound-mcp'],
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
client = await _create_test_client(tool_mgr, pipeline_service)
|
||||
|
||||
response = await client.get(
|
||||
'/api/v1/tools?pipeline_id=pipeline-1',
|
||||
headers={'Authorization': 'Bearer test-token'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = await response.get_json()
|
||||
assert payload['data']['tools'][0]['source_id'] == 'bound-mcp'
|
||||
pipeline_service.get_pipeline.assert_awaited_once_with('pipeline-1')
|
||||
tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with(
|
||||
['allowed/plugin'],
|
||||
['bound-mcp'],
|
||||
include_skill_authoring=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('invalid_value', [0, None, 'false'])
|
||||
async def test_pipeline_tool_selector_malformed_enable_all_flags_fail_closed(invalid_value):
|
||||
tool_mgr = SimpleNamespace(get_resolved_tool_catalog=AsyncMock(return_value=[]))
|
||||
pipeline_service = SimpleNamespace(
|
||||
get_pipeline=AsyncMock(
|
||||
return_value={
|
||||
'extensions_preferences': {
|
||||
'enable_all_plugins': invalid_value,
|
||||
'plugins': [{'author': 'allowed', 'name': 'plugin'}],
|
||||
'enable_all_mcp_servers': invalid_value,
|
||||
'mcp_servers': ['bound-mcp'],
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
client = await _create_test_client(tool_mgr, pipeline_service)
|
||||
|
||||
response = await client.get(
|
||||
'/api/v1/tools?pipeline_id=pipeline-1',
|
||||
headers={'Authorization': 'Bearer test-token'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with(
|
||||
['allowed/plugin'],
|
||||
['bound-mcp'],
|
||||
include_skill_authoring=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('invalid_preferences', [None, [], 'all', 0, False])
|
||||
async def test_pipeline_tool_selector_malformed_extension_root_uses_empty_allowlists(
|
||||
invalid_preferences,
|
||||
):
|
||||
tool_mgr = SimpleNamespace(get_resolved_tool_catalog=AsyncMock(return_value=[]))
|
||||
pipeline_service = SimpleNamespace(
|
||||
get_pipeline=AsyncMock(
|
||||
return_value={'extensions_preferences': invalid_preferences}
|
||||
)
|
||||
)
|
||||
client = await _create_test_client(tool_mgr, pipeline_service)
|
||||
|
||||
response = await client.get(
|
||||
'/api/v1/tools?pipeline_id=pipeline-1',
|
||||
headers={'Authorization': 'Bearer test-token'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with(
|
||||
[],
|
||||
[],
|
||||
include_skill_authoring=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_pipeline_tool_selector_malformed_binding_lists_use_empty_allowlists():
|
||||
tool_mgr = SimpleNamespace(get_resolved_tool_catalog=AsyncMock(return_value=[]))
|
||||
pipeline_service = SimpleNamespace(
|
||||
get_pipeline=AsyncMock(
|
||||
return_value={
|
||||
'extensions_preferences': {
|
||||
'enable_all_plugins': True,
|
||||
'plugins': 'allowed/plugin',
|
||||
'enable_all_mcp_servers': True,
|
||||
'mcp_servers': 'bound-mcp',
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
client = await _create_test_client(tool_mgr, pipeline_service)
|
||||
|
||||
response = await client.get(
|
||||
'/api/v1/tools?pipeline_id=pipeline-1',
|
||||
headers={'Authorization': 'Bearer test-token'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with(
|
||||
[],
|
||||
[],
|
||||
include_skill_authoring=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('pipeline_query_key', ['pipeline_uuid', 'pipeline_id'])
|
||||
async def test_tool_detail_uses_pipeline_scoped_catalog_and_path_tool_name(pipeline_query_key):
|
||||
tool_mgr = SimpleNamespace(
|
||||
get_resolved_tool_catalog=AsyncMock(
|
||||
return_value=[
|
||||
{
|
||||
'name': 'namespace/unique_tool',
|
||||
'description': 'Unique tool',
|
||||
'human_desc': 'A unique tool',
|
||||
'parameters': {'type': 'object'},
|
||||
'source': 'plugin',
|
||||
'source_name': 'allowed/plugin',
|
||||
'source_id': 'allowed/plugin',
|
||||
}
|
||||
]
|
||||
)
|
||||
)
|
||||
pipeline_service = SimpleNamespace(
|
||||
get_pipeline=AsyncMock(
|
||||
return_value={
|
||||
'extensions_preferences': {
|
||||
'enable_all_plugins': False,
|
||||
'plugins': [{'author': 'allowed', 'name': 'plugin'}],
|
||||
'enable_all_mcp_servers': False,
|
||||
'mcp_servers': ['bound-mcp'],
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
client = await _create_test_client(tool_mgr, pipeline_service)
|
||||
|
||||
response = await client.get(
|
||||
f'/api/v1/tools/namespace%2Funique_tool?{pipeline_query_key}=pipeline-1',
|
||||
headers={'Authorization': 'Bearer test-token'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = await response.get_json()
|
||||
assert payload['data']['tool'] == {
|
||||
'name': 'namespace/unique_tool',
|
||||
'description': 'Unique tool',
|
||||
'human_desc': 'A unique tool',
|
||||
'parameters': {'type': 'object'},
|
||||
'source': 'plugin',
|
||||
'source_name': 'allowed/plugin',
|
||||
'source_id': 'allowed/plugin',
|
||||
}
|
||||
pipeline_service.get_pipeline.assert_awaited_once_with('pipeline-1')
|
||||
tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with(
|
||||
['allowed/plugin'],
|
||||
['bound-mcp'],
|
||||
include_skill_authoring=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_global_builtin_tool_detail_includes_nullable_source_id():
|
||||
tool_mgr = SimpleNamespace(
|
||||
get_resolved_tool_catalog=AsyncMock(
|
||||
return_value=[
|
||||
{
|
||||
'name': 'exec',
|
||||
'description': 'Execute a command',
|
||||
'human_desc': 'Execute',
|
||||
'parameters': {'type': 'object'},
|
||||
'source': 'builtin',
|
||||
'source_name': 'LangBot',
|
||||
}
|
||||
]
|
||||
)
|
||||
)
|
||||
client = await _create_test_client(tool_mgr, SimpleNamespace(get_pipeline=AsyncMock()))
|
||||
|
||||
response = await client.get(
|
||||
'/api/v1/tools/exec',
|
||||
headers={'Authorization': 'Bearer test-token'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = await response.get_json()
|
||||
assert payload['data']['tool']['source'] == 'builtin'
|
||||
assert payload['data']['tool']['source_id'] is None
|
||||
|
||||
|
||||
async def test_tool_detail_hides_ambiguous_or_missing_name():
|
||||
tool_mgr = SimpleNamespace(
|
||||
get_resolved_tool_catalog=AsyncMock(return_value=[{'name': 'other_tool', 'source': 'builtin'}])
|
||||
)
|
||||
client = await _create_test_client(tool_mgr, SimpleNamespace(get_pipeline=AsyncMock()))
|
||||
|
||||
response = await client.get(
|
||||
'/api/v1/tools/shared_tool',
|
||||
headers={'Authorization': 'Bearer test-token'},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with(
|
||||
None,
|
||||
None,
|
||||
include_skill_authoring=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_tool_detail_returns_pipeline_not_found_before_catalog_lookup():
|
||||
tool_mgr = SimpleNamespace(get_resolved_tool_catalog=AsyncMock())
|
||||
pipeline_service = SimpleNamespace(get_pipeline=AsyncMock(return_value=None))
|
||||
client = await _create_test_client(tool_mgr, pipeline_service)
|
||||
|
||||
response = await client.get(
|
||||
'/api/v1/tools/unique_tool?pipeline_uuid=missing-pipeline',
|
||||
headers={'Authorization': 'Bearer test-token'},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert await response.get_json() == {'code': -1, 'msg': 'pipeline not found'}
|
||||
pipeline_service.get_pipeline.assert_awaited_once_with('missing-pipeline')
|
||||
tool_mgr.get_resolved_tool_catalog.assert_not_awaited()
|
||||
Reference in New Issue
Block a user