feat(agent-runner): normalize binding config boundaries

This commit is contained in:
huanghuoguoguo
2026-06-02 15:40:57 +08:00
parent 93cd852061
commit 4d4ccfabd5
8 changed files with 185 additions and 108 deletions

View File

@@ -234,7 +234,21 @@ LangBot 应提供事实源能力:
AgentRunner 可以读取这些能力,但不能被迫使用 LangBot 作为唯一记忆系统。
### 4.8 External harness resource projection
### 4.8 Prompt / Instruction Package占位
旧 Pipeline 入口目前可以把 preprocessing 后的有效 prompt 放进 adapter metadata
这是为了保持旧入口行为,不是长期协议。目标形态应是 Host 保存或生成一个
run-scoped instruction packagerunner 通过 Host API 拉取:
- Host 负责记录静态绑定 prompt、host hook / user plugin 产生的 instruction
fragment、来源和审计信息。
- `ctx.context.available_apis.prompt_get` 只表示拉取能力是否可用。
- Runner 拉取 instruction package 后,仍由 runner 自己决定如何与 history、RAG、
tool 结果、memory 和当前输入组装最终模型 prompt。
- Host 不实现通用 agentic prompt assembler也不把 Pipeline adapter prompt 作为
长期业务输入契约。
### 4.9 External harness resource projection
Claude Code、Codex、Kimi Code 等外部 harness runner 可能不会直接调用 LangBot 的 model/tool loop而是把 LangBot 事件和授权资源投影到自己的 harness 中执行。Host 侧仍要保持统一边界:

View File

@@ -670,6 +670,8 @@ Pipeline adapter 负责:
- 从 Pipeline config 构造临时 AgentBinding。
- 从当前 runner binding config 构造 `ctx.config`
- 保留必要的 legacy adapter metadata但不定义历史窗口、prompt 组装或 agentic context 策略。
- 后续若需要传递 preprocessing / hook 后的有效指令,应通过 Host prompt/instruction
package pull API 暴露能力位和引用,而不是继续把 prompt 推入 `ctx.adapter.extra`
- 将 Query-only 字段放入 `adapter`
Runner 不应长期依赖 `adapter`。新 runner 应只依赖 event-first context 和 Host APIs。

View File

@@ -1,4 +1,5 @@
"""Configuration migration for agent runner IDs."""
from __future__ import annotations
import typing
@@ -113,10 +114,33 @@ class ConfigMigration:
if old_runner_name:
old_config = ai_config.get(old_runner_name, {})
if old_config:
return old_config
return ConfigMigration.normalize_runner_config_for_migration(runner_id, old_config)
return {}
@staticmethod
def normalize_runner_config_for_migration(
runner_id: str,
runner_config: dict[str, typing.Any],
) -> dict[str, typing.Any]:
"""Normalize released legacy runner config before storing binding config.
Runtime code should not carry aliases. This helper is intentionally used
only by config migration so AgentRunner implementations can consume the
current manifest-defined field names.
"""
normalized = dict(runner_config)
if runner_id == OLD_RUNNER_TO_PLUGIN_RUNNER_ID['local-agent']:
legacy_kb = normalized.pop('knowledge-base', None)
if 'knowledge-bases' not in normalized:
if isinstance(legacy_kb, str) and legacy_kb and legacy_kb not in {'__none__', '__none'}:
normalized['knowledge-bases'] = [legacy_kb]
elif legacy_kb is not None:
normalized['knowledge-bases'] = []
return normalized
@staticmethod
def get_old_runner_name(runner_id: str) -> str | None:
"""Get old runner name from mapped runner ID.
@@ -188,6 +212,7 @@ class ConfigMigration:
if not resolved_config:
resolved_config = ConfigMigration.resolve_legacy_runner_config(pipeline_config, runner_id)
if resolved_config:
resolved_config = ConfigMigration.normalize_runner_config_for_migration(runner_id, resolved_config)
runner_configs[runner_id] = resolved_config
# Remove old runner config block
for old_name, mapped_id in OLD_RUNNER_TO_PLUGIN_RUNNER_ID.items():

View File

@@ -1,4 +1,5 @@
"""Agent run context builder for provisioning AgentRunContext envelopes."""
from __future__ import annotations
import uuid
@@ -19,6 +20,7 @@ DEFAULT_RUNNER_TIMEOUT_SECONDS = 300
class AgentTrigger(typing.TypedDict):
"""Agent trigger information."""
type: str
source: str # 'pipeline' or 'event_router'
timestamp: int | None
@@ -26,6 +28,7 @@ class AgentTrigger(typing.TypedDict):
class ConversationContext(typing.TypedDict):
"""Conversation context."""
conversation_id: str | None
thread_id: str | None
launcher_type: str | None
@@ -39,6 +42,7 @@ class ConversationContext(typing.TypedDict):
class AgentInput(typing.TypedDict):
"""Agent input."""
text: str | None
contents: list[dict[str, typing.Any]]
message_chain: dict[str, typing.Any] | None
@@ -47,6 +51,7 @@ class AgentInput(typing.TypedDict):
class AgentRunState(typing.TypedDict):
"""Agent run state with 4 scopes."""
conversation: dict[str, typing.Any]
actor: dict[str, typing.Any]
subject: dict[str, typing.Any]
@@ -58,6 +63,7 @@ class AgentRunState(typing.TypedDict):
class ModelResource(typing.TypedDict):
"""Model resource payload."""
model_id: str
model_type: str | None
provider: str | None
@@ -65,6 +71,7 @@ class ModelResource(typing.TypedDict):
class ToolResource(typing.TypedDict):
"""Tool resource payload."""
tool_name: str
tool_type: str | None
description: str | None
@@ -72,6 +79,7 @@ class ToolResource(typing.TypedDict):
class KnowledgeBaseResource(typing.TypedDict):
"""Knowledge base resource payload."""
kb_id: str
kb_name: str | None
kb_type: str | None
@@ -79,6 +87,7 @@ class KnowledgeBaseResource(typing.TypedDict):
class FileResource(typing.TypedDict):
"""File resource payload."""
file_id: str
file_name: str | None
mime_type: str | None
@@ -87,12 +96,14 @@ class FileResource(typing.TypedDict):
class StorageResource(typing.TypedDict):
"""Storage resource payload."""
plugin_storage: bool
workspace_storage: bool
class AgentResources(typing.TypedDict):
"""Agent resources payload."""
models: list[ModelResource]
tools: list[ToolResource]
knowledge_bases: list[KnowledgeBaseResource]
@@ -103,6 +114,7 @@ class AgentResources(typing.TypedDict):
class AgentRuntimeContext(typing.TypedDict):
"""Agent runtime context."""
langbot_version: str | None
sdk_protocol_version: str
query_id: int | None
@@ -119,6 +131,7 @@ class AgentRunContextPayload(typing.TypedDict):
Note: The 'config' field contains the binding config from ai.runner_config[runner_id],
which is Pipeline's configuration for this specific runner binding (not plugin instance config).
"""
run_id: str
trigger: AgentTrigger
conversation: ConversationContext | None
@@ -237,7 +250,9 @@ class AgentRunContextBuilder:
'text': event.input.text,
'contents': [c.model_dump(mode='json') if hasattr(c, 'model_dump') else c for c in event.input.contents],
'message_chain': event.input.message_chain,
'attachments': [a.model_dump(mode='json') if hasattr(a, 'model_dump') else a for a in event.input.attachments],
'attachments': [
a.model_dump(mode='json') if hasattr(a, 'model_dump') else a for a in event.input.attachments
],
}
# Build context access (no history inlined by default for Protocol v1)
@@ -245,9 +260,7 @@ class AgentRunContextBuilder:
context_access = await self._build_context_access(event, descriptor, binding)
# Build state snapshot from persistent state store (event-first Protocol v1)
persistent_state_store = get_persistent_state_store(
self.ap.persistence_mgr.get_db_engine()
)
persistent_state_store = get_persistent_state_store(self.ap.persistence_mgr.get_db_engine())
state: AgentRunState = await persistent_state_store.build_snapshot_from_event(event, binding, descriptor)
# Build runtime context
@@ -261,6 +274,10 @@ class AgentRunContextBuilder:
'bot_id': event.bot_id,
'workspace_id': event.workspace_id,
'streaming_supported': event.delivery.supports_streaming,
'model_context_window_tokens': None,
# TODO(model-info): populate model_context_window_tokens after
# LiteLLM/model metadata lands. Runners fall back to their
# binding config until Host can provide the real window.
},
}
@@ -375,6 +392,7 @@ class AgentRunContextBuilder:
if conversation_id:
try:
from .transcript_store import TranscriptStore
store = TranscriptStore(self.ap.persistence_mgr.get_db_engine())
latest_cursor = await store.get_latest_cursor(conversation_id)
@@ -406,5 +424,6 @@ class AgentRunContextBuilder:
'artifact_read': artifact_read_enabled,
'state': state_enabled,
'storage': True,
'prompt_get': False,
},
}

View File

@@ -31,6 +31,21 @@ def is_plugin_runner_id(runner_id: str) -> bool:
return runner_id.startswith('plugin:')
def normalize_runner_config_for_migration(runner_id: str, runner_config: dict) -> dict:
"""Normalize released legacy runner fields before storing binding config."""
normalized = dict(runner_config)
if runner_id == OLD_RUNNER_TO_PLUGIN_RUNNER_ID['local-agent']:
legacy_kb = normalized.pop('knowledge-base', None)
if 'knowledge-bases' not in normalized:
if isinstance(legacy_kb, str) and legacy_kb and legacy_kb not in {'__none__', '__none'}:
normalized['knowledge-bases'] = [legacy_kb]
elif legacy_kb is not None:
normalized['knowledge-bases'] = []
return normalized
def migrate_pipeline_config(config: dict) -> dict:
"""Migrate pipeline config to new format."""
new_config = dict(config)
@@ -44,7 +59,13 @@ def migrate_pipeline_config(config: dict) -> dict:
# Check for new format first
runner_id = runner_config.get('id')
if runner_id and is_plugin_runner_id(runner_id):
# Already in new format, no need to migrate
if runner_id in runner_configs:
runner_configs[runner_id] = normalize_runner_config_for_migration(
runner_id,
runner_configs[runner_id],
)
ai_config['runner_config'] = runner_configs
new_config['ai'] = ai_config
return new_config
# Check for old format
@@ -67,14 +88,14 @@ def migrate_pipeline_config(config: dict) -> dict:
if old_runner_name in ai_config:
old_runner_config = ai_config[old_runner_name]
if old_runner_config:
runner_configs[runner_id] = old_runner_config
runner_configs[runner_id] = normalize_runner_config_for_migration(runner_id, old_runner_config)
# Remove old config block after migration
del ai_config[old_runner_name]
# Also check if runner_id has config under other old name formats
for old_name, mapped_id in OLD_RUNNER_TO_PLUGIN_RUNNER_ID.items():
if mapped_id == runner_id and old_name in ai_config:
runner_configs[runner_id] = ai_config[old_name]
runner_configs[runner_id] = normalize_runner_config_for_migration(runner_id, ai_config[old_name])
# Remove old config block after migration
del ai_config[old_name]
@@ -111,7 +132,7 @@ def upgrade() -> None:
if json.dumps(config, sort_keys=True) != json.dumps(migrated_config, sort_keys=True):
conn.execute(
sa.text('UPDATE pipelines SET config = :config WHERE uuid = :uuid'),
{'config': json.dumps(migrated_config), 'uuid': pipeline_uuid}
{'config': json.dumps(migrated_config), 'uuid': pipeline_uuid},
)
except Exception:
# Skip invalid configs
@@ -121,4 +142,4 @@ def upgrade() -> None:
def downgrade() -> None:
"""Downgrade is not supported for data migration."""
# No downgrade - keep configs in new format
pass
pass

View File

@@ -1,4 +1,5 @@
"""Tests for agent runner config migration."""
from __future__ import annotations
@@ -155,6 +156,7 @@ class TestResolveRunnerConfig:
'local-agent': {
'model': 'uuid-123',
'max_round': 10,
'knowledge-base': 'kb-123',
},
},
}
@@ -163,7 +165,8 @@ class TestResolveRunnerConfig:
pipeline_config,
'plugin:langbot/local-agent/default',
)
assert config == {'model': 'uuid-123', 'max_round': 10}
assert config == {'model': 'uuid-123', 'max_round': 10, 'knowledge-bases': ['kb-123']}
assert 'knowledge-base' not in config
def test_resolve_no_config(self):
"""Resolve runner config when not found."""

View File

@@ -21,6 +21,7 @@ class TestMigratePipelineConfig:
'local-agent': {
'model': {'primary': 'model-uuid', 'fallbacks': []},
'max-round': 10,
'knowledge-base': 'kb-uuid',
'prompt': [{'role': 'system', 'content': 'Hello'}],
},
},
@@ -35,6 +36,8 @@ class TestMigratePipelineConfig:
# Config should be in runner_config
assert 'plugin:langbot/local-agent/default' in migrated['ai']['runner_config']
assert migrated['ai']['runner_config']['plugin:langbot/local-agent/default']['max-round'] == 10
assert migrated['ai']['runner_config']['plugin:langbot/local-agent/default']['knowledge-bases'] == ['kb-uuid']
assert 'knowledge-base' not in migrated['ai']['runner_config']['plugin:langbot/local-agent/default']
# Expire-time preserved
assert migrated['ai']['runner']['expire-time'] == 0
@@ -85,6 +88,26 @@ class TestMigratePipelineConfig:
assert migrated['ai']['runner']['id'] == 'plugin:langbot/local-agent/default'
assert migrated['ai']['runner_config']['plugin:langbot/local-agent/default']['max-round'] == 10
def test_new_format_local_agent_config_normalizes_legacy_kb_key(self):
"""Migration should normalize legacy KB aliases before runtime."""
config = {
'ai': {
'runner': {
'id': 'plugin:langbot/local-agent/default',
},
'runner_config': {
'plugin:langbot/local-agent/default': {
'knowledge-base': 'kb-legacy',
},
},
},
}
migrated = ConfigMigration.migrate_pipeline_config(config)
runner_config = migrated['ai']['runner_config']['plugin:langbot/local-agent/default']
assert runner_config == {'knowledge-bases': ['kb-legacy']}
def test_migrate_all_old_runners(self):
"""All old runner names should be migrated."""
old_runners = [

View File

@@ -10,6 +10,7 @@ Authorization paths:
1. AgentRunner calls: has run_id, validates against session_registry
2. Regular plugin calls: no run_id, unscoped plugin action path
"""
from __future__ import annotations
import pytest
@@ -26,6 +27,7 @@ from .conftest import make_resources
class MockModel:
"""Mock LLM model for testing."""
def __init__(self, uuid: str):
self.uuid = uuid
self.provider = MagicMock()
@@ -34,6 +36,7 @@ class MockModel:
class MockEmbeddingModel:
"""Mock embedding model for testing."""
def __init__(self, uuid: str):
self.uuid = uuid
self.provider = MagicMock()
@@ -41,6 +44,7 @@ class MockEmbeddingModel:
class MockKnowledgeBase:
"""Mock knowledge base for testing."""
def __init__(self, uuid: str, name: str = 'KB'):
self.knowledge_base_entity = MagicMock()
self.knowledge_base_entity.description = f'{name} description'
@@ -57,6 +61,7 @@ class MockKnowledgeBase:
class MockQuery:
"""Mock query for testing."""
def __init__(self, query_id: int = 1):
self.query_id = query_id
self.session = MagicMock()
@@ -81,6 +86,7 @@ class MockQuery:
class MockApplication:
"""Mock Application for testing."""
def __init__(self):
self.logger = MagicMock()
self.logger.debug = MagicMock()
@@ -125,6 +131,7 @@ class FakeAgentRunnerRegistry:
class MockConnection:
"""Mock connection for testing."""
pass
@@ -155,6 +162,7 @@ class TestPipelineKnowledgeBaseScope:
class MockDisconnectCallback:
"""Mock disconnect callback for testing."""
async def __call__(self):
return True
@@ -489,25 +497,25 @@ class TestHandlerAuthorizationErrorMessages:
def test_model_not_authorized_error_message(self):
"""Error message should mention model not authorized."""
expected_msg = "Model model_999 is not authorized for this agent run"
expected_msg = 'Model model_999 is not authorized for this agent run'
assert 'not authorized' in expected_msg
assert 'model_999' in expected_msg
def test_tool_not_authorized_error_message(self):
"""Error message should mention tool not authorized."""
expected_msg = "Tool image_gen is not authorized for this agent run"
expected_msg = 'Tool image_gen is not authorized for this agent run'
assert 'not authorized' in expected_msg
assert 'image_gen' in expected_msg
def test_kb_not_authorized_error_message(self):
"""Error message should mention kb not authorized."""
expected_msg = "Knowledge base kb_999 is not authorized for this agent run"
expected_msg = 'Knowledge base kb_999 is not authorized for this agent run'
assert 'not authorized' in expected_msg
assert 'kb_999' in expected_msg
def test_session_not_found_error_message(self):
"""Error message should mention session not found."""
expected_msg = "Run session run_xyz not found or expired"
expected_msg = 'Run session run_xyz not found or expired'
assert 'not found' in expected_msg
assert 'run_xyz' in expected_msg
@@ -585,8 +593,8 @@ class TestRETRIEVEKNOWLEDGEBASEBugFix:
# Should resolve to plugin:langbot/local-agent/default
assert 'local-agent' in runner_id
def test_retrieve_kb_fix_backward_compat_knowledge_base(self):
"""Fix should handle backward compat for old 'knowledge-base' field."""
def test_retrieve_kb_legacy_single_key_is_migration_only(self):
"""Old singular knowledge-base config is normalized before runtime."""
from langbot.pkg.agent.runner.config_migration import ConfigMigration
pipeline_config = {
@@ -602,17 +610,11 @@ class TestRETRIEVEKNOWLEDGEBASEBugFix:
},
}
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
runner_config = ConfigMigration.resolve_runner_config(pipeline_config, runner_id)
migrated = ConfigMigration.migrate_pipeline_config(pipeline_config)
runner_id = ConfigMigration.resolve_runner_id(migrated)
runner_config = ConfigMigration.resolve_runner_config(migrated, runner_id)
# Handler.py checks both knowledge-bases and knowledge-base
allowed_kbs = runner_config.get('knowledge-bases', [])
if not allowed_kbs:
old_kb = runner_config.get('knowledge-base', '')
if old_kb and old_kb != '__none__':
allowed_kbs = [old_kb]
assert 'kb_single' in allowed_kbs
assert runner_config == {'knowledge-bases': ['kb_single']}
class TestHandlerActionAuthorization:
@@ -1059,10 +1061,12 @@ class TestResourceTypeValidation:
async def test_model_resource_validation(self):
"""Model resource: correct model_id validation."""
registry = AgentRunSessionRegistry()
resources = make_resources(models=[
{'model_id': 'model_001'},
{'model_id': 'model_002'},
])
resources = make_resources(
models=[
{'model_id': 'model_001'},
{'model_id': 'model_002'},
]
)
await registry.register(
run_id='run_model_validation',
@@ -1087,10 +1091,12 @@ class TestResourceTypeValidation:
async def test_tool_resource_validation(self):
"""Tool resource: correct tool_name validation."""
registry = AgentRunSessionRegistry()
resources = make_resources(tools=[
{'tool_name': 'web_search'},
{'tool_name': 'image_gen'},
])
resources = make_resources(
tools=[
{'tool_name': 'web_search'},
{'tool_name': 'image_gen'},
]
)
await registry.register(
run_id='run_tool_validation',
@@ -1115,10 +1121,12 @@ class TestResourceTypeValidation:
async def test_knowledge_base_resource_validation(self):
"""Knowledge base resource: correct kb_id validation."""
registry = AgentRunSessionRegistry()
resources = make_resources(knowledge_bases=[
{'kb_id': 'kb_001'},
{'kb_id': 'kb_002'},
])
resources = make_resources(
knowledge_bases=[
{'kb_id': 'kb_001'},
{'kb_id': 'kb_002'},
]
)
await registry.register(
run_id='run_kb_validation',
@@ -1262,6 +1270,7 @@ class TestValidateRunAuthorizationHelper:
"""_validate_run_authorization returns session when resource is authorized."""
# Use global session registry (same as _validate_run_authorization)
from langbot.pkg.agent.runner.session_registry import get_session_registry
registry = get_session_registry()
resources = make_resources(models=[{'model_id': 'model_001'}])
@@ -1280,12 +1289,7 @@ class TestValidateRunAuthorizationHelper:
mock_ap = MagicMock()
mock_ap.logger = MagicMock()
session, error = await _validate_run_authorization(
'run_validate_test_helper',
'model',
'model_001',
mock_ap
)
session, error = await _validate_run_authorization('run_validate_test_helper', 'model', 'model_001', mock_ap)
# Should return session, no error
assert session is not None
@@ -1303,12 +1307,7 @@ class TestValidateRunAuthorizationHelper:
mock_ap.logger = MagicMock()
mock_ap.logger.warning = MagicMock()
session, error = await _validate_run_authorization(
'run_nonexistent_helper',
'model',
'model_001',
mock_ap
)
session, error = await _validate_run_authorization('run_nonexistent_helper', 'model', 'model_001', mock_ap)
# Should return no session, error response
assert session is None
@@ -1321,6 +1320,7 @@ class TestValidateRunAuthorizationHelper:
"""_validate_run_authorization returns error when resource not allowed."""
# Use global session registry
from langbot.pkg.agent.runner.session_registry import get_session_registry
registry = get_session_registry()
resources = make_resources(models=[{'model_id': 'model_001'}])
@@ -1342,7 +1342,7 @@ class TestValidateRunAuthorizationHelper:
'run_unauthorized_helper',
'model',
'model_999', # Not in resources
mock_ap
mock_ap,
)
# Should return no session, error response
@@ -1358,6 +1358,7 @@ class TestValidateRunAuthorizationHelper:
"""_validate_run_authorization works for tool resource type."""
# Use global session registry
from langbot.pkg.agent.runner.session_registry import get_session_registry
registry = get_session_registry()
resources = make_resources(tools=[{'tool_name': 'web_search'}])
@@ -1374,12 +1375,7 @@ class TestValidateRunAuthorizationHelper:
mock_ap = MagicMock()
mock_ap.logger = MagicMock()
session, error = await _validate_run_authorization(
'run_tool_test_helper',
'tool',
'web_search',
mock_ap
)
session, error = await _validate_run_authorization('run_tool_test_helper', 'tool', 'web_search', mock_ap)
assert session is not None
assert error is None
@@ -1391,6 +1387,7 @@ class TestValidateRunAuthorizationHelper:
"""_validate_run_authorization works for knowledge_base resource type."""
# Use global session registry
from langbot.pkg.agent.runner.session_registry import get_session_registry
registry = get_session_registry()
resources = make_resources(knowledge_bases=[{'kb_id': 'kb_001'}])
@@ -1407,12 +1404,7 @@ class TestValidateRunAuthorizationHelper:
mock_ap = MagicMock()
mock_ap.logger = MagicMock()
session, error = await _validate_run_authorization(
'run_kb_test_helper',
'knowledge_base',
'kb_001',
mock_ap
)
session, error = await _validate_run_authorization('run_kb_test_helper', 'knowledge_base', 'kb_001', mock_ap)
assert session is not None
assert error is None
@@ -1545,6 +1537,7 @@ class TestFilesResourcePermission:
async def test_files_resource_type_now_implemented(self):
"""'files' resource type is now implemented in is_resource_allowed."""
from langbot.pkg.agent.runner.session_registry import get_session_registry
registry = get_session_registry()
resources = make_resources(files=[{'file_id': 'file_001'}])
@@ -1577,6 +1570,7 @@ class TestRealActionHandlerSimulation:
"""Simulate INVOKE_LLM action handler authorization flow."""
# Use global session registry
from langbot.pkg.agent.runner.session_registry import get_session_registry
registry = get_session_registry()
resources = make_resources(models=[{'model_id': 'model_001'}])
@@ -1595,12 +1589,7 @@ class TestRealActionHandlerSimulation:
mock_ap.logger = MagicMock()
# Step 1: Validate authorization
session, error = await _validate_run_authorization(
'run_invoke_llm_flow_sim',
'model',
'model_001',
mock_ap
)
session, error = await _validate_run_authorization('run_invoke_llm_flow_sim', 'model', 'model_001', mock_ap)
# Should pass authorization
assert session is not None
@@ -1615,6 +1604,7 @@ class TestRealActionHandlerSimulation:
"""Simulate INVOKE_LLM handler rejecting unauthorized model."""
# Use global session registry
from langbot.pkg.agent.runner.session_registry import get_session_registry
registry = get_session_registry()
resources = make_resources(models=[{'model_id': 'model_001'}])
@@ -1633,12 +1623,7 @@ class TestRealActionHandlerSimulation:
mock_ap.logger.warning = MagicMock()
# Try to access unauthorized model
session, error = await _validate_run_authorization(
'run_reject_model_sim',
'model',
'model_999',
mock_ap
)
session, error = await _validate_run_authorization('run_reject_model_sim', 'model', 'model_999', mock_ap)
# Should reject
assert session is None
@@ -1659,10 +1644,7 @@ class TestRealActionHandlerSimulation:
# Try to validate with non-existent run_id
session, error = await _validate_run_authorization(
'run_nonexistent_session_flow',
'model',
'model_001',
mock_ap
'run_nonexistent_session_flow', 'model', 'model_001', mock_ap
)
# Should return error
@@ -1683,6 +1665,7 @@ class TestStoragePermissionValidation:
async def test_plugin_storage_allowed_when_permitted(self):
"""_validate_run_authorization allows 'plugin' storage when permitted."""
from langbot.pkg.agent.runner.session_registry import get_session_registry
registry = get_session_registry()
resources = make_resources(storage={'plugin_storage': True, 'workspace_storage': False})
@@ -1699,12 +1682,7 @@ class TestStoragePermissionValidation:
mock_ap = MagicMock()
mock_ap.logger = MagicMock()
session, error = await _validate_run_authorization(
'run_plugin_storage_auth',
'storage',
'plugin',
mock_ap
)
session, error = await _validate_run_authorization('run_plugin_storage_auth', 'storage', 'plugin', mock_ap)
assert session is not None
assert error is None
@@ -1715,6 +1693,7 @@ class TestStoragePermissionValidation:
async def test_plugin_storage_denied_when_not_permitted(self):
"""_validate_run_authorization denies 'plugin' storage when not permitted."""
from langbot.pkg.agent.runner.session_registry import get_session_registry
registry = get_session_registry()
resources = make_resources(storage={'plugin_storage': False, 'workspace_storage': False})
@@ -1732,12 +1711,7 @@ class TestStoragePermissionValidation:
mock_ap.logger = MagicMock()
mock_ap.logger.warning = MagicMock()
session, error = await _validate_run_authorization(
'run_plugin_storage_denied',
'storage',
'plugin',
mock_ap
)
session, error = await _validate_run_authorization('run_plugin_storage_denied', 'storage', 'plugin', mock_ap)
assert session is None
assert error is not None
@@ -1749,6 +1723,7 @@ class TestStoragePermissionValidation:
async def test_workspace_storage_allowed_when_permitted(self):
"""_validate_run_authorization allows 'workspace' storage when permitted."""
from langbot.pkg.agent.runner.session_registry import get_session_registry
registry = get_session_registry()
resources = make_resources(storage={'plugin_storage': False, 'workspace_storage': True})
@@ -1766,10 +1741,7 @@ class TestStoragePermissionValidation:
mock_ap.logger = MagicMock()
session, error = await _validate_run_authorization(
'run_workspace_storage_auth',
'storage',
'workspace',
mock_ap
'run_workspace_storage_auth', 'storage', 'workspace', mock_ap
)
assert session is not None
@@ -1781,6 +1753,7 @@ class TestStoragePermissionValidation:
async def test_workspace_storage_denied_when_not_permitted(self):
"""_validate_run_authorization denies 'workspace' storage when not permitted."""
from langbot.pkg.agent.runner.session_registry import get_session_registry
registry = get_session_registry()
resources = make_resources(storage={'plugin_storage': False, 'workspace_storage': False})
@@ -1799,10 +1772,7 @@ class TestStoragePermissionValidation:
mock_ap.logger.warning = MagicMock()
session, error = await _validate_run_authorization(
'run_workspace_storage_denied',
'storage',
'workspace',
mock_ap
'run_workspace_storage_denied', 'storage', 'workspace', mock_ap
)
assert session is None
@@ -1823,6 +1793,7 @@ class TestFilePermissionValidation:
async def test_file_allowed_when_in_resources(self):
"""_validate_run_authorization allows file when in resources."""
from langbot.pkg.agent.runner.session_registry import get_session_registry
registry = get_session_registry()
resources = make_resources(files=[{'file_id': 'file_001'}])
@@ -1839,12 +1810,7 @@ class TestFilePermissionValidation:
mock_ap = MagicMock()
mock_ap.logger = MagicMock()
session, error = await _validate_run_authorization(
'run_file_auth',
'file',
'file_001',
mock_ap
)
session, error = await _validate_run_authorization('run_file_auth', 'file', 'file_001', mock_ap)
assert session is not None
assert error is None
@@ -1855,6 +1821,7 @@ class TestFilePermissionValidation:
async def test_file_denied_when_not_in_resources(self):
"""_validate_run_authorization denies file when not in resources."""
from langbot.pkg.agent.runner.session_registry import get_session_registry
registry = get_session_registry()
resources = make_resources(files=[{'file_id': 'file_001'}])
@@ -1876,7 +1843,7 @@ class TestFilePermissionValidation:
'run_file_denied',
'file',
'file_999', # Not in resources
mock_ap
mock_ap,
)
assert session is None
@@ -1898,6 +1865,7 @@ class TestCallerPluginIdentityValidation:
async def test_same_plugin_identity_allowed(self):
"""_validate_run_authorization allows when caller matches session."""
from langbot.pkg.agent.runner.session_registry import get_session_registry
registry = get_session_registry()
resources = make_resources(models=[{'model_id': 'model_001'}])
@@ -1931,6 +1899,7 @@ class TestCallerPluginIdentityValidation:
async def test_different_plugin_identity_denied(self):
"""_validate_run_authorization denies when caller differs from session."""
from langbot.pkg.agent.runner.session_registry import get_session_registry
registry = get_session_registry()
resources = make_resources(models=[{'model_id': 'model_001'}])
@@ -1967,6 +1936,7 @@ class TestCallerPluginIdentityValidation:
"""_validate_run_authorization allows when caller_plugin_identity not provided."""
# Unscoped plugin path: if caller_plugin_identity is None, skip identity check
from langbot.pkg.agent.runner.session_registry import get_session_registry
registry = get_session_registry()
resources = make_resources(models=[{'model_id': 'model_001'}])