feat(agent-runner): enforce 4.x host-owned execution

This commit is contained in:
huanghuoguoguo
2026-07-12 20:36:32 +08:00
parent e6384aae5d
commit 99d9c227f9
171 changed files with 6958 additions and 5385 deletions
@@ -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,