mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-17 11:14:19 +00:00
fix: harden agent runner runtime boundaries
This commit is contained in:
@@ -43,6 +43,9 @@ def make_session(
|
||||
plugin_identity: str = 'test/test-runner',
|
||||
resources: dict | None = None,
|
||||
conversation_id: str | None = None,
|
||||
bot_id: str | None = None,
|
||||
workspace_id: str | None = None,
|
||||
thread_id: 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,
|
||||
@@ -114,6 +117,9 @@ def make_session(
|
||||
'resources': res,
|
||||
'available_apis': apis,
|
||||
'conversation_id': conversation_id,
|
||||
'bot_id': bot_id,
|
||||
'workspace_id': workspace_id,
|
||||
'thread_id': thread_id,
|
||||
'state_policy': policy,
|
||||
'state_context': context,
|
||||
'authorized_ids': authorized_ids,
|
||||
|
||||
@@ -208,10 +208,20 @@ class TestArtifactAuthorization:
|
||||
class TestArtifactAccessValidation:
|
||||
"""Test _validate_artifact_access authorization rules."""
|
||||
|
||||
def _make_session(self, conversation_id: str | None):
|
||||
def _make_session(
|
||||
self,
|
||||
conversation_id: str | None,
|
||||
*,
|
||||
bot_id: str | None = None,
|
||||
workspace_id: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
):
|
||||
return make_session(
|
||||
run_id="run_001",
|
||||
conversation_id=conversation_id,
|
||||
bot_id=bot_id,
|
||||
workspace_id=workspace_id,
|
||||
thread_id=thread_id,
|
||||
available_apis={"artifact_metadata": True, "artifact_read": True},
|
||||
)
|
||||
|
||||
@@ -259,6 +269,64 @@ class TestArtifactAccessValidation:
|
||||
assert is_allowed is True
|
||||
assert error is None
|
||||
|
||||
def test_same_conversation_and_scope_allowed(self):
|
||||
"""Artifacts in the same run scope are allowed across runs."""
|
||||
session = self._make_session(
|
||||
"conv_001",
|
||||
bot_id="bot_001",
|
||||
workspace_id="workspace_001",
|
||||
thread_id="thread_001",
|
||||
)
|
||||
metadata = {
|
||||
"artifact_id": "art_001",
|
||||
"conversation_id": "conv_001",
|
||||
"run_id": "run_other",
|
||||
"bot_id": "bot_001",
|
||||
"workspace_id": "workspace_001",
|
||||
"thread_id": "thread_001",
|
||||
}
|
||||
|
||||
is_allowed, error = self._call_validate(session, metadata)
|
||||
assert is_allowed is True
|
||||
assert error is None
|
||||
|
||||
def test_same_conversation_different_scope_denied(self):
|
||||
"""Artifacts in another bot/thread scope are denied even in the same conversation."""
|
||||
session = self._make_session(
|
||||
"conv_001",
|
||||
bot_id="bot_001",
|
||||
workspace_id="workspace_001",
|
||||
thread_id="thread_001",
|
||||
)
|
||||
metadata = {
|
||||
"artifact_id": "art_001",
|
||||
"conversation_id": "conv_001",
|
||||
"run_id": "run_other",
|
||||
"bot_id": "bot_002",
|
||||
"workspace_id": "workspace_001",
|
||||
"thread_id": "thread_001",
|
||||
}
|
||||
|
||||
is_allowed, error = self._call_validate(session, metadata)
|
||||
assert is_allowed is False
|
||||
assert "denied" in error.lower()
|
||||
|
||||
def test_same_conversation_missing_scope_denied_for_scoped_session(self):
|
||||
"""Scoped runs should not read legacy-scope artifacts from other runs."""
|
||||
session = self._make_session("conv_001", bot_id="bot_001")
|
||||
metadata = {
|
||||
"artifact_id": "art_001",
|
||||
"conversation_id": "conv_001",
|
||||
"run_id": "run_other",
|
||||
"bot_id": None,
|
||||
"workspace_id": None,
|
||||
"thread_id": None,
|
||||
}
|
||||
|
||||
is_allowed, error = self._call_validate(session, metadata)
|
||||
assert is_allowed is False
|
||||
assert "denied" in error.lower()
|
||||
|
||||
def test_different_conversation_and_run_denied(self):
|
||||
"""Artifacts in different conversation and different run are denied."""
|
||||
session = self._make_session("conv_001")
|
||||
@@ -470,6 +538,9 @@ class TestArtifactStoreRealSQLite:
|
||||
content=content,
|
||||
conversation_id="conv_001",
|
||||
run_id="run_001",
|
||||
bot_id="bot_001",
|
||||
workspace_id="workspace_001",
|
||||
thread_id="thread_001",
|
||||
)
|
||||
|
||||
assert artifact_id == "art_real_001"
|
||||
@@ -489,6 +560,14 @@ class TestArtifactStoreRealSQLite:
|
||||
assert "storage_type" not in metadata
|
||||
assert "bot_id" not in metadata
|
||||
assert "workspace_id" not in metadata
|
||||
assert "thread_id" not in metadata
|
||||
assert "_langbot_thread_id" not in metadata.get("metadata", {})
|
||||
|
||||
auth_metadata = await store.get_authorization_metadata(artifact_id)
|
||||
assert auth_metadata is not None
|
||||
assert auth_metadata["bot_id"] == "bot_001"
|
||||
assert auth_metadata["workspace_id"] == "workspace_001"
|
||||
assert auth_metadata["thread_id"] == "thread_001"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_artifact_round_trip(self, db_engine):
|
||||
|
||||
@@ -628,6 +628,52 @@ class TestTranscriptStoreRealSQLite:
|
||||
assert messages[0].content[0].text == "User structured text"
|
||||
assert messages[1].content == "Assistant text"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_legacy_provider_messages_filters_scope(self, db_engine):
|
||||
"""Legacy Pipeline history projection must stay inside the current run scope."""
|
||||
store = TranscriptStore(db_engine)
|
||||
|
||||
await store.append_transcript(
|
||||
transcript_id="trans_scope_001",
|
||||
event_id="evt_scope_001",
|
||||
conversation_id="conv_scope",
|
||||
bot_id="bot_001",
|
||||
workspace_id="workspace_001",
|
||||
thread_id="thread_001",
|
||||
role="user",
|
||||
content="Current scope text",
|
||||
)
|
||||
await store.append_transcript(
|
||||
transcript_id="trans_scope_002",
|
||||
event_id="evt_scope_002",
|
||||
conversation_id="conv_scope",
|
||||
bot_id="bot_002",
|
||||
workspace_id="workspace_001",
|
||||
thread_id="thread_001",
|
||||
role="assistant",
|
||||
content="Other bot text",
|
||||
)
|
||||
await store.append_transcript(
|
||||
transcript_id="trans_scope_003",
|
||||
event_id="evt_scope_003",
|
||||
conversation_id="conv_scope",
|
||||
bot_id="bot_001",
|
||||
workspace_id="workspace_001",
|
||||
thread_id="thread_002",
|
||||
role="assistant",
|
||||
content="Other thread text",
|
||||
)
|
||||
|
||||
messages = await store.get_legacy_provider_messages(
|
||||
"conv_scope",
|
||||
bot_id="bot_001",
|
||||
workspace_id="workspace_001",
|
||||
thread_id="thread_001",
|
||||
strict_thread=True,
|
||||
)
|
||||
|
||||
assert [message.content for message in messages] == ["Current scope text"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_transcript_real_db(self, db_engine):
|
||||
"""Test search_transcript with real DB."""
|
||||
|
||||
@@ -193,6 +193,41 @@ async def test_build_models_authorizes_rerank_and_llm_refs_from_config(app):
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_resources_accepts_dynamic_form_type_aliases(app):
|
||||
"""Frontend DynamicForm aliases should resolve to runtime resource grants."""
|
||||
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=make_model())
|
||||
|
||||
async def get_kb(kb_uuid):
|
||||
return SimpleNamespace(
|
||||
uuid=kb_uuid,
|
||||
get_name=lambda: f'name-{kb_uuid}',
|
||||
knowledge_base_entity=SimpleNamespace(kb_type='default'),
|
||||
)
|
||||
|
||||
app.rag_mgr.get_knowledge_base_by_uuid = AsyncMock(side_effect=get_kb)
|
||||
descriptor = make_descriptor(
|
||||
capabilities={'knowledge_retrieval': True},
|
||||
config_schema=[
|
||||
{'name': 'model', 'type': 'select-llm-model'},
|
||||
{'name': 'knowledge-bases', 'type': 'select-knowledge-bases'},
|
||||
],
|
||||
)
|
||||
query = make_query({
|
||||
'model': 'llm_alias',
|
||||
'knowledge-bases': ['kb_alias'],
|
||||
})
|
||||
|
||||
resources = await build_resources(app, query, descriptor)
|
||||
|
||||
assert resources['models'] == [
|
||||
{'model_id': 'llm_alias', 'model_type': 'llm', 'provider': 'test-provider', 'operations': ['invoke', 'stream']},
|
||||
]
|
||||
assert resources['knowledge_bases'] == [
|
||||
{'kb_id': 'kb_alias', 'kb_name': 'name-kb_alias', 'kb_type': 'default', 'operations': ['list', 'retrieve']},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_models_manifest_permission_narrows_binding(app):
|
||||
"""Manifest model permissions narrower than binding should remove LLM grants."""
|
||||
|
||||
@@ -288,6 +288,45 @@ class TestSessionRegistryBasic:
|
||||
assert len(items) == MAX_STEERING_QUEUE_ITEMS
|
||||
assert all(item['event']['event_id'] != 'overflow' for item in items)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_steering_target_requires_same_scope(self):
|
||||
"""Steering claims must not cross bot/workspace/thread boundaries."""
|
||||
registry = AgentRunSessionRegistry()
|
||||
await registry.register(
|
||||
run_id='run_steering_scoped',
|
||||
runner_id='plugin:test/my-runner/default',
|
||||
query_id=1,
|
||||
plugin_identity='test/my-runner',
|
||||
resources=make_resources(),
|
||||
conversation_id='conv_1',
|
||||
bot_id='bot_1',
|
||||
workspace_id='workspace_1',
|
||||
thread_id='thread_1',
|
||||
available_apis={'steering_pull': True},
|
||||
)
|
||||
|
||||
assert await registry.find_steering_target(
|
||||
conversation_id='conv_1',
|
||||
runner_id='plugin:test/my-runner/default',
|
||||
bot_id='bot_1',
|
||||
workspace_id='workspace_1',
|
||||
thread_id='thread_1',
|
||||
) == 'run_steering_scoped'
|
||||
assert await registry.find_steering_target(
|
||||
conversation_id='conv_1',
|
||||
runner_id='plugin:test/my-runner/default',
|
||||
bot_id='bot_2',
|
||||
workspace_id='workspace_1',
|
||||
thread_id='thread_1',
|
||||
) is None
|
||||
assert await registry.find_steering_target(
|
||||
conversation_id='conv_1',
|
||||
runner_id='plugin:test/my-runner/default',
|
||||
bot_id='bot_1',
|
||||
workspace_id='workspace_1',
|
||||
thread_id='thread_2',
|
||||
) is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unregister_returns_pending_steering_queue(self):
|
||||
"""Unregister returns the removed session so callers can audit pending steering."""
|
||||
|
||||
@@ -630,7 +630,9 @@ class TestMCPServiceTestMCPServer:
|
||||
ap.tool_mgr.mcp_tool_loader = SimpleNamespace()
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.server_name = 'transient-test-server'
|
||||
mock_session.start = AsyncMock()
|
||||
mock_session.shutdown = AsyncMock()
|
||||
ap.tool_mgr.mcp_tool_loader.load_mcp_server = AsyncMock(return_value=mock_session)
|
||||
|
||||
ap.task_mgr = SimpleNamespace()
|
||||
@@ -645,4 +647,9 @@ class TestMCPServiceTestMCPServer:
|
||||
|
||||
# Verify - load_mcp_server called
|
||||
ap.tool_mgr.mcp_tool_loader.load_mcp_server.assert_called_once()
|
||||
assert task_id == 456
|
||||
assert task_id == 456
|
||||
|
||||
coroutine = ap.task_mgr.create_user_task.call_args.args[0]
|
||||
await coroutine
|
||||
mock_session.start.assert_awaited_once()
|
||||
mock_session.shutdown.assert_awaited_once()
|
||||
|
||||
@@ -181,6 +181,23 @@ def make_app(
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_box_session_id_reads_current_runner_config():
|
||||
query = make_query(101)
|
||||
query.pipeline_config = {
|
||||
'ai': {
|
||||
'runner': {'id': 'plugin:langbot/local-agent/default'},
|
||||
'runner_config': {
|
||||
'plugin:langbot/local-agent/default': {
|
||||
'box-session-id-template': 'bot-{launcher_id}-{sender_id}',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient))
|
||||
|
||||
assert service.resolve_box_session_id(query) == 'bot-test_user-test_user'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_box_service_without_explicit_client_initializes_internal_connector(monkeypatch: pytest.MonkeyPatch):
|
||||
connector = Mock()
|
||||
|
||||
@@ -273,6 +273,13 @@ async def test_preproc_uses_transcript_history_view_when_available():
|
||||
|
||||
assert result.result_type == entities_module.ResultType.CONTINUE
|
||||
assert query.messages == transcript_messages
|
||||
stage._load_agent_runner_history_messages.assert_awaited_once_with(
|
||||
'plugin:langbot/local-agent/default',
|
||||
'conv-1',
|
||||
bot_id='bot-1',
|
||||
workspace_id=None,
|
||||
thread_id=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user