mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-12 16:56:02 +00:00
feat(agent-runner): enforce typed host permissions
This commit is contained in:
@@ -43,7 +43,7 @@ def make_session(
|
||||
plugin_identity: str = 'test/test-runner',
|
||||
resources: dict | None = None,
|
||||
conversation_id: str | None = None,
|
||||
permissions: dict[str, list[str]] | None = None,
|
||||
available_apis: dict[str, bool] | None = None,
|
||||
state_policy: dict[str, typing.Any] | None = None,
|
||||
state_context: dict[str, typing.Any] | None = None,
|
||||
) -> dict[str, typing.Any]:
|
||||
@@ -62,7 +62,7 @@ def make_session(
|
||||
import time
|
||||
now = int(time.time())
|
||||
res = resources if resources is not None else make_resources()
|
||||
perms = permissions if permissions is not None else {}
|
||||
apis = available_apis if available_apis is not None else {}
|
||||
policy = (
|
||||
state_policy
|
||||
if state_policy is not None
|
||||
@@ -85,7 +85,7 @@ def make_session(
|
||||
'plugin_identity': plugin_identity,
|
||||
'authorization': {
|
||||
'resources': res,
|
||||
'permissions': perms,
|
||||
'available_apis': apis,
|
||||
'conversation_id': conversation_id,
|
||||
'state_policy': policy,
|
||||
'state_context': context,
|
||||
|
||||
@@ -212,7 +212,7 @@ class TestArtifactAccessValidation:
|
||||
return make_session(
|
||||
run_id="run_001",
|
||||
conversation_id=conversation_id,
|
||||
permissions={"artifacts": ["metadata", "read"]},
|
||||
available_apis={"artifact_metadata": True, "artifact_read": True},
|
||||
)
|
||||
|
||||
def _call_validate(self, session, metadata, operation="metadata"):
|
||||
@@ -298,33 +298,23 @@ class TestArtifactAccessValidation:
|
||||
|
||||
|
||||
class TestContextAccessArtifactAPIs:
|
||||
"""Test ContextAccess reflects artifact API permissions."""
|
||||
"""Test ContextAccess reflects runtime artifact API availability."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_access_has_artifact_apis_when_permitted(self):
|
||||
"""Test ContextAccess shows artifact APIs when permissions allow."""
|
||||
# This tests the context builder logic
|
||||
# When artifact permissions include 'metadata' and 'read',
|
||||
# available_apis should reflect that
|
||||
permissions = {"artifacts": ["metadata", "read"]}
|
||||
"""Artifact APIs are exposed through run-scoped available_apis."""
|
||||
available_apis = {"artifact_metadata": True, "artifact_read": True}
|
||||
|
||||
# Check that permissions are properly interpreted
|
||||
artifact_metadata_enabled = "metadata" in permissions.get("artifacts", [])
|
||||
artifact_read_enabled = "read" in permissions.get("artifacts", [])
|
||||
|
||||
assert artifact_metadata_enabled is True
|
||||
assert artifact_read_enabled is True
|
||||
assert available_apis["artifact_metadata"] is True
|
||||
assert available_apis["artifact_read"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_access_no_artifact_apis_without_permission(self):
|
||||
"""Test ContextAccess hides artifact APIs when permissions denied."""
|
||||
permissions = {"artifacts": []}
|
||||
"""Artifact APIs are absent when the run did not receive them."""
|
||||
available_apis = {}
|
||||
|
||||
artifact_metadata_enabled = "metadata" in permissions.get("artifacts", [])
|
||||
artifact_read_enabled = "read" in permissions.get("artifacts", [])
|
||||
|
||||
assert artifact_metadata_enabled is False
|
||||
assert artifact_read_enabled is False
|
||||
assert available_apis.get("artifact_metadata", False) is False
|
||||
assert available_apis.get("artifact_read", False) is False
|
||||
|
||||
|
||||
class TestArtifactMetadataFieldAlignment:
|
||||
@@ -376,8 +366,8 @@ class TestArtifactMetadataFieldAlignment:
|
||||
assert "storage_type" not in result
|
||||
|
||||
|
||||
class TestSessionRegistryPermissions:
|
||||
"""Test that session registry stores and retrieves permissions correctly."""
|
||||
class TestSessionRegistryAvailableAPIs:
|
||||
"""Test that session registry stores and retrieves available APIs correctly."""
|
||||
|
||||
@pytest.fixture
|
||||
def session_registry(self):
|
||||
@@ -387,8 +377,8 @@ class TestSessionRegistryPermissions:
|
||||
return get_session_registry()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_stores_permissions(self, session_registry):
|
||||
"""Test that register() stores permissions from descriptor."""
|
||||
async def test_register_stores_available_apis(self, session_registry):
|
||||
"""Test that register() stores runtime API availability."""
|
||||
await session_registry.register(
|
||||
run_id="run_001",
|
||||
runner_id="plugin:author/plugin/runner",
|
||||
@@ -402,24 +392,26 @@ class TestSessionRegistryPermissions:
|
||||
"storage": {"plugin_storage": True, "workspace_storage": False},
|
||||
"platform_capabilities": {},
|
||||
},
|
||||
permissions={
|
||||
"artifacts": ["metadata", "read"],
|
||||
"history": ["page"],
|
||||
"events": ["get"],
|
||||
available_apis={
|
||||
"artifact_metadata": True,
|
||||
"artifact_read": True,
|
||||
"history_page": True,
|
||||
"event_get": True,
|
||||
},
|
||||
conversation_id="conv_001",
|
||||
)
|
||||
|
||||
session = await session_registry.get("run_001")
|
||||
assert session is not None
|
||||
permissions = session["authorization"]["permissions"]
|
||||
assert permissions["artifacts"] == ["metadata", "read"]
|
||||
assert permissions["history"] == ["page"]
|
||||
assert permissions["events"] == ["get"]
|
||||
available_apis = session["authorization"]["available_apis"]
|
||||
assert available_apis["artifact_metadata"] is True
|
||||
assert available_apis["artifact_read"] is True
|
||||
assert available_apis["history_page"] is True
|
||||
assert available_apis["event_get"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_with_empty_permissions(self, session_registry):
|
||||
"""Test that register() handles empty permissions."""
|
||||
async def test_register_with_empty_available_apis(self, session_registry):
|
||||
"""Test that register() handles empty API availability."""
|
||||
await session_registry.register(
|
||||
run_id="run_002",
|
||||
runner_id="plugin:author/plugin/runner",
|
||||
@@ -433,13 +425,13 @@ class TestSessionRegistryPermissions:
|
||||
"storage": {"plugin_storage": True, "workspace_storage": False},
|
||||
"platform_capabilities": {},
|
||||
},
|
||||
permissions={},
|
||||
available_apis={},
|
||||
conversation_id="conv_001",
|
||||
)
|
||||
|
||||
session = await session_registry.get("run_002")
|
||||
assert session is not None
|
||||
assert session["authorization"]["permissions"] == {}
|
||||
assert session["authorization"]["available_apis"] == {}
|
||||
|
||||
|
||||
class TestArtifactStoreRealSQLite:
|
||||
|
||||
@@ -11,6 +11,7 @@ import pytest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from langbot.pkg.agent.runner.context_builder import AgentRunContextBuilder
|
||||
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
|
||||
from langbot.pkg.agent.runner.host_models import AgentEventEnvelope, AgentBinding, BindingScope, StatePolicy
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.event import ActorContext
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
|
||||
@@ -25,6 +26,27 @@ class MockApplication:
|
||||
self.persistence_mgr.get_db_engine = MagicMock()
|
||||
|
||||
|
||||
def make_descriptor(
|
||||
permissions: dict | None = None,
|
||||
) -> AgentRunnerDescriptor:
|
||||
return AgentRunnerDescriptor(
|
||||
id='plugin:test/runner/default',
|
||||
source='plugin',
|
||||
label={'en_US': 'Test Runner'},
|
||||
plugin_author='test',
|
||||
plugin_name='runner',
|
||||
runner_name='default',
|
||||
permissions=permissions
|
||||
if permissions is not None
|
||||
else {
|
||||
'history': ['page', 'search'],
|
||||
'events': ['get', 'page'],
|
||||
'artifacts': ['metadata', 'read'],
|
||||
'storage': ['plugin'],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class TestContextAccessStateDetermination:
|
||||
"""Tests for ContextAccess.state field determination - real calls to _build_context_access."""
|
||||
|
||||
@@ -54,10 +76,7 @@ class TestContextAccessStateDetermination:
|
||||
@pytest.fixture
|
||||
def mock_descriptor(self):
|
||||
"""Create mock runner descriptor."""
|
||||
descriptor = MagicMock()
|
||||
descriptor.id = 'plugin:test/runner/default'
|
||||
descriptor.permissions = {}
|
||||
return descriptor
|
||||
return make_descriptor()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enable_state_true_with_scopes_sets_state_true(self, mock_app, mock_event, mock_descriptor):
|
||||
@@ -237,7 +256,7 @@ class TestBindingWithStatePolicy:
|
||||
|
||||
|
||||
class TestContextAccessOtherAPIs:
|
||||
"""Tests for other available_apis fields based on permissions."""
|
||||
"""Tests for other available_apis fields based on run scope."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_app(self):
|
||||
@@ -245,16 +264,12 @@ class TestContextAccessOtherAPIs:
|
||||
return MockApplication()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_history_apis_based_on_permissions(self, mock_app):
|
||||
"""History APIs availability based on runner permissions."""
|
||||
async def test_history_apis_enabled_with_conversation(self, mock_app):
|
||||
"""History APIs are available when the run has a conversation scope."""
|
||||
mock_event = MagicMock()
|
||||
mock_event.conversation_id = 'conv_001'
|
||||
mock_event.thread_id = None
|
||||
|
||||
mock_descriptor = MagicMock()
|
||||
mock_descriptor.permissions = {
|
||||
'history': ['page', 'search'],
|
||||
}
|
||||
mock_descriptor = make_descriptor()
|
||||
|
||||
binding = AgentBinding(
|
||||
binding_id='binding_001',
|
||||
@@ -268,21 +283,16 @@ class TestContextAccessOtherAPIs:
|
||||
# Real call
|
||||
context_access = await builder._build_context_access(mock_event, mock_descriptor, binding)
|
||||
|
||||
# History APIs enabled based on permissions
|
||||
assert context_access['available_apis']['history_page'] is True
|
||||
assert context_access['available_apis']['history_search'] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_apis_based_on_permissions(self, mock_app):
|
||||
"""Event APIs availability based on runner permissions."""
|
||||
async def test_event_apis_enabled_by_default(self, mock_app):
|
||||
"""Event APIs are available based on current run scope."""
|
||||
mock_event = MagicMock()
|
||||
mock_event.conversation_id = 'conv_001'
|
||||
mock_event.thread_id = None
|
||||
|
||||
mock_descriptor = MagicMock()
|
||||
mock_descriptor.permissions = {
|
||||
'events': ['get', 'page'],
|
||||
}
|
||||
mock_descriptor = make_descriptor()
|
||||
|
||||
binding = AgentBinding(
|
||||
binding_id='binding_001',
|
||||
@@ -296,21 +306,16 @@ class TestContextAccessOtherAPIs:
|
||||
# Real call
|
||||
context_access = await builder._build_context_access(mock_event, mock_descriptor, binding)
|
||||
|
||||
# Event APIs enabled based on permissions
|
||||
assert context_access['available_apis']['event_get'] is True
|
||||
assert context_access['available_apis']['event_page'] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_artifact_apis_based_on_permissions(self, mock_app):
|
||||
"""Artifact APIs availability based on runner permissions."""
|
||||
async def test_artifact_apis_enabled_by_default(self, mock_app):
|
||||
"""Artifact APIs are available based on current run scope."""
|
||||
mock_event = MagicMock()
|
||||
mock_event.conversation_id = 'conv_001'
|
||||
mock_event.thread_id = None
|
||||
|
||||
mock_descriptor = MagicMock()
|
||||
mock_descriptor.permissions = {
|
||||
'artifacts': ['metadata', 'read'],
|
||||
}
|
||||
mock_descriptor = make_descriptor()
|
||||
|
||||
binding = AgentBinding(
|
||||
binding_id='binding_001',
|
||||
@@ -324,19 +329,16 @@ class TestContextAccessOtherAPIs:
|
||||
# Real call
|
||||
context_access = await builder._build_context_access(mock_event, mock_descriptor, binding)
|
||||
|
||||
# Artifact APIs enabled based on permissions
|
||||
assert context_access['available_apis']['artifact_metadata'] is True
|
||||
assert context_access['available_apis']['artifact_read'] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_permissions_all_apis_disabled(self, mock_app):
|
||||
"""All pull APIs disabled when permissions are empty."""
|
||||
async def test_conversation_required_apis_disabled_without_conversation(self, mock_app):
|
||||
"""Conversation-scoped APIs are disabled when the run has no conversation."""
|
||||
mock_event = MagicMock()
|
||||
mock_event.conversation_id = 'conv_001'
|
||||
mock_event.conversation_id = None
|
||||
mock_event.thread_id = None
|
||||
|
||||
mock_descriptor = MagicMock()
|
||||
mock_descriptor.permissions = {} # No permissions
|
||||
mock_descriptor = make_descriptor()
|
||||
|
||||
binding = AgentBinding(
|
||||
binding_id='binding_001',
|
||||
@@ -350,11 +352,37 @@ class TestContextAccessOtherAPIs:
|
||||
# Real call
|
||||
context_access = await builder._build_context_access(mock_event, mock_descriptor, binding)
|
||||
|
||||
# All pull APIs should be disabled
|
||||
assert context_access['available_apis']['history_page'] is False
|
||||
assert context_access['available_apis']['history_search'] is False
|
||||
assert context_access['available_apis']['event_get'] is True
|
||||
assert context_access['available_apis']['event_page'] is False
|
||||
assert context_access['available_apis']['artifact_metadata'] is True
|
||||
assert context_access['available_apis']['artifact_read'] is True
|
||||
assert context_access['available_apis']['state'] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manifest_permissions_disable_context_apis(self, mock_app):
|
||||
"""Pull APIs are disabled when manifest permissions omit them."""
|
||||
mock_event = MagicMock()
|
||||
mock_event.conversation_id = 'conv_001'
|
||||
mock_event.thread_id = None
|
||||
mock_descriptor = make_descriptor(permissions={})
|
||||
|
||||
binding = AgentBinding(
|
||||
binding_id='binding_001',
|
||||
runner_id='plugin:test/runner/default',
|
||||
scope=BindingScope(scope_type='agent', scope_id='conv_001'),
|
||||
state_policy=StatePolicy(enable_state=False, state_scopes=[]),
|
||||
)
|
||||
|
||||
builder = AgentRunContextBuilder(mock_app)
|
||||
|
||||
context_access = await builder._build_context_access(mock_event, mock_descriptor, binding)
|
||||
|
||||
assert context_access['available_apis']['history_page'] is False
|
||||
assert context_access['available_apis']['history_search'] is False
|
||||
assert context_access['available_apis']['event_get'] is False
|
||||
assert context_access['available_apis']['event_page'] is False
|
||||
assert context_access['available_apis']['artifact_metadata'] is False
|
||||
assert context_access['available_apis']['artifact_read'] is False
|
||||
assert context_access['available_apis']['state'] is False
|
||||
assert context_access['available_apis']['storage'] is False
|
||||
|
||||
@@ -18,6 +18,7 @@ from langbot.pkg.agent.runner.context_builder import (
|
||||
AgentRunContextBuilder,
|
||||
AgentResources as BuilderResources,
|
||||
)
|
||||
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
|
||||
from langbot.pkg.agent.runner.host_models import AgentEventEnvelope, AgentBinding, BindingScope
|
||||
from langbot.pkg.core import app
|
||||
|
||||
@@ -88,13 +89,20 @@ class TestContextValidation:
|
||||
|
||||
def _make_descriptor(self):
|
||||
"""Create a mock runner descriptor."""
|
||||
descriptor = MagicMock()
|
||||
descriptor.id = "plugin:test/plugin/runner"
|
||||
descriptor.permissions = {
|
||||
'history': ['page', 'search'],
|
||||
'events': ['get', 'page'],
|
||||
}
|
||||
return descriptor
|
||||
return AgentRunnerDescriptor(
|
||||
id="plugin:test/plugin/runner",
|
||||
source="plugin",
|
||||
label={"en_US": "Test Runner"},
|
||||
plugin_author="test",
|
||||
plugin_name="plugin",
|
||||
runner_name="runner",
|
||||
permissions={
|
||||
"history": ["page", "search"],
|
||||
"events": ["get", "page"],
|
||||
"artifacts": ["metadata", "read"],
|
||||
"storage": ["plugin", "workspace"],
|
||||
},
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_context_from_event_validates(self):
|
||||
|
||||
@@ -23,12 +23,6 @@ from langbot_plugin.api.entities.builtin.agent_runner.result import (
|
||||
AgentRunResult,
|
||||
AgentRunResultType,
|
||||
)
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.capabilities import (
|
||||
AgentRunnerCapabilities,
|
||||
)
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.permissions import (
|
||||
AgentRunnerPermissions,
|
||||
)
|
||||
|
||||
# Import LangBot host models
|
||||
from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter
|
||||
@@ -57,6 +51,7 @@ class TestQueryToEventEnvelope:
|
||||
|
||||
assert event.input is not None
|
||||
assert event.input.text == "Hello world"
|
||||
assert "message_chain" not in event.input.model_dump()
|
||||
|
||||
def test_query_to_event_conversation(self, mock_query):
|
||||
"""Test conversation context extraction."""
|
||||
@@ -232,43 +227,6 @@ class TestHostManagedHistoryNotInProtocol:
|
||||
assert "messages" not in ctx_fields
|
||||
|
||||
|
||||
class TestSDKCapabilitiesProtocolV1:
|
||||
"""Test SDK capabilities for Protocol v1."""
|
||||
|
||||
def test_self_managed_context_default_true(self):
|
||||
"""Test self_managed_context defaults to True for Protocol v1."""
|
||||
caps = AgentRunnerCapabilities()
|
||||
|
||||
assert caps.self_managed_context is True
|
||||
|
||||
def test_event_context_default_true(self):
|
||||
"""Test event_context defaults to True for Protocol v1."""
|
||||
caps = AgentRunnerCapabilities()
|
||||
|
||||
assert caps.event_context is True
|
||||
|
||||
|
||||
class TestSDKPermissionsProtocolV1:
|
||||
"""Test SDK permissions for Protocol v1."""
|
||||
|
||||
def test_permissions_new_fields(self):
|
||||
"""Test new permission fields for Protocol v1."""
|
||||
perms = AgentRunnerPermissions(
|
||||
models=["invoke", "stream", "rerank"],
|
||||
tools=["detail", "call"],
|
||||
knowledge_bases=["list", "retrieve"],
|
||||
history=["page", "search"],
|
||||
events=["get", "page"],
|
||||
artifacts=["metadata", "read"],
|
||||
storage=["plugin", "workspace", "binding"],
|
||||
)
|
||||
|
||||
assert perms.history == ["page", "search"]
|
||||
assert perms.events == ["get", "page"]
|
||||
assert perms.artifacts == ["metadata", "read"]
|
||||
assert perms.storage == ["plugin", "workspace", "binding"]
|
||||
|
||||
|
||||
class TestSDKResultProtocolV1:
|
||||
"""Test SDK AgentRunResult for Protocol v1."""
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ async def _register_session(
|
||||
*,
|
||||
run_id='run_1',
|
||||
conversation_id='conv_1',
|
||||
permissions=None,
|
||||
available_apis=None,
|
||||
):
|
||||
await session_registry.register(
|
||||
run_id=run_id,
|
||||
@@ -73,13 +73,13 @@ async def _register_session(
|
||||
plugin_identity='test/runner',
|
||||
resources=make_resources(),
|
||||
conversation_id=conversation_id,
|
||||
permissions=permissions or {},
|
||||
available_apis=available_apis or {},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_history_page_requires_manifest_permission(session_registry, db_engine):
|
||||
await _register_session(session_registry, permissions={'history': []})
|
||||
async def test_history_page_requires_runtime_capability(session_registry, db_engine):
|
||||
await _register_session(session_registry, available_apis={'history_page': False})
|
||||
handler = _handler(db_engine, session_registry)
|
||||
history_page = handler.actions[PluginToRuntimeAction.HISTORY_PAGE.value]
|
||||
|
||||
@@ -94,7 +94,7 @@ async def test_history_page_requires_manifest_permission(session_registry, db_en
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_history_page_rejects_cross_conversation(session_registry, db_engine):
|
||||
await _register_session(session_registry, permissions={'history': ['page']})
|
||||
await _register_session(session_registry, available_apis={'history_page': True})
|
||||
handler = _handler(db_engine, session_registry)
|
||||
history_page = handler.actions[PluginToRuntimeAction.HISTORY_PAGE.value]
|
||||
|
||||
@@ -110,7 +110,7 @@ async def test_history_page_rejects_cross_conversation(session_registry, db_engi
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_history_search_rejects_filter_conversation_override(session_registry, db_engine):
|
||||
await _register_session(session_registry, permissions={'history': ['search']})
|
||||
await _register_session(session_registry, available_apis={'history_search': True})
|
||||
handler = _handler(db_engine, session_registry)
|
||||
history_search = handler.actions[PluginToRuntimeAction.HISTORY_SEARCH.value]
|
||||
|
||||
@@ -126,8 +126,8 @@ async def test_history_search_rejects_filter_conversation_override(session_regis
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_page_requires_manifest_permission(session_registry, db_engine):
|
||||
await _register_session(session_registry, permissions={'events': []})
|
||||
async def test_event_page_requires_runtime_capability(session_registry, db_engine):
|
||||
await _register_session(session_registry, available_apis={'event_page': False})
|
||||
handler = _handler(db_engine, session_registry)
|
||||
event_page = handler.actions[PluginToRuntimeAction.EVENT_PAGE.value]
|
||||
|
||||
@@ -142,7 +142,7 @@ async def test_event_page_requires_manifest_permission(session_registry, db_engi
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_page_rejects_cross_conversation(session_registry, db_engine):
|
||||
await _register_session(session_registry, permissions={'events': ['page']})
|
||||
await _register_session(session_registry, available_apis={'event_page': True})
|
||||
handler = _handler(db_engine, session_registry)
|
||||
event_page = handler.actions[PluginToRuntimeAction.EVENT_PAGE.value]
|
||||
|
||||
@@ -158,7 +158,7 @@ async def test_event_page_rejects_cross_conversation(session_registry, db_engine
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_get_returns_sdk_record_projection(session_registry, db_engine):
|
||||
await _register_session(session_registry, permissions={'events': ['get']})
|
||||
await _register_session(session_registry, available_apis={'event_get': True})
|
||||
store = EventLogStore(db_engine)
|
||||
event_id = await store.append_event(
|
||||
event_id='evt_projection_1',
|
||||
@@ -193,7 +193,7 @@ async def test_event_get_returns_sdk_record_projection(session_registry, db_engi
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_page_returns_sdk_page_projection(session_registry, db_engine):
|
||||
await _register_session(session_registry, permissions={'events': ['page']})
|
||||
await _register_session(session_registry, available_apis={'event_page': True})
|
||||
store = EventLogStore(db_engine)
|
||||
await store.append_event(
|
||||
event_id='evt_projection_page_1',
|
||||
|
||||
@@ -159,17 +159,19 @@ def make_descriptor() -> AgentRunnerDescriptor:
|
||||
"knowledge_retrieval": True,
|
||||
"skill_authoring": True,
|
||||
},
|
||||
permissions={
|
||||
"models": ["invoke", "stream"],
|
||||
"tools": ["detail", "call"],
|
||||
"knowledge_bases": ["list", "retrieve"],
|
||||
"history": ["page", "search"],
|
||||
"events": ["get", "page"],
|
||||
"artifacts": ["metadata", "read"],
|
||||
"storage": ["plugin"],
|
||||
},
|
||||
config_schema=[
|
||||
{"name": "model", "type": "model-fallback-selector"},
|
||||
{"name": "knowledge-bases", "type": "knowledge-base-multi-selector", "default": []},
|
||||
],
|
||||
permissions={
|
||||
"models": ["invoke", "stream"],
|
||||
"tools": ["list", "detail", "call"],
|
||||
"knowledge_bases": ["list", "retrieve"],
|
||||
"storage": ["plugin"],
|
||||
"files": [],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -13,13 +13,23 @@ from langbot.pkg.agent.runner.resource_builder import AgentResourceBuilder
|
||||
|
||||
|
||||
RUNNER_ID = 'plugin:test/runner/default'
|
||||
FULL_PERMISSIONS = {
|
||||
'models': ['invoke', 'stream', 'rerank'],
|
||||
'tools': ['detail', 'call'],
|
||||
'knowledge_bases': ['list', 'retrieve'],
|
||||
'history': ['page', 'search'],
|
||||
'events': ['get', 'page'],
|
||||
'artifacts': ['metadata', 'read'],
|
||||
'storage': ['plugin', 'workspace'],
|
||||
'files': ['config', 'knowledge'],
|
||||
}
|
||||
|
||||
|
||||
def make_descriptor(
|
||||
*,
|
||||
permissions: dict | None = None,
|
||||
config_schema: list[dict] | None = None,
|
||||
capabilities: dict | None = None,
|
||||
permissions: dict | None = None,
|
||||
) -> AgentRunnerDescriptor:
|
||||
return AgentRunnerDescriptor(
|
||||
id=RUNNER_ID,
|
||||
@@ -29,7 +39,7 @@ def make_descriptor(
|
||||
plugin_name='runner',
|
||||
runner_name='default',
|
||||
capabilities=capabilities or {},
|
||||
permissions=permissions or {'models': ['invoke', 'stream']},
|
||||
permissions=permissions if permissions is not None else FULL_PERMISSIONS,
|
||||
config_schema=config_schema or [],
|
||||
)
|
||||
|
||||
@@ -113,7 +123,6 @@ async def test_build_models_authorizes_config_declared_llm_and_rerank_models(app
|
||||
app.model_mgr.get_model_by_uuid = AsyncMock(side_effect=get_model_by_uuid)
|
||||
app.model_mgr.get_rerank_model_by_uuid = AsyncMock(side_effect=get_rerank_model_by_uuid)
|
||||
descriptor = make_descriptor(
|
||||
permissions={'models': ['invoke', 'stream', 'rerank']},
|
||||
config_schema=[
|
||||
{'name': 'model', 'type': 'model-fallback-selector'},
|
||||
{'name': 'aux-model', 'type': 'llm-model-selector'},
|
||||
@@ -137,16 +146,16 @@ async def test_build_models_authorizes_config_declared_llm_and_rerank_models(app
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_models_still_honors_manifest_permissions(app):
|
||||
"""Config-selected models should not bypass runner manifest permissions."""
|
||||
async def test_build_models_from_config_without_manifest_acl(app):
|
||||
"""Config-selected models are not projected without manifest model permissions."""
|
||||
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=make_model())
|
||||
app.model_mgr.get_rerank_model_by_uuid = AsyncMock(return_value=make_model(model_type='rerank'))
|
||||
descriptor = make_descriptor(
|
||||
permissions={'models': []},
|
||||
config_schema=[
|
||||
{'name': 'model', 'type': 'model-fallback-selector'},
|
||||
{'name': 'rerank-model', 'type': 'rerank-model-selector'},
|
||||
],
|
||||
permissions={},
|
||||
)
|
||||
query = make_query({
|
||||
'model': {'primary': 'primary', 'fallbacks': ['fallback']},
|
||||
@@ -156,19 +165,16 @@ async def test_build_models_still_honors_manifest_permissions(app):
|
||||
resources = await build_resources(app, query, descriptor)
|
||||
|
||||
assert resources['models'] == []
|
||||
app.model_mgr.get_model_by_uuid.assert_not_awaited()
|
||||
app.model_mgr.get_rerank_model_by_uuid.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_models_authorizes_rerank_only_runner(app):
|
||||
"""A rerank-only runner should receive config-selected rerank models."""
|
||||
async def test_build_models_authorizes_rerank_and_llm_refs_from_config(app):
|
||||
"""Config-selected model references are projected regardless of method granularity."""
|
||||
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=make_model())
|
||||
app.model_mgr.get_rerank_model_by_uuid = AsyncMock(
|
||||
return_value=make_model(model_type='rerank', provider='rerank-provider')
|
||||
)
|
||||
descriptor = make_descriptor(
|
||||
permissions={'models': ['rerank']},
|
||||
config_schema=[
|
||||
{'name': 'model', 'type': 'llm-model-selector'},
|
||||
{'name': 'rerank-model', 'type': 'rerank-model-selector'},
|
||||
@@ -181,10 +187,39 @@ async def test_build_models_authorizes_rerank_only_runner(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'},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_models_manifest_permission_narrows_binding(app):
|
||||
"""Manifest model permissions narrower than binding should remove LLM grants."""
|
||||
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=make_model())
|
||||
app.model_mgr.get_rerank_model_by_uuid = AsyncMock(
|
||||
return_value=make_model(model_type='rerank', provider='rerank-provider')
|
||||
)
|
||||
descriptor = make_descriptor(
|
||||
config_schema=[
|
||||
{'name': 'model', 'type': 'llm-model-selector'},
|
||||
{'name': 'rerank-model', 'type': 'rerank-model-selector'},
|
||||
],
|
||||
permissions={
|
||||
**FULL_PERMISSIONS,
|
||||
'models': ['rerank'],
|
||||
},
|
||||
)
|
||||
query = make_query({
|
||||
'model': 'llm',
|
||||
'rerank-model': 'rerank',
|
||||
})
|
||||
|
||||
resources = await build_resources(app, query, descriptor)
|
||||
|
||||
assert resources['models'] == [
|
||||
{'model_id': 'rerank', 'model_type': 'rerank', 'provider': 'rerank-provider'},
|
||||
]
|
||||
app.model_mgr.get_model_by_uuid.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -212,10 +247,7 @@ async def test_build_models_deduplicates_query_and_config_models(app):
|
||||
async def test_build_tools_authorizes_query_declared_tools(app):
|
||||
"""Tools discovered by Pipeline preprocessing become run-scoped authorized resources."""
|
||||
descriptor = make_descriptor(
|
||||
permissions={
|
||||
'models': [],
|
||||
'tools': ['detail', 'call'],
|
||||
},
|
||||
capabilities={'tool_calling': True},
|
||||
)
|
||||
query = make_query(
|
||||
{},
|
||||
@@ -241,14 +273,32 @@ async def test_build_tools_authorizes_query_declared_tools(app):
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_tools_manifest_permission_denies_binding_tools(app):
|
||||
"""Binding tool grants should be removed when manifest does not request tools."""
|
||||
descriptor = make_descriptor(
|
||||
capabilities={'tool_calling': True},
|
||||
permissions={
|
||||
**FULL_PERMISSIONS,
|
||||
'tools': [],
|
||||
},
|
||||
)
|
||||
query = make_query(
|
||||
{},
|
||||
use_funcs=[
|
||||
{'name': 'qa_plugin_echo', 'description': 'Echo test tool'},
|
||||
],
|
||||
)
|
||||
|
||||
resources = await build_resources(app, query, descriptor)
|
||||
|
||||
assert resources['tools'] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_knowledge_bases_unions_config_and_policy_grants(app):
|
||||
descriptor = make_descriptor(
|
||||
capabilities={'knowledge_retrieval': True},
|
||||
permissions={
|
||||
'models': [],
|
||||
'knowledge_bases': ['retrieve'],
|
||||
},
|
||||
config_schema=[
|
||||
{'name': 'knowledge-bases', 'type': 'knowledge-base-multi-selector'},
|
||||
],
|
||||
@@ -273,3 +323,43 @@ async def test_build_knowledge_bases_unions_config_and_policy_grants(app):
|
||||
{'kb_id': 'kb_config', 'kb_name': 'name-kb_config', 'kb_type': 'default'},
|
||||
{'kb_id': 'kb_policy', 'kb_name': 'name-kb_policy', 'kb_type': 'default'},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_knowledge_bases_manifest_permission_denies_binding_kbs(app):
|
||||
descriptor = make_descriptor(
|
||||
capabilities={'knowledge_retrieval': True},
|
||||
permissions={
|
||||
**FULL_PERMISSIONS,
|
||||
'knowledge_bases': [],
|
||||
},
|
||||
config_schema=[
|
||||
{'name': 'knowledge-bases', 'type': 'knowledge-base-multi-selector'},
|
||||
],
|
||||
)
|
||||
query = make_query(
|
||||
{'knowledge-bases': ['kb_config']},
|
||||
variables={'_knowledge_base_uuids': ['kb_policy']},
|
||||
)
|
||||
|
||||
resources = await build_resources(app, query, descriptor)
|
||||
|
||||
assert resources['knowledge_bases'] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_storage_intersects_manifest_and_binding_policy(app):
|
||||
descriptor = make_descriptor(
|
||||
permissions={
|
||||
**FULL_PERMISSIONS,
|
||||
'storage': ['plugin'],
|
||||
},
|
||||
)
|
||||
query = make_query({})
|
||||
|
||||
resources = await build_resources(app, query, descriptor)
|
||||
|
||||
assert resources['storage'] == {
|
||||
'plugin_storage': True,
|
||||
'workspace_storage': False,
|
||||
}
|
||||
|
||||
@@ -14,12 +14,15 @@ class FakeApplication:
|
||||
"""Fake Application for testing."""
|
||||
def __init__(self):
|
||||
class FakeLogger:
|
||||
def __init__(self):
|
||||
self.warnings = []
|
||||
|
||||
def info(self, msg):
|
||||
pass
|
||||
def debug(self, msg):
|
||||
pass
|
||||
def warning(self, msg):
|
||||
pass
|
||||
self.warnings.append(msg)
|
||||
def error(self, msg):
|
||||
pass
|
||||
|
||||
@@ -67,7 +70,7 @@ class TestNormalizeMessageDelta:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_normalize_message_delta_missing_chunk(self):
|
||||
"""Normalize message.delta without chunk data."""
|
||||
"""Invalid message.delta payload is dropped."""
|
||||
normalizer = AgentResultNormalizer(FakeApplication())
|
||||
descriptor = make_descriptor()
|
||||
|
||||
@@ -76,10 +79,9 @@ class TestNormalizeMessageDelta:
|
||||
'data': {},
|
||||
}
|
||||
|
||||
with pytest.raises(RunnerProtocolError) as exc_info:
|
||||
await normalizer.normalize(result_dict, descriptor)
|
||||
result = await normalizer.normalize(result_dict, descriptor)
|
||||
|
||||
assert 'missing chunk data' in str(exc_info.value)
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestNormalizeMessageCompleted:
|
||||
@@ -110,7 +112,7 @@ class TestNormalizeMessageCompleted:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_normalize_message_completed_missing_message(self):
|
||||
"""Normalize message.completed without message data."""
|
||||
"""Invalid message.completed payload is dropped."""
|
||||
normalizer = AgentResultNormalizer(FakeApplication())
|
||||
descriptor = make_descriptor()
|
||||
|
||||
@@ -119,10 +121,9 @@ class TestNormalizeMessageCompleted:
|
||||
'data': {},
|
||||
}
|
||||
|
||||
with pytest.raises(RunnerProtocolError) as exc_info:
|
||||
await normalizer.normalize(result_dict, descriptor)
|
||||
result = await normalizer.normalize(result_dict, descriptor)
|
||||
|
||||
assert 'missing message data' in str(exc_info.value)
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestNormalizeRunCompleted:
|
||||
@@ -260,13 +261,57 @@ class TestNormalizeNonMessageResults:
|
||||
'type': 'action.requested',
|
||||
'data': {
|
||||
'action': 'platform.message.edit',
|
||||
'parameters': {},
|
||||
'payload': {},
|
||||
},
|
||||
}
|
||||
|
||||
result = await normalizer.normalize(result_dict, descriptor)
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_state_updated_payload_is_dropped(self):
|
||||
"""Invalid state.updated payload returns None with a warning."""
|
||||
app = FakeApplication()
|
||||
normalizer = AgentResultNormalizer(app)
|
||||
descriptor = make_descriptor()
|
||||
|
||||
result = await normalizer.normalize(
|
||||
{
|
||||
'type': 'state.updated',
|
||||
'data': {
|
||||
'scope': 'invalid',
|
||||
'key': 'k',
|
||||
'value': 'v',
|
||||
},
|
||||
},
|
||||
descriptor,
|
||||
)
|
||||
|
||||
assert result is None
|
||||
assert app.logger.warnings
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_artifact_created_payload_is_dropped(self):
|
||||
"""Invalid artifact.created payload returns None with a warning."""
|
||||
app = FakeApplication()
|
||||
normalizer = AgentResultNormalizer(app)
|
||||
descriptor = make_descriptor()
|
||||
|
||||
result = await normalizer.normalize(
|
||||
{
|
||||
'type': 'artifact.created',
|
||||
'data': {
|
||||
'artifact_id': 'artifact-1',
|
||||
'artifact_type': 'file',
|
||||
'content_base64': 'not base64',
|
||||
},
|
||||
},
|
||||
descriptor,
|
||||
)
|
||||
|
||||
assert result is None
|
||||
assert app.logger.warnings
|
||||
|
||||
|
||||
class TestNormalizeInvalidResults:
|
||||
"""Tests for handling invalid results."""
|
||||
|
||||
@@ -63,7 +63,7 @@ class TestSessionRegistryBasic:
|
||||
query_id=1,
|
||||
plugin_identity='test/my-runner',
|
||||
resources=resources,
|
||||
permissions={'models': ['invoke']},
|
||||
available_apis={'history_page': True},
|
||||
conversation_id='conv_001',
|
||||
)
|
||||
|
||||
@@ -74,7 +74,7 @@ class TestSessionRegistryBasic:
|
||||
assert session is not None
|
||||
authorization = session['authorization']
|
||||
assert authorization['conversation_id'] == 'conv_001'
|
||||
assert authorization['permissions'] == {'models': ['invoke']}
|
||||
assert authorization['available_apis'] == {'history_page': True}
|
||||
assert registry.is_resource_allowed(session, 'model', 'model_001') is True
|
||||
assert registry.is_resource_allowed(session, 'model', 'model_late') is False
|
||||
assert registry.is_resource_allowed(session, 'storage', 'workspace') is False
|
||||
|
||||
Reference in New Issue
Block a user