mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-18 03:34:20 +00:00
Fix agent runner host migration and runtime guards
Migrates legacy runner blocks into plugin runner configs, preserves run-scoped history boundaries, enforces operation/file authorization, and sanitizes inline attachment persistence. Also fixes plugin runner form dirty handling and adds regression coverage.
This commit is contained in:
@@ -77,6 +77,33 @@ def make_session(
|
||||
'skill': {s.get('skill_name') for s in res.get('skills', [])},
|
||||
'file': {f.get('file_id') for f in res.get('files', [])},
|
||||
}
|
||||
authorized_operations: dict[str, dict[str, set[str]]] = {
|
||||
'model': {
|
||||
m.get('model_id'): set(m.get('operations') or ['invoke', 'stream', 'rerank'])
|
||||
for m in res.get('models', [])
|
||||
if m.get('model_id')
|
||||
},
|
||||
'tool': {
|
||||
t.get('tool_name'): set(t.get('operations') or ['detail', 'call'])
|
||||
for t in res.get('tools', [])
|
||||
if t.get('tool_name')
|
||||
},
|
||||
'knowledge_base': {
|
||||
kb.get('kb_id'): set(kb.get('operations') or ['list', 'retrieve'])
|
||||
for kb in res.get('knowledge_bases', [])
|
||||
if kb.get('kb_id')
|
||||
},
|
||||
'skill': {
|
||||
s.get('skill_name'): set(s.get('operations') or ['activate'])
|
||||
for s in res.get('skills', [])
|
||||
if s.get('skill_name')
|
||||
},
|
||||
'file': {
|
||||
f.get('file_id'): set(f.get('operations') or ['config', 'knowledge'])
|
||||
for f in res.get('files', [])
|
||||
if f.get('file_id')
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
'run_id': run_id,
|
||||
@@ -90,6 +117,7 @@ def make_session(
|
||||
'state_policy': policy,
|
||||
'state_context': context,
|
||||
'authorized_ids': authorized_ids,
|
||||
'authorized_operations': authorized_operations,
|
||||
},
|
||||
'status': {
|
||||
'started_at': now,
|
||||
|
||||
@@ -129,6 +129,9 @@ class MockAgentRunOrchestrator:
|
||||
for chunk in self._chunks:
|
||||
yield chunk
|
||||
|
||||
async def try_claim_steering_from_query(self, query):
|
||||
return False
|
||||
|
||||
def resolve_runner_id_for_telemetry(self, query):
|
||||
return 'plugin:langbot/local-agent/default'
|
||||
|
||||
@@ -240,7 +243,7 @@ class TestConfigMigrationInChatHandler:
|
||||
assert runner_id == 'plugin:langbot/local-agent/default'
|
||||
|
||||
def test_resolve_runner_id_from_old_format(self):
|
||||
"""ConfigMigration should not resolve removed runner aliases."""
|
||||
"""ConfigMigration resolves old runner aliases for compatibility."""
|
||||
pipeline_config = {
|
||||
'ai': {
|
||||
'runner': {
|
||||
@@ -250,7 +253,7 @@ class TestConfigMigrationInChatHandler:
|
||||
}
|
||||
|
||||
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
|
||||
assert runner_id is None
|
||||
assert runner_id == 'plugin:langbot/local-agent/default'
|
||||
|
||||
|
||||
class TestErrorHandling:
|
||||
|
||||
@@ -20,7 +20,7 @@ class TestResolveRunnerId:
|
||||
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
|
||||
assert runner_id == 'plugin:langbot/local-agent/default'
|
||||
|
||||
def test_does_not_resolve_old_runner_field(self):
|
||||
def test_resolves_old_runner_field(self):
|
||||
pipeline_config = {
|
||||
'ai': {
|
||||
'runner': {
|
||||
@@ -30,7 +30,7 @@ class TestResolveRunnerId:
|
||||
}
|
||||
|
||||
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
|
||||
assert runner_id is None
|
||||
assert runner_id == 'plugin:langbot/local-agent/default'
|
||||
|
||||
def test_resolve_no_runner_config(self):
|
||||
runner_id = ConfigMigration.resolve_runner_id({})
|
||||
@@ -58,7 +58,7 @@ class TestResolveRunnerConfig:
|
||||
)
|
||||
assert config == {'model': 'uuid-123', 'custom_option': 10}
|
||||
|
||||
def test_does_not_read_old_runner_block(self):
|
||||
def test_reads_old_runner_block(self):
|
||||
pipeline_config = {
|
||||
'ai': {
|
||||
'local-agent': {
|
||||
@@ -71,7 +71,7 @@ class TestResolveRunnerConfig:
|
||||
pipeline_config,
|
||||
'plugin:langbot/local-agent/default',
|
||||
)
|
||||
assert config == {}
|
||||
assert config == {'model': {'primary': 'uuid-123', 'fallbacks': []}}
|
||||
|
||||
def test_resolve_no_config(self):
|
||||
config = ConfigMigration.resolve_runner_config(
|
||||
@@ -138,15 +138,20 @@ class TestNormalizePipelineConfig:
|
||||
assert migrated['ai']['runner']['id'] == 'plugin:test/my-runner/default'
|
||||
assert migrated['ai']['runner_config']['plugin:test/my-runner/default']['custom-option'] == 20
|
||||
|
||||
def test_does_not_migrate_old_runner_blocks(self):
|
||||
def test_migrates_old_runner_blocks(self):
|
||||
config = {
|
||||
'ai': {
|
||||
'runner': {'runner': 'local-agent'},
|
||||
'local-agent': {'model': 'old-model'},
|
||||
'local-agent': {'model': 'old-model', 'knowledge-base': 'kb_1'},
|
||||
},
|
||||
}
|
||||
|
||||
migrated = ConfigMigration.migrate_pipeline_config(config)
|
||||
|
||||
assert 'id' not in migrated['ai']['runner']
|
||||
assert migrated['ai']['local-agent'] == {'model': 'old-model'}
|
||||
assert migrated['ai']['runner']['id'] == 'plugin:langbot/local-agent/default'
|
||||
assert 'runner' not in migrated['ai']['runner']
|
||||
assert 'local-agent' not in migrated['ai']
|
||||
assert migrated['ai']['runner_config']['plugin:langbot/local-agent/default'] == {
|
||||
'model': {'primary': 'old-model', 'fallbacks': []},
|
||||
'knowledge-bases': ['kb_1'],
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ class TestMigratePipelineConfig:
|
||||
assert migrated['ai']['runner']['id'] == 'plugin:langbot/local-agent/default'
|
||||
assert migrated['ai']['runner_config']['plugin:langbot/local-agent/default']['custom-option'] == 10
|
||||
|
||||
def test_old_runner_field_is_not_mapped(self):
|
||||
def test_old_runner_field_is_mapped(self):
|
||||
config = {
|
||||
'ai': {
|
||||
'runner': {
|
||||
@@ -47,11 +47,13 @@ class TestMigratePipelineConfig:
|
||||
migrated = ConfigMigration.migrate_pipeline_config(config)
|
||||
|
||||
assert migrated['ai']['runner'] == {
|
||||
'runner': 'local-agent',
|
||||
'expire-time': 3600,
|
||||
'id': 'plugin:langbot/local-agent/default',
|
||||
}
|
||||
assert migrated['ai']['runner_config'] == {}
|
||||
assert migrated['ai']['local-agent'] == {'model': 'old-model'}
|
||||
assert migrated['ai']['runner_config']['plugin:langbot/local-agent/default'] == {
|
||||
'model': {'primary': 'old-model', 'fallbacks': []},
|
||||
}
|
||||
assert 'local-agent' not in migrated['ai']
|
||||
|
||||
def test_empty_config_is_unchanged(self):
|
||||
config = {}
|
||||
@@ -95,14 +97,14 @@ class TestResolveRunnerId:
|
||||
runner_id = ConfigMigration.resolve_runner_id(config)
|
||||
assert runner_id == 'plugin:test/my-runner/default'
|
||||
|
||||
def test_old_runner_field_is_ignored(self):
|
||||
def test_old_runner_field_is_mapped(self):
|
||||
config = {
|
||||
'ai': {
|
||||
'runner': {'runner': 'local-agent'},
|
||||
},
|
||||
}
|
||||
runner_id = ConfigMigration.resolve_runner_id(config)
|
||||
assert runner_id is None
|
||||
assert runner_id == 'plugin:langbot/local-agent/default'
|
||||
|
||||
|
||||
class TestResolveRunnerConfig:
|
||||
@@ -119,11 +121,11 @@ class TestResolveRunnerConfig:
|
||||
runner_config = ConfigMigration.resolve_runner_config(config, 'plugin:langbot/local-agent/default')
|
||||
assert runner_config['custom-option'] == 20
|
||||
|
||||
def test_old_runner_block_is_ignored(self):
|
||||
def test_old_runner_block_is_read(self):
|
||||
config = {
|
||||
'ai': {
|
||||
'local-agent': {'custom-option': 20},
|
||||
},
|
||||
}
|
||||
runner_config = ConfigMigration.resolve_runner_config(config, 'plugin:langbot/local-agent/default')
|
||||
assert runner_config == {}
|
||||
assert runner_config == {'custom-option': 20}
|
||||
|
||||
@@ -576,8 +576,8 @@ class TestRETRIEVEKNOWLEDGEBASEBugFix:
|
||||
|
||||
assert 'kb_custom' in allowed_kbs
|
||||
|
||||
def test_retrieve_kb_ignores_old_runner_format(self):
|
||||
"""Old runner format is not resolved by current AgentRunner helpers."""
|
||||
def test_retrieve_kb_reads_old_runner_format(self):
|
||||
"""Old runner format is resolved for migration compatibility."""
|
||||
from langbot.pkg.agent.runner.config_migration import ConfigMigration
|
||||
|
||||
pipeline_config = {
|
||||
@@ -585,11 +585,16 @@ class TestRETRIEVEKNOWLEDGEBASEBugFix:
|
||||
'runner': {
|
||||
'runner': 'local-agent',
|
||||
},
|
||||
'local-agent': {
|
||||
'knowledge-bases': ['kb_legacy'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
|
||||
assert runner_id is None
|
||||
runner_config = ConfigMigration.resolve_runner_config(pipeline_config, runner_id)
|
||||
assert runner_id == 'plugin:langbot/local-agent/default'
|
||||
assert runner_config.get('knowledge-bases') == ['kb_legacy']
|
||||
|
||||
|
||||
class TestHandlerActionAuthorization:
|
||||
@@ -1870,6 +1875,42 @@ class TestFilePermissionValidation:
|
||||
await registry.unregister('run_file_denied')
|
||||
|
||||
|
||||
class TestOperationPermissionValidation:
|
||||
"""Tests operation-level Host-side run authorization."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_operation_denied_when_resource_only_allows_invoke(self):
|
||||
from langbot.pkg.agent.runner.session_registry import get_session_registry
|
||||
from langbot.pkg.plugin.handler import _validate_run_authorization
|
||||
|
||||
registry = get_session_registry()
|
||||
await registry.register(
|
||||
run_id='run_model_operation_denied',
|
||||
runner_id='plugin:test/runner/default',
|
||||
query_id=1,
|
||||
plugin_identity='test/runner',
|
||||
resources=make_resources(models=[{'model_id': 'model_001', 'operations': ['invoke']}]),
|
||||
)
|
||||
|
||||
mock_ap = MagicMock()
|
||||
mock_ap.logger = MagicMock()
|
||||
|
||||
session, error = await _validate_run_authorization(
|
||||
'run_model_operation_denied',
|
||||
'model',
|
||||
'model_001',
|
||||
mock_ap,
|
||||
caller_plugin_identity='test/runner',
|
||||
operation='stream',
|
||||
)
|
||||
|
||||
assert session is None
|
||||
assert error is not None
|
||||
assert 'operation stream' in error.message
|
||||
|
||||
await registry.unregister('run_model_operation_denied')
|
||||
|
||||
|
||||
class TestCallerPluginIdentityValidation:
|
||||
"""Tests for caller_plugin_identity cross-plugin validation.
|
||||
|
||||
|
||||
@@ -64,6 +64,9 @@ async def _register_session(
|
||||
*,
|
||||
run_id='run_1',
|
||||
conversation_id='conv_1',
|
||||
bot_id=None,
|
||||
workspace_id=None,
|
||||
thread_id=None,
|
||||
available_apis=None,
|
||||
):
|
||||
await session_registry.register(
|
||||
@@ -73,6 +76,9 @@ async def _register_session(
|
||||
plugin_identity='test/runner',
|
||||
resources=make_resources(),
|
||||
conversation_id=conversation_id,
|
||||
bot_id=bot_id,
|
||||
workspace_id=workspace_id,
|
||||
thread_id=thread_id,
|
||||
available_apis=available_apis or {},
|
||||
)
|
||||
|
||||
@@ -220,3 +226,98 @@ async def test_event_page_returns_sdk_page_projection(session_registry, db_engin
|
||||
assert 'input_json' not in item
|
||||
assert 'run_id' not in item
|
||||
assert 'runner_id' not in item
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_history_page_filters_run_scope_thread_and_bot(session_registry, db_engine):
|
||||
from langbot.pkg.agent.runner.transcript_store import TranscriptStore
|
||||
|
||||
await _register_session(
|
||||
session_registry,
|
||||
bot_id='bot_1',
|
||||
thread_id='thread_1',
|
||||
available_apis={'history_page': True},
|
||||
)
|
||||
store = TranscriptStore(db_engine)
|
||||
await store.append_transcript(
|
||||
transcript_id='tr_visible',
|
||||
event_id='evt_visible',
|
||||
conversation_id='conv_1',
|
||||
role='user',
|
||||
bot_id='bot_1',
|
||||
thread_id='thread_1',
|
||||
content='visible',
|
||||
)
|
||||
await store.append_transcript(
|
||||
transcript_id='tr_other_bot',
|
||||
event_id='evt_other_bot',
|
||||
conversation_id='conv_1',
|
||||
role='user',
|
||||
bot_id='bot_2',
|
||||
thread_id='thread_1',
|
||||
content='hidden bot',
|
||||
)
|
||||
await store.append_transcript(
|
||||
transcript_id='tr_other_thread',
|
||||
event_id='evt_other_thread',
|
||||
conversation_id='conv_1',
|
||||
role='user',
|
||||
bot_id='bot_1',
|
||||
thread_id='thread_2',
|
||||
content='hidden thread',
|
||||
)
|
||||
handler = _handler(db_engine, session_registry)
|
||||
history_page = handler.actions[PluginToRuntimeAction.HISTORY_PAGE.value]
|
||||
|
||||
result = await history_page({
|
||||
'run_id': 'run_1',
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
})
|
||||
|
||||
assert result.code == 0
|
||||
assert [item['content'] for item in result.data['items']] == ['visible']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_page_filters_run_scope_thread_and_bot(session_registry, db_engine):
|
||||
await _register_session(
|
||||
session_registry,
|
||||
bot_id='bot_1',
|
||||
thread_id='thread_1',
|
||||
available_apis={'event_page': True},
|
||||
)
|
||||
store = EventLogStore(db_engine)
|
||||
await store.append_event(
|
||||
event_id='evt_visible',
|
||||
event_type='message.received',
|
||||
source='platform',
|
||||
bot_id='bot_1',
|
||||
conversation_id='conv_1',
|
||||
thread_id='thread_1',
|
||||
)
|
||||
await store.append_event(
|
||||
event_id='evt_other_bot',
|
||||
event_type='message.received',
|
||||
source='platform',
|
||||
bot_id='bot_2',
|
||||
conversation_id='conv_1',
|
||||
thread_id='thread_1',
|
||||
)
|
||||
await store.append_event(
|
||||
event_id='evt_other_thread',
|
||||
event_type='message.received',
|
||||
source='platform',
|
||||
bot_id='bot_1',
|
||||
conversation_id='conv_1',
|
||||
thread_id='thread_2',
|
||||
)
|
||||
handler = _handler(db_engine, session_registry)
|
||||
event_page = handler.actions[PluginToRuntimeAction.EVENT_PAGE.value]
|
||||
|
||||
result = await event_page({
|
||||
'run_id': 'run_1',
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
})
|
||||
|
||||
assert result.code == 0
|
||||
assert [item['event_id'] for item in result.data['items']] == ['evt_visible']
|
||||
|
||||
@@ -427,6 +427,37 @@ async def test_orchestrator_streams_fake_plugin_deltas(clean_agent_state):
|
||||
assert [chunk.content for chunk in chunks] == ["hel", "hello"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_orchestrator_persists_run_completed_message_transcript(clean_agent_state):
|
||||
"""run.completed(message=...) should be treated as the final assistant transcript."""
|
||||
from langbot.pkg.agent.runner.transcript_store import TranscriptStore
|
||||
|
||||
db_engine = clean_agent_state
|
||||
descriptor = make_descriptor()
|
||||
plugin_connector = FakePluginConnector(
|
||||
results=[
|
||||
{
|
||||
"type": "run.completed",
|
||||
"data": {
|
||||
"finish_reason": "stop",
|
||||
"message": {"role": "assistant", "content": "final response"},
|
||||
},
|
||||
},
|
||||
]
|
||||
)
|
||||
orchestrator = AgentRunOrchestrator(FakeApplication(plugin_connector, db_engine), FakeRegistry(descriptor))
|
||||
query = make_query()
|
||||
|
||||
messages = [message async for message in orchestrator.run_from_query(query)]
|
||||
|
||||
assert [message.content for message in messages] == ["final response"]
|
||||
transcript_store = TranscriptStore(db_engine)
|
||||
transcripts, _, _, _ = await transcript_store.page_transcript(query.session.using_conversation.uuid, limit=10)
|
||||
assistant_items = [item for item in transcripts if item["role"] == "assistant"]
|
||||
assert len(assistant_items) == 1
|
||||
assert assistant_items[0]["content"] == "final response"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_orchestrator_drops_duplicate_result_sequence(clean_agent_state):
|
||||
"""Duplicate runner result sequences are idempotently ignored."""
|
||||
@@ -560,6 +591,14 @@ class TestQueryEntrySessionQueryId:
|
||||
ap = FakeApplication(plugin_connector, db_engine)
|
||||
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor))
|
||||
query = make_query()
|
||||
query.user_message = provider_message.Message(
|
||||
role="user",
|
||||
content=[
|
||||
provider_message.ContentElement.from_text("hello"),
|
||||
provider_message.ContentElement.from_image_base64("data:image/png;base64,aGVsbG8="),
|
||||
provider_message.ContentElement.from_file_base64("data:text/plain;base64,aGVsbG8=", "hello.txt"),
|
||||
],
|
||||
)
|
||||
|
||||
messages = [message async for message in orchestrator.run_from_query(query)]
|
||||
|
||||
@@ -815,6 +854,13 @@ class TestQueryEntryAdapterHostCapabilities:
|
||||
ap = FakeApplication(plugin_connector, db_engine)
|
||||
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor))
|
||||
query = make_query()
|
||||
query.user_message = provider_message.Message(
|
||||
role="user",
|
||||
content=[
|
||||
provider_message.ContentElement.from_text("hello"),
|
||||
provider_message.ContentElement.from_image_base64("data:image/png;base64,aGVsbG8="),
|
||||
],
|
||||
)
|
||||
|
||||
messages = [message async for message in orchestrator.run_from_query(query)]
|
||||
|
||||
@@ -896,6 +942,13 @@ class TestQueryEntryAdapterHostCapabilities:
|
||||
ap = FakeApplication(plugin_connector, db_engine)
|
||||
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor))
|
||||
query = make_query()
|
||||
query.user_message = provider_message.Message(
|
||||
role="user",
|
||||
content=[
|
||||
provider_message.ContentElement.from_text("hello"),
|
||||
provider_message.ContentElement.from_image_base64("data:image/png;base64,aGVsbG8="),
|
||||
],
|
||||
)
|
||||
|
||||
messages = [message async for message in orchestrator.run_from_query(query)]
|
||||
|
||||
@@ -910,18 +963,26 @@ class TestQueryEntryAdapterHostCapabilities:
|
||||
assert len(event_logs) >= 1
|
||||
# First event should be the incoming message.received
|
||||
assert event_logs[0]["event_type"] == "message.received"
|
||||
assert event_logs[0]["input_json"]["contents"][1]["image_base64"] is None
|
||||
assert event_logs[0]["input_json"]["contents"][1]["content_redacted"] is True
|
||||
assert "aGVsbG8=" not in str(event_logs[0]["input_json"])
|
||||
|
||||
# Check Transcript has user and assistant messages
|
||||
transcript_store = TranscriptStore(db_engine)
|
||||
transcripts, _, _, _ = await transcript_store.page_transcript(
|
||||
conversation_id=query.session.using_conversation.uuid,
|
||||
limit=10,
|
||||
include_artifacts=True,
|
||||
)
|
||||
assert len(transcripts) >= 2
|
||||
# Find user and assistant messages
|
||||
roles = [t["role"] for t in transcripts]
|
||||
assert "user" in roles
|
||||
assert "assistant" in roles
|
||||
user_item = next(t for t in transcripts if t["role"] == "user")
|
||||
assert user_item["content_json"]["content"][1]["image_base64"] is None
|
||||
assert user_item["artifact_refs"][0]["content"] is None
|
||||
assert "aGVsbG8=" not in str(user_item)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_artifact_created_via_event_first_path(self, clean_agent_state):
|
||||
|
||||
@@ -138,10 +138,10 @@ async def test_build_models_authorizes_config_declared_llm_and_rerank_models(app
|
||||
resources = await build_resources(app, query, descriptor)
|
||||
|
||||
assert resources['models'] == [
|
||||
{'model_id': 'primary', 'model_type': 'llm', 'provider': 'test-provider'},
|
||||
{'model_id': 'fallback', 'model_type': 'llm', 'provider': 'test-provider'},
|
||||
{'model_id': 'aux', 'model_type': 'llm', 'provider': 'aux-provider'},
|
||||
{'model_id': 'rerank', 'model_type': 'rerank', 'provider': 'rerank-provider'},
|
||||
{'model_id': 'primary', 'model_type': 'llm', 'provider': 'test-provider', 'operations': ['invoke', 'stream']},
|
||||
{'model_id': 'fallback', 'model_type': 'llm', 'provider': 'test-provider', 'operations': ['invoke', 'stream']},
|
||||
{'model_id': 'aux', 'model_type': 'llm', 'provider': 'aux-provider', 'operations': ['invoke', 'stream']},
|
||||
{'model_id': 'rerank', 'model_type': 'rerank', 'provider': 'rerank-provider', 'operations': ['rerank']},
|
||||
]
|
||||
|
||||
|
||||
@@ -188,8 +188,8 @@ async def test_build_models_authorizes_rerank_and_llm_refs_from_config(app):
|
||||
resources = await build_resources(app, query, descriptor)
|
||||
|
||||
assert resources['models'] == [
|
||||
{'model_id': 'llm', 'model_type': 'llm', 'provider': 'test-provider'},
|
||||
{'model_id': 'rerank', 'model_type': 'rerank', 'provider': 'rerank-provider'},
|
||||
{'model_id': 'llm', 'model_type': 'llm', 'provider': 'test-provider', 'operations': ['invoke', 'stream']},
|
||||
{'model_id': 'rerank', 'model_type': 'rerank', 'provider': 'rerank-provider', 'operations': ['rerank']},
|
||||
]
|
||||
|
||||
|
||||
@@ -218,7 +218,7 @@ async def test_build_models_manifest_permission_narrows_binding(app):
|
||||
resources = await build_resources(app, query, descriptor)
|
||||
|
||||
assert resources['models'] == [
|
||||
{'model_id': 'rerank', 'model_type': 'rerank', 'provider': 'rerank-provider'},
|
||||
{'model_id': 'rerank', 'model_type': 'rerank', 'provider': 'rerank-provider', 'operations': ['rerank']},
|
||||
]
|
||||
|
||||
|
||||
@@ -264,11 +264,13 @@ async def test_build_tools_authorizes_query_declared_tools(app):
|
||||
'tool_name': 'qa_plugin_echo',
|
||||
'tool_type': None,
|
||||
'description': None,
|
||||
'operations': ['detail', 'call'],
|
||||
},
|
||||
{
|
||||
'tool_name': 'qa_mcp_echo',
|
||||
'tool_type': None,
|
||||
'description': None,
|
||||
'operations': ['detail', 'call'],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -320,8 +322,8 @@ async def test_build_knowledge_bases_unions_config_and_policy_grants(app):
|
||||
resources = await build_resources(app, query, descriptor)
|
||||
|
||||
assert resources['knowledge_bases'] == [
|
||||
{'kb_id': 'kb_config', 'kb_name': 'name-kb_config', 'kb_type': 'default'},
|
||||
{'kb_id': 'kb_policy', 'kb_name': 'name-kb_policy', 'kb_type': 'default'},
|
||||
{'kb_id': 'kb_config', 'kb_name': 'name-kb_config', 'kb_type': 'default', 'operations': ['list', 'retrieve']},
|
||||
{'kb_id': 'kb_policy', 'kb_name': 'name-kb_policy', 'kb_type': 'default', 'operations': ['list', 'retrieve']},
|
||||
]
|
||||
|
||||
|
||||
@@ -347,6 +349,42 @@ async def test_build_knowledge_bases_manifest_permission_denies_binding_kbs(app)
|
||||
assert resources['knowledge_bases'] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_files_authorizes_config_declared_file_fields(app):
|
||||
descriptor = make_descriptor(
|
||||
config_schema=[
|
||||
{'name': 'avatar', 'type': 'file'},
|
||||
{'name': 'references', 'type': 'array[file]'},
|
||||
],
|
||||
)
|
||||
query = make_query({
|
||||
'avatar': {'file_key': 'plugin_config_avatar.png', 'mimetype': 'image/png'},
|
||||
'references': [
|
||||
{'file_key': 'plugin_config_doc.txt', 'mimetype': 'text/plain', 'file_name': 'doc.txt'},
|
||||
{'file_key': 'plugin_config_doc.txt', 'mimetype': 'text/plain', 'file_name': 'doc.txt'},
|
||||
],
|
||||
})
|
||||
|
||||
resources = await build_resources(app, query, descriptor)
|
||||
|
||||
assert resources['files'] == [
|
||||
{
|
||||
'file_id': 'plugin_config_avatar.png',
|
||||
'file_name': None,
|
||||
'mime_type': 'image/png',
|
||||
'source': 'config',
|
||||
'operations': ['config'],
|
||||
},
|
||||
{
|
||||
'file_id': 'plugin_config_doc.txt',
|
||||
'file_name': 'doc.txt',
|
||||
'mime_type': 'text/plain',
|
||||
'source': 'config',
|
||||
'operations': ['config'],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_storage_intersects_manifest_and_binding_policy(app):
|
||||
descriptor = make_descriptor(
|
||||
|
||||
@@ -330,6 +330,19 @@ class TestIsResourceAllowed:
|
||||
assert registry.is_resource_allowed(session, 'model', 'model_001') is True
|
||||
assert registry.is_resource_allowed(session, 'model', 'model_002') is True
|
||||
|
||||
def test_model_operation_denied(self):
|
||||
"""Model resources should enforce operation-level grants."""
|
||||
registry = AgentRunSessionRegistry()
|
||||
resources = make_resources(
|
||||
models=[
|
||||
{'model_id': 'model_001', 'operations': ['invoke']},
|
||||
]
|
||||
)
|
||||
session = make_session(resources=resources)
|
||||
|
||||
assert registry.is_resource_allowed(session, 'model', 'model_001', 'invoke') is True
|
||||
assert registry.is_resource_allowed(session, 'model', 'model_001', 'stream') is False
|
||||
|
||||
def test_model_not_allowed(self):
|
||||
"""Model not in resources should be denied."""
|
||||
registry = AgentRunSessionRegistry()
|
||||
@@ -360,6 +373,19 @@ class TestIsResourceAllowed:
|
||||
assert registry.is_resource_allowed(session, 'tool', 'web_search') is True
|
||||
assert registry.is_resource_allowed(session, 'tool', 'code_exec') is True
|
||||
|
||||
def test_tool_operation_denied(self):
|
||||
"""Tool resources should enforce detail/call grants."""
|
||||
registry = AgentRunSessionRegistry()
|
||||
resources = make_resources(
|
||||
tools=[
|
||||
{'tool_name': 'web_search', 'operations': ['detail']},
|
||||
]
|
||||
)
|
||||
session = make_session(resources=resources)
|
||||
|
||||
assert registry.is_resource_allowed(session, 'tool', 'web_search', 'detail') is True
|
||||
assert registry.is_resource_allowed(session, 'tool', 'web_search', 'call') is False
|
||||
|
||||
def test_tool_not_allowed(self):
|
||||
"""Tool not in resources should be denied."""
|
||||
registry = AgentRunSessionRegistry()
|
||||
|
||||
@@ -138,6 +138,7 @@ class TestStateAPIHandlerAuthorization:
|
||||
query_id=1,
|
||||
plugin_identity='test/runner',
|
||||
resources=make_resources(),
|
||||
available_apis={'state': True},
|
||||
state_policy={'enable_state': True, 'state_scopes': ['conversation']},
|
||||
state_context={'scope_keys': {'conversation': 'conv_key'}, 'binding_identity': 'binding_1'},
|
||||
)
|
||||
@@ -173,6 +174,7 @@ class TestStateAPIHandlerAuthorization:
|
||||
query_id=1,
|
||||
plugin_identity='test/runner',
|
||||
resources=make_resources(),
|
||||
available_apis={'state': True},
|
||||
state_policy={'enable_state': True, 'state_scopes': ['conversation']},
|
||||
state_context={'scope_keys': {'conversation': 'conv_key'}, 'binding_identity': 'binding_1'},
|
||||
)
|
||||
@@ -209,6 +211,7 @@ class TestStateAPIHandlerAuthorization:
|
||||
query_id=1,
|
||||
plugin_identity='test/runner',
|
||||
resources=make_resources(),
|
||||
available_apis={'state': True},
|
||||
state_policy={'enable_state': False, 'state_scopes': []},
|
||||
state_context={'scope_keys': {}, 'binding_identity': 'binding_1'},
|
||||
)
|
||||
@@ -244,6 +247,7 @@ class TestStateAPIHandlerAuthorization:
|
||||
query_id=1,
|
||||
plugin_identity='test/runner',
|
||||
resources=make_resources(),
|
||||
available_apis={'state': True},
|
||||
state_policy={'enable_state': True, 'state_scopes': ['conversation']},
|
||||
state_context={'scope_keys': {'conversation': 'conv_key', 'actor': 'actor_key'}, 'binding_identity': 'binding_1'},
|
||||
)
|
||||
@@ -280,6 +284,7 @@ class TestStateAPIHandlerAuthorization:
|
||||
query_id=1,
|
||||
plugin_identity='test/runner',
|
||||
resources=make_resources(),
|
||||
available_apis={'state': True},
|
||||
state_policy={'enable_state': True, 'state_scopes': ['conversation']},
|
||||
state_context={'scope_keys': {}, 'binding_identity': 'binding_1'}, # No scope_keys
|
||||
)
|
||||
@@ -320,6 +325,7 @@ class TestStateAPIFullFlowWithRealDB:
|
||||
query_id=1,
|
||||
plugin_identity='test/runner',
|
||||
resources=make_resources(),
|
||||
available_apis={'state': True},
|
||||
state_policy={'enable_state': True, 'state_scopes': ['conversation', 'runner']},
|
||||
state_context={
|
||||
'scope_keys': {
|
||||
@@ -426,6 +432,7 @@ class TestStateHandlerReadsFromAuthorizationSnapshot:
|
||||
query_id=1,
|
||||
plugin_identity='test/runner',
|
||||
resources=make_resources(),
|
||||
available_apis={'state': True},
|
||||
state_policy={'enable_state': False, 'state_scopes': []},
|
||||
state_context={'scope_keys': {}, 'binding_identity': 'binding_1'},
|
||||
)
|
||||
@@ -469,6 +476,7 @@ class TestStateHandlerReadsFromAuthorizationSnapshot:
|
||||
query_id=1,
|
||||
plugin_identity='test/runner',
|
||||
resources=make_resources(),
|
||||
available_apis={'state': True},
|
||||
state_policy={'enable_state': True, 'state_scopes': ['conversation']},
|
||||
state_context={'scope_keys': {'conversation': 'conv_key_xyz'}, 'binding_identity': 'binding_xyz'},
|
||||
)
|
||||
|
||||
@@ -119,18 +119,16 @@ class TestStateScopeHelpers:
|
||||
thread_id='thread_001',
|
||||
)
|
||||
|
||||
assert build_state_scope_key('conversation', event, binding, descriptor) == (
|
||||
'conversation:plugin:test/my-runner/default:binding_a:conv_001:thread_001'
|
||||
)
|
||||
assert build_state_scope_key('actor', event, binding, descriptor) == (
|
||||
'actor:plugin:test/my-runner/default:binding_a:user:user_001'
|
||||
)
|
||||
assert build_state_scope_key('subject', event, binding, descriptor) == (
|
||||
'subject:plugin:test/my-runner/default:binding_a:message:msg_001'
|
||||
)
|
||||
assert build_state_scope_key('runner', event, binding, descriptor) == (
|
||||
'runner:plugin:test/my-runner/default:binding_a'
|
||||
)
|
||||
keys = {
|
||||
scope: build_state_scope_key(scope, event, binding, descriptor)
|
||||
for scope in VALID_STATE_SCOPES
|
||||
}
|
||||
|
||||
assert keys['conversation'].startswith('conversation:v2:')
|
||||
assert keys['actor'].startswith('actor:v2:')
|
||||
assert keys['subject'].startswith('subject:v2:')
|
||||
assert keys['runner'].startswith('runner:v2:')
|
||||
assert len(set(keys.values())) == len(keys)
|
||||
|
||||
def test_scope_key_missing_identity_returns_none(self):
|
||||
descriptor = make_descriptor()
|
||||
|
||||
Reference in New Issue
Block a user