refactor(agent-runner): make agent binding and auth snapshot explicit

This commit is contained in:
huanghuoguoguo
2026-06-03 18:45:27 +08:00
parent a850127893
commit 08c51118c5
22 changed files with 530 additions and 411 deletions

View File

@@ -39,6 +39,10 @@ def make_session(
query_id: int | None = 1,
plugin_identity: str = 'test/test-runner',
resources: dict | None = None,
conversation_id: str | None = None,
permissions: dict[str, list[str]] | None = None,
state_policy: dict[str, typing.Any] | None = None,
state_context: dict[str, typing.Any] | None = None,
) -> dict[str, typing.Any]:
"""Create a minimal AgentRunSession dict for testing.
@@ -50,13 +54,19 @@ def make_session(
resources: AgentResources dict (uses make_resources() default if None)
Returns:
AgentRunSession dict with all required fields including pre-computed _authorized_ids
AgentRunSession dict with run-scoped authorization snapshot
"""
import time
now = int(time.time())
res = resources or make_resources()
res = resources if resources is not None else make_resources()
perms = permissions if permissions is not None else {}
policy = (
state_policy
if state_policy is not None
else {'enable_state': True, 'state_scopes': ['conversation', 'actor']}
)
context = state_context if state_context is not None else {}
# Pre-compute authorized IDs for O(1) lookup (matching production behavior)
authorized_ids: dict[str, set[str]] = {
'model': {m.get('model_id') for m in res.get('models', [])},
'tool': {t.get('tool_name') for t in res.get('tools', [])},
@@ -69,10 +79,16 @@ def make_session(
'runner_id': runner_id,
'query_id': query_id,
'plugin_identity': plugin_identity,
'resources': res,
'authorization': {
'resources': res,
'permissions': perms,
'conversation_id': conversation_id,
'state_policy': policy,
'state_context': context,
'authorized_ids': authorized_ids,
},
'status': {
'started_at': now,
'last_activity_at': now,
},
'_authorized_ids': authorized_ids,
}

View File

@@ -12,6 +12,7 @@ from langbot.pkg.agent.runner.session_registry import (
AgentRunSessionRegistry,
get_session_registry,
)
from .conftest import make_session
class TestArtifactStore:
@@ -210,6 +211,13 @@ class TestArtifactAuthorization:
class TestArtifactAccessValidation:
"""Test _validate_artifact_access authorization rules."""
def _make_session(self, conversation_id: str | None):
return make_session(
run_id="run_001",
conversation_id=conversation_id,
permissions={"artifacts": ["metadata", "read"]},
)
def _call_validate(self, session, metadata, operation="metadata"):
"""Helper to call the validation function."""
from langbot.pkg.plugin.handler import _validate_artifact_access
@@ -217,11 +225,7 @@ class TestArtifactAccessValidation:
def test_global_artifact_denied_by_default(self):
"""Artifacts without conversation_id are denied by default (no global access)."""
session = {
"run_id": "run_001",
"conversation_id": "conv_001",
"permissions": {"artifacts": ["metadata", "read"]},
}
session = self._make_session("conv_001")
metadata = {
"artifact_id": "art_global",
"conversation_id": None, # No conversation scope
@@ -234,11 +238,7 @@ class TestArtifactAccessValidation:
def test_own_run_artifact_allowed(self):
"""Artifacts created by same run are allowed (even cross-conversation)."""
session = {
"run_id": "run_001",
"conversation_id": "conv_001",
"permissions": {"artifacts": ["metadata", "read"]},
}
session = self._make_session("conv_001")
metadata = {
"artifact_id": "art_001",
"conversation_id": "conv_other", # Different conversation
@@ -251,11 +251,7 @@ class TestArtifactAccessValidation:
def test_same_conversation_allowed(self):
"""Artifacts in same conversation are allowed."""
session = {
"run_id": "run_001",
"conversation_id": "conv_001",
"permissions": {"artifacts": ["metadata", "read"]},
}
session = self._make_session("conv_001")
metadata = {
"artifact_id": "art_001",
"conversation_id": "conv_001", # Same as session
@@ -268,11 +264,7 @@ class TestArtifactAccessValidation:
def test_different_conversation_and_run_denied(self):
"""Artifacts in different conversation and different run are denied."""
session = {
"run_id": "run_001",
"conversation_id": "conv_001",
"permissions": {"artifacts": ["metadata", "read"]},
}
session = self._make_session("conv_001")
metadata = {
"artifact_id": "art_001",
"conversation_id": "conv_other", # Different conversation
@@ -285,11 +277,7 @@ class TestArtifactAccessValidation:
def test_session_without_conversation_denied_for_conversation_artifact(self):
"""Session without conversation_id cannot access conversation-scoped artifacts."""
session = {
"run_id": "run_001",
"conversation_id": None, # No conversation
"permissions": {"artifacts": ["metadata", "read"]},
}
session = self._make_session(None)
metadata = {
"artifact_id": "art_001",
"conversation_id": "conv_001", # Has conversation
@@ -301,11 +289,7 @@ class TestArtifactAccessValidation:
def test_session_without_conversation_allowed_for_own_artifact(self):
"""Session without conversation can access artifacts it created."""
session = {
"run_id": "run_001",
"conversation_id": None, # No conversation
"permissions": {"artifacts": ["metadata", "read"]},
}
session = self._make_session(None)
metadata = {
"artifact_id": "art_001",
"conversation_id": "conv_001", # Has conversation
@@ -431,9 +415,10 @@ class TestSessionRegistryPermissions:
session = await session_registry.get("run_001")
assert session is not None
assert session["permissions"]["artifacts"] == ["metadata", "read"]
assert session["permissions"]["history"] == ["page"]
assert session["permissions"]["events"] == ["get"]
permissions = session["authorization"]["permissions"]
assert permissions["artifacts"] == ["metadata", "read"]
assert permissions["history"] == ["page"]
assert permissions["events"] == ["get"]
@pytest.mark.asyncio
async def test_register_with_empty_permissions(self, session_registry):
@@ -457,7 +442,7 @@ class TestSessionRegistryPermissions:
session = await session_registry.get("run_002")
assert session is not None
assert session["permissions"] == {}
assert session["authorization"]["permissions"] == {}
class TestArtifactStoreRealSQLite:

View File

@@ -97,6 +97,7 @@ class MockMessageChunk:
self.role = 'assistant'
self.content = content
self.resp_message_id = resp_message_id
self.tool_calls = []
self.is_final = False
def readable_str(self):

View File

@@ -2,7 +2,7 @@
Tests cover:
1. Query -> AgentEventEnvelope conversion
2. Current config -> AgentBinding conversion
2. Current config -> AgentConfig projection and single-binding resolution
3. AgentRunContext not inlining full history by default
4. LangBot Host not defining context-window controls
5. Event-first run() entry point
@@ -32,6 +32,10 @@ from langbot_plugin.api.entities.builtin.agent_runner.permissions import (
# Import LangBot host models
from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter
from langbot.pkg.agent.runner.binding_resolver import (
AgentBindingResolver,
AgentBindingResolutionError,
)
class TestQueryToEventEnvelope:
@@ -127,27 +131,40 @@ class TestQueryToEventEnvelope:
assert second.event_id != first.event_id
class TestQueryConfigToBinding:
"""Test current config -> AgentBinding conversion."""
class TestQueryConfigToAgentConfig:
"""Test current config projection and single-Agent binding resolution."""
def test_config_to_binding_runner_id(self, mock_query):
"""Test binding runner_id extraction."""
binding = QueryEntryAdapter.config_to_binding(
def test_config_to_agent_config_runner_id(self, mock_query):
"""Test AgentConfig runner_id extraction."""
agent_config = QueryEntryAdapter.config_to_agent_config(
mock_query, "plugin:author/plugin/runner"
)
assert binding.runner_id == "plugin:author/plugin/runner"
assert agent_config.runner_id == "plugin:author/plugin/runner"
def test_config_to_binding_scope(self, mock_query):
"""Test binding scope extraction."""
binding = QueryEntryAdapter.config_to_binding(
def test_resolver_projects_agent_scope(self, mock_query):
"""Test binding scope projection through the resolver."""
event = QueryEntryAdapter.query_to_event(mock_query)
agent_config = QueryEntryAdapter.config_to_agent_config(
mock_query, "plugin:test/plugin/runner"
)
binding = AgentBindingResolver().resolve_one(event, [agent_config])
assert binding.scope.scope_type == "agent"
assert binding.scope.scope_id == mock_query.pipeline_uuid
assert binding.agent_id == mock_query.pipeline_uuid
def test_resolver_rejects_multiple_matching_agents(self, mock_query):
"""Event dispatch is single-Agent in v1."""
event = QueryEntryAdapter.query_to_event(mock_query)
first = QueryEntryAdapter.config_to_agent_config(
mock_query, "plugin:test/plugin/runner"
)
second = first.model_copy(update={"agent_id": "agent_2"})
with pytest.raises(AgentBindingResolutionError):
AgentBindingResolver().resolve_one(event, [first, second])
class TestAgentRunContextProtocolV1:
"""Test AgentRunContext Protocol v1 behavior."""

View File

@@ -313,7 +313,7 @@ class TestHistoryPageAuthorization:
session = await session_registry.get("run_1")
assert session is not None
assert session["conversation_id"] == "conv_1"
assert session["authorization"]["conversation_id"] == "conv_1"
# Cleanup
await session_registry.unregister("run_1")

View File

@@ -22,7 +22,7 @@ from langbot.pkg.agent.runner.session_registry import AgentRunSessionRegistry
from langbot.pkg.plugin.handler import _build_tool_detail, _get_pipeline_knowledge_base_uuids
# Import shared test fixtures from conftest.py
from .conftest import make_resources
from .conftest import make_resources, make_session
class MockModel:
@@ -1152,15 +1152,7 @@ class TestResourceTypeValidation:
registry = AgentRunSessionRegistry()
resources = make_resources()
# Create session manually for this test
session = {
'run_id': 'test',
'runner_id': 'test',
'query_id': 1,
'plugin_identity': 'test',
'resources': resources,
'status': {'started_at': 0, 'last_activity_at': 0},
}
session = make_session(resources=resources)
# Unknown resource type should return False
assert registry.is_resource_allowed(session, 'unknown_type', 'any_id') is False
@@ -1487,15 +1479,7 @@ class TestStorageResourcePermissionHelper:
"""is_resource_allowed handles missing storage field gracefully."""
registry = AgentRunSessionRegistry()
# Create session without storage field
session = {
'run_id': 'test',
'runner_id': 'test',
'query_id': 1,
'plugin_identity': 'test',
'resources': {}, # No storage field
'status': {'started_at': 0, 'last_activity_at': 0},
}
session = make_session(resources={})
# Should return False for both storage types
assert registry.is_resource_allowed(session, 'storage', 'plugin') is False

View File

@@ -13,6 +13,7 @@ from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
from langbot.pkg.agent.runner.errors import RunnerExecutionError
from langbot.pkg.agent.runner.orchestrator import AgentRunOrchestrator
from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter
from langbot.pkg.agent.runner.binding_resolver import AgentBindingResolver
from langbot.pkg.agent.runner.session_registry import get_session_registry
from langbot.pkg.agent.runner.persistent_state_store import reset_persistent_state_store
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
@@ -327,7 +328,7 @@ async def test_orchestrator_runs_fake_plugin_with_authorized_context(clean_agent
session_during_run = plugin_connector.sessions_during_run[0]
assert session_during_run is not None
assert session_during_run["plugin_identity"] == "langbot/local-agent"
assert session_during_run["_authorized_ids"]["tool"] == {"langbot/test-tool/search"}
assert session_during_run["authorization"]["authorized_ids"]["tool"] == {"langbot/test-tool/search"}
assert await get_session_registry().get(context["run_id"]) is None
@@ -758,7 +759,8 @@ class TestQueryEntryAdapterHostCapabilities:
# Note: We need to rebuild the event and binding to query the store
from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter
event = QueryEntryAdapter.query_to_event(query)
binding = QueryEntryAdapter.config_to_binding(query, RUNNER_ID)
agent_config = QueryEntryAdapter.config_to_agent_config(query, RUNNER_ID)
binding = AgentBindingResolver().resolve_one(event, [agent_config])
snapshot = await persistent_store.build_snapshot_from_event(event, binding, descriptor)
assert snapshot["conversation"]["external.test_key"] == "test_value"

View File

@@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, Mock
import pytest
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
from langbot.pkg.agent.runner.binding_resolver import AgentBindingResolver
from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter
from langbot.pkg.agent.runner.resource_builder import AgentResourceBuilder
@@ -48,6 +49,15 @@ def make_query(
use_funcs: list | None = None,
):
return SimpleNamespace(
query_id=1,
bot_uuid='bot_001',
launcher_type='person',
launcher_id='launcher_001',
sender_id='sender_001',
message_event=None,
message_chain=None,
user_message=None,
session=None,
pipeline_config={
'ai': {
'runner': {'id': RUNNER_ID},
@@ -62,9 +72,11 @@ def make_query(
async def build_resources(app, query, descriptor):
binding = QueryEntryAdapter.config_to_binding(query, descriptor.id)
event = QueryEntryAdapter.query_to_event(query)
agent_config = QueryEntryAdapter.config_to_agent_config(query, descriptor.id)
binding = AgentBindingResolver().resolve_one(event, [agent_config])
return await AgentResourceBuilder(app).build_resources_from_binding(
event=Mock(),
event=event,
binding=binding,
descriptor=descriptor,
)

View File

@@ -41,8 +41,43 @@ class TestSessionRegistryBasic:
assert result['runner_id'] == 'plugin:test/my-runner/default'
assert result['query_id'] == 1
assert result['plugin_identity'] == 'test/my-runner'
assert len(result['resources']['models']) == 1
assert result['resources']['models'][0]['model_id'] == 'model_001'
auth_resources = result['authorization']['resources']
assert len(auth_resources['models']) == 1
assert auth_resources['models'][0]['model_id'] == 'model_001'
assert 'resources' not in result
assert 'permissions' not in result
assert '_authorized_ids' not in result
@pytest.mark.asyncio
async def test_register_freezes_authorization_snapshot(self):
"""Register should freeze authorization data for the run."""
registry = AgentRunSessionRegistry()
resources = make_resources(
models=[{'model_id': 'model_001'}],
storage={'plugin_storage': True, 'workspace_storage': False},
)
await registry.register(
run_id='run_snapshot',
runner_id='plugin:test/my-runner/default',
query_id=1,
plugin_identity='test/my-runner',
resources=resources,
permissions={'models': ['invoke']},
conversation_id='conv_001',
)
resources['models'].append({'model_id': 'model_late'})
resources['storage']['workspace_storage'] = True
session = await registry.get('run_snapshot')
assert session is not None
authorization = session['authorization']
assert authorization['conversation_id'] == 'conv_001'
assert authorization['permissions'] == {'models': ['invoke']}
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
@pytest.mark.asyncio
async def test_get_nonexistent_session(self):
@@ -91,23 +126,15 @@ class TestSessionRegistryBasic:
# Create session with manually set old timestamp
now = int(time.time())
res = make_resources()
old_session: AgentRunSession = {
'run_id': run_id,
'runner_id': 'plugin:test/my-runner/default',
'query_id': 1,
'plugin_identity': 'test/my-runner',
'resources': res,
'status': {
'started_at': now - 100, # 100 seconds ago
'last_activity_at': now - 100, # 100 seconds ago
},
'_authorized_ids': {
'model': set(),
'tool': set(),
'knowledge_base': set(),
'file': set(),
},
old_session: AgentRunSession = make_session(
run_id=run_id,
runner_id='plugin:test/my-runner/default',
query_id=1,
plugin_identity='test/my-runner',
)
old_session['status'] = {
'started_at': now - 100,
'last_activity_at': now - 100,
}
async with registry._lock:
@@ -153,40 +180,25 @@ class TestSessionRegistryBasic:
# Create sessions with manually set old timestamp
now = int(time.time())
res = make_resources()
old_session: AgentRunSession = {
'run_id': 'old_run',
'runner_id': 'plugin:test/runner/default',
'query_id': 1,
'plugin_identity': 'test/runner',
'resources': res,
'status': {
'started_at': now - 7200, # 2 hours ago
'last_activity_at': now - 7200, # 2 hours ago
},
'_authorized_ids': {
'model': set(),
'tool': set(),
'knowledge_base': set(),
'file': set(),
},
old_session: AgentRunSession = make_session(
run_id='old_run',
runner_id='plugin:test/runner/default',
query_id=1,
plugin_identity='test/runner',
)
old_session['status'] = {
'started_at': now - 7200,
'last_activity_at': now - 7200,
}
new_session: AgentRunSession = {
'run_id': 'new_run',
'runner_id': 'plugin:test/runner/default',
'query_id': 2,
'plugin_identity': 'test/runner',
'resources': res,
'status': {
'started_at': now,
'last_activity_at': now,
},
'_authorized_ids': {
'model': set(),
'tool': set(),
'knowledge_base': set(),
'file': set(),
},
new_session: AgentRunSession = make_session(
run_id='new_run',
runner_id='plugin:test/runner/default',
query_id=2,
plugin_identity='test/runner',
)
new_session['status'] = {
'started_at': now,
'last_activity_at': now,
}
async with registry._lock:

View File

@@ -342,7 +342,7 @@ class TestStateAPIFullFlowWithRealDB:
# Verify session has correct state_context
session = await session_registry.get('run_full_flow')
assert session is not None
state_ctx = session.get('state_context')
state_ctx = session['authorization']['state_context']
assert state_ctx is not None, f"state_context is None. Session keys: {list(session.keys())}"
assert 'scope_keys' in state_ctx, f"scope_keys not in state_context: {state_ctx}"
assert 'conversation' in state_ctx['scope_keys'], f"conversation not in scope_keys: {state_ctx['scope_keys']}"
@@ -412,31 +412,31 @@ class TestStateAPIFullFlowWithRealDB:
await session_registry.unregister('run_full_flow')
class TestStateHandlerReadsFromSessionTopLevel:
"""Tests verifying handlers read state_policy/state_context from session top-level, not resources."""
class TestStateHandlerReadsFromAuthorizationSnapshot:
"""Tests verifying handlers read state_policy/state_context from authorization snapshot."""
@pytest.mark.asyncio
async def test_state_handler_reads_state_policy_from_session_top_level(self, session_registry, db_engine, persistent_store):
"""Handler reads state_policy from session['state_policy'], not session['resources']['state_policy']."""
async def test_state_handler_reads_state_policy_from_authorization(self, session_registry, db_engine, persistent_store):
"""Handler reads state_policy from session['authorization'], not resources."""
fake_app = FakeApplication(db_engine)
fake_app.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine)
# Register with explicit state_policy at top level
# Register with explicit state_policy in the authorization snapshot
await session_registry.register(
run_id='run_policy_top_level',
runner_id='plugin:test/runner/default',
query_id=1,
plugin_identity='test/runner',
resources=make_resources(),
state_policy={'enable_state': False, 'state_scopes': []}, # Disabled at top level
state_policy={'enable_state': False, 'state_scopes': []},
state_context={'scope_keys': {}, 'binding_identity': 'binding_1'},
)
# Verify resources does NOT contain state_policy
session = await session_registry.get('run_policy_top_level')
assert session is not None
assert 'state_policy' not in session.get('resources', {}), \
"resources should NOT contain state_policy"
resources = session['authorization']['resources']
assert 'state_policy' not in resources, "resources should NOT contain state_policy"
async def fake_disconnect():
return True
@@ -445,7 +445,7 @@ class TestStateHandlerReadsFromSessionTopLevel:
handler = RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app)
state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value]
# Should fail because enable_state=False in session['state_policy']
# Should fail because enable_state=False in authorization.state_policy
result = await state_get_handler({
'run_id': 'run_policy_top_level',
'scope': 'conversation',
@@ -459,12 +459,12 @@ class TestStateHandlerReadsFromSessionTopLevel:
await session_registry.unregister('run_policy_top_level')
@pytest.mark.asyncio
async def test_state_handler_reads_state_context_from_session_top_level(self, session_registry, db_engine, persistent_store):
"""Handler reads state_context from session['state_context'], not session['resources']['state_context']."""
async def test_state_handler_reads_state_context_from_authorization(self, session_registry, db_engine, persistent_store):
"""Handler reads state_context from session['authorization'], not resources."""
fake_app = FakeApplication(db_engine)
fake_app.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine)
# Register with explicit state_context at top level
# Register with explicit state_context in the authorization snapshot
await session_registry.register(
run_id='run_context_top_level',
runner_id='plugin:test/runner/default',
@@ -478,8 +478,8 @@ class TestStateHandlerReadsFromSessionTopLevel:
# Verify resources does NOT contain state_context
session = await session_registry.get('run_context_top_level')
assert session is not None
assert 'state_context' not in session.get('resources', {}), \
"resources should NOT contain state_context"
resources = session['authorization']['resources']
assert 'state_context' not in resources, "resources should NOT contain state_context"
async def fake_disconnect():
return True
@@ -488,7 +488,7 @@ class TestStateHandlerReadsFromSessionTopLevel:
handler = RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app)
state_set_handler = handler.actions[PluginToRuntimeAction.STATE_SET.value]
# Should use scope_key from session['state_context']['scope_keys']['conversation']
# Should use scope_key from authorization.state_context.scope_keys.conversation
result = await state_set_handler({
'run_id': 'run_context_top_level',
'scope': 'conversation',
@@ -508,7 +508,7 @@ class TestResourcesDoesNotContainStateMetadata:
@pytest.mark.asyncio
async def test_resources_clean_after_register(self, session_registry):
"""After register(), resources should not contain state_policy or state_context."""
"""After register(), only authorization contains resources and state metadata."""
resources = make_resources()
await session_registry.register(
@@ -524,15 +524,15 @@ class TestResourcesDoesNotContainStateMetadata:
session = await session_registry.get('run_resources_clean')
assert session is not None
# Verify resources is clean
session_resources = session.get('resources', {})
# Verify resources is nested under authorization and is clean.
assert 'resources' not in session
session_resources = session['authorization']['resources']
assert 'state_policy' not in session_resources, \
"session['resources'] should NOT contain state_policy"
"authorization['resources'] should NOT contain state_policy"
assert 'state_context' not in session_resources, \
"session['resources'] should NOT contain state_context"
"authorization['resources'] should NOT contain state_context"
# Verify state metadata is at top level
assert 'state_policy' in session
assert 'state_context' in session
assert 'state_policy' in session['authorization']
assert 'state_context' in session['authorization']
await session_registry.unregister('run_resources_clean')