fix: harden agent runner runtime boundaries

This commit is contained in:
huanghuoguoguo
2026-06-13 00:17:40 +08:00
parent 2094993afb
commit e7779bd16f
22 changed files with 366 additions and 889 deletions
+27 -1
View File
@@ -16,6 +16,7 @@ from ...entity.persistence.artifact import AgentArtifact
from ...entity.persistence.bstorage import BinaryStorage
_FILE_ARTIFACT_METADATA_KEY = '_langbot_file_artifact'
_ARTIFACT_THREAD_METADATA_KEY = '_langbot_thread_id'
class ArtifactStore:
@@ -56,6 +57,7 @@ class ArtifactStore:
runner_id: str | None = None,
bot_id: str | None = None,
workspace_id: str | None = None,
thread_id: str | None = None,
expires_at: datetime.datetime | None = None,
metadata: dict[str, typing.Any] | None = None,
) -> str:
@@ -92,6 +94,7 @@ class ArtifactStore:
runner_id=runner_id,
bot_id=bot_id,
workspace_id=workspace_id,
thread_id=thread_id,
expires_at=expires_at,
metadata=public_metadata,
content=None,
@@ -113,6 +116,7 @@ class ArtifactStore:
runner_id: str | None = None,
bot_id: str | None = None,
workspace_id: str | None = None,
thread_id: str | None = None,
expires_at: datetime.datetime | None = None,
metadata: dict[str, typing.Any] | None = None,
content: bytes | None = None,
@@ -137,6 +141,7 @@ class ArtifactStore:
runner_id: Runner ID that created this
bot_id: Bot UUID
workspace_id: Workspace ID
thread_id: Thread ID stored as Host-only metadata
expires_at: Expiration time
metadata: Additional metadata
content: Optional content to store in BinaryStorage
@@ -147,6 +152,10 @@ class ArtifactStore:
if artifact_id is None:
artifact_id = str(uuid.uuid4())
metadata_payload = dict(metadata or {})
if thread_id is not None:
metadata_payload[_ARTIFACT_THREAD_METADATA_KEY] = thread_id
# If content provided, store in BinaryStorage
if content is not None and storage_key is None:
storage_key = f"artifact:{artifact_id}"
@@ -184,7 +193,7 @@ class ArtifactStore:
workspace_id=workspace_id,
created_at=datetime.datetime.utcnow(),
expires_at=expires_at,
metadata_json=json.dumps(metadata) if metadata else None,
metadata_json=json.dumps(metadata_payload) if metadata_payload else None,
)
session.add(artifact)
await session.commit()
@@ -216,6 +225,22 @@ class ArtifactStore:
return None
return self._row_to_public_dict(row)
async def get_authorization_metadata(
self,
artifact_id: str,
) -> dict[str, typing.Any] | None:
"""Get artifact metadata with Host-only scope fields for authorization."""
row = await self._get_internal_record(artifact_id)
if row is None:
return None
metadata = self._row_to_public_dict(row)
metadata.update({
'bot_id': row.bot_id,
'workspace_id': row.workspace_id,
'thread_id': self._load_metadata(row.metadata_json).get(_ARTIFACT_THREAD_METADATA_KEY),
})
return metadata
async def _get_internal_record(
self,
artifact_id: str,
@@ -455,6 +480,7 @@ class ArtifactStore:
def _public_metadata(metadata_json: str | None) -> dict[str, typing.Any]:
metadata = ArtifactStore._load_metadata(metadata_json)
metadata.pop(_FILE_ARTIFACT_METADATA_KEY, None)
metadata.pop(_ARTIFACT_THREAD_METADATA_KEY, None)
return metadata
@staticmethod
+19 -6
View File
@@ -6,6 +6,10 @@ import typing
from .descriptor import AgentRunnerDescriptor
FORM_ITEM_TYPE_ALIASES = {
'select-llm-model': 'llm-model-selector',
'select-knowledge-bases': 'knowledge-base-multi-selector',
}
LLM_MODEL_SELECTOR_TYPES = {'model-fallback-selector', 'llm-model-selector'}
KB_SELECTOR_TYPES = {'knowledge-base-multi-selector'}
PROMPT_EDITOR_TYPES = {'prompt-editor'}
@@ -13,6 +17,13 @@ FILE_SELECTOR_TYPES = {'file', 'array[file]'}
NONE_SENTINELS = {'', '__none__', '__none'}
def normalize_schema_item_type(item_type: typing.Any) -> typing.Any:
"""Normalize legacy/frontend DynamicForm aliases to protocol field types."""
if not isinstance(item_type, str):
return item_type
return FORM_ITEM_TYPE_ALIASES.get(item_type, item_type)
def iter_schema_items(
descriptor: AgentRunnerDescriptor | None,
field_types: set[str],
@@ -23,7 +34,7 @@ def iter_schema_items(
for item in descriptor.config_schema or []:
if not isinstance(item, dict):
continue
if item.get('type') in field_types:
if normalize_schema_item_type(item.get('type')) in field_types:
yield item
@@ -81,7 +92,8 @@ def extract_model_selection(
continue
value = runner_config.get(field_name, item.get('default'))
if item.get('type') == 'model-fallback-selector':
item_type = normalize_schema_item_type(item.get('type'))
if item_type == 'model-fallback-selector':
if isinstance(value, str):
primary_uuid = value
elif isinstance(value, dict):
@@ -91,7 +103,7 @@ def extract_model_selection(
fallback_uuids = [fallback for fallback in fallbacks if isinstance(fallback, str)]
break
if item.get('type') == 'llm-model-selector' and isinstance(value, str):
if item_type == 'llm-model-selector' and isinstance(value, str):
primary_uuid = value
break
@@ -145,7 +157,8 @@ def extract_config_file_resources(
if not field_name:
continue
value = runner_config.get(field_name, item.get('default'))
if item.get('type') == 'file':
item_type = normalize_schema_item_type(item.get('type'))
if item_type == 'file':
append_file(value)
elif isinstance(value, list):
for entry in value:
@@ -167,7 +180,7 @@ def iter_config_model_refs(
continue
field_name = item.get('name')
field_type = item.get('type')
field_type = normalize_schema_item_type(item.get('type'))
if not field_name or field_name not in runner_config:
continue
@@ -200,7 +213,7 @@ def set_empty_llm_model_selection(
"""Set the first empty schema-defined LLM selector to model_uuid."""
for item in iter_schema_items(descriptor, LLM_MODEL_SELECTOR_TYPES):
field_name = item.get('name')
field_type = item.get('type')
field_type = normalize_schema_item_type(item.get('type'))
if not field_name:
continue
@@ -283,6 +283,9 @@ class AgentRunOrchestrator:
target_run_id = await self._session_registry.find_steering_target(
conversation_id=event.conversation_id,
runner_id=descriptor.id,
bot_id=event.bot_id,
workspace_id=event.workspace_id,
thread_id=event.thread_id,
)
if target_run_id is None:
return False
@@ -230,6 +230,7 @@ class AgentRunJournal:
runner_id=runner_id,
bot_id=event.bot_id,
workspace_id=event.workspace_id,
thread_id=event.thread_id,
metadata=metadata,
content=content,
)
@@ -371,6 +372,7 @@ class AgentRunJournal:
runner_id=runner_id,
bot_id=event.bot_id,
workspace_id=event.workspace_id,
thread_id=event.thread_id,
metadata=metadata,
content=content,
)
@@ -254,6 +254,9 @@ class AgentRunSessionRegistry:
*,
conversation_id: str,
runner_id: str,
bot_id: str | None = None,
workspace_id: str | None = None,
thread_id: str | None = None,
) -> str | None:
"""Find the oldest active run that can accept steering for a conversation."""
async with self._lock:
@@ -264,6 +267,12 @@ class AgentRunSessionRegistry:
continue
if authorization.get('conversation_id') != conversation_id:
continue
if authorization.get('bot_id') != bot_id:
continue
if authorization.get('workspace_id') != workspace_id:
continue
if authorization.get('thread_id') != thread_id:
continue
if not authorization.get('available_apis', {}).get('steering_pull', False):
continue
candidates.append((session['status'].get('started_at', 0), run_id))
@@ -254,6 +254,10 @@ class TranscriptStore:
self,
conversation_id: str,
limit: int = HARD_LIMIT,
bot_id: str | None = None,
workspace_id: str | None = None,
thread_id: str | None = None,
strict_thread: bool = False,
) -> list[provider_message.Message]:
"""Project Transcript rows into the legacy provider Message view.
@@ -265,6 +269,10 @@ class TranscriptStore:
conversation_id=conversation_id,
limit=limit,
direction="backward",
bot_id=bot_id,
workspace_id=workspace_id,
thread_id=thread_id,
strict_thread=strict_thread,
)
messages: list[provider_message.Message] = []