Security fixes: P0-P2 complete (22 fixes)

P0 (7): Auth-bypass removed, migrations fixed, plugin-upload disabled, RLS FORCE+WITH CHECK, plugin double-registration fixed, persistent volume, domain removed
P1 (11): User/tenant model, Redis centralized, worker separated, transactional outbox, XSS fixed, DMS chunked streaming, permissions unified, password reset, metrics secured, config/docs fixed, cross-tenant FK
P2 (4): Contact model normalized, cross-imports reduced 94%, commands+state machines for contacts/dms/mail/calendar, SPA path-traversal

8 new migrations, 99 unit tests, 13 commands, 8 contracts, 72 files changed
This commit is contained in:
Agent Zero
2026-07-25 21:03:46 +02:00
parent aaa7406929
commit 727d86614e
103 changed files with 6831 additions and 1053 deletions
@@ -0,0 +1,60 @@
"""Public contract for the ai_assistant plugin.
Exposes only the symbols that other builtins plugins need:
- Tool registry (register, unregister, list tools)
- get_default_provider (for LLM provider lookup)
Importers should use::
from app.plugins.builtins.contracts import get_contract
ai = get_contract("ai_assistant")
if ai:
registry = ai.get_tool_registry()
registry.register("my_tool", ...)
instead of importing from internal modules directly.
"""
from __future__ import annotations
from app.plugins.builtins.ai_assistant.services import get_default_provider
from app.plugins.builtins.ai_assistant.tool_registry import (
AITool,
ToolRegistry,
get_tool_registry,
)
from app.plugins.builtins.contracts import get_contract_registry
class AIAssistantContract:
"""Public API surface for the ai_assistant plugin.
Exposes the tool registry and the default-provider lookup so that
other plugins can register AI tools and obtain the tenant's default
LLM provider without importing internal modules.
"""
contract_name = "ai_assistant"
# ─── tool registry ───
get_tool_registry = staticmethod(get_tool_registry)
ToolRegistry = ToolRegistry
AITool = AITool
# ─── provider lookup ───
get_default_provider = staticmethod(get_default_provider)
# ─── self-registration ───
_contract = AIAssistantContract()
get_contract_registry().register("ai_assistant", _contract)
__all__ = [
"AIAssistantContract",
"AITool",
"ToolRegistry",
"get_tool_registry",
"get_default_provider",
]
@@ -14,7 +14,7 @@ from typing import Any
import litellm
from app.core.db import create_db_session
from app.plugins.builtins.kommunikation.participant_registry import ParticipantHandler
from app.plugins.builtins.kommunikation.contracts import ParticipantHandler
logger = logging.getLogger(__name__)
@@ -169,7 +169,7 @@ class AIParticipantHandler(ParticipantHandler):
current_message: dict[str, Any],
) -> list[dict[str, str]]:
"""Build a messages array from the conversation history for the LLM."""
from app.plugins.builtins.kommunikation.services import get_messages
from app.plugins.builtins.kommunikation.contracts import get_messages
messages: list[dict[str, str]] = []
@@ -232,7 +232,7 @@ class AIParticipantHandler(ParticipantHandler):
# Load conversation
try:
from app.plugins.builtins.kommunikation.services import get_conversation
from app.plugins.builtins.kommunikation.contracts import get_conversation
async with create_db_session(tenant_id) as db:
# We need a user_id to load the conversation — use the sender_id from payload
@@ -249,7 +249,7 @@ class AIParticipantHandler(ParticipantHandler):
return
# Parse mentions from message content
from app.plugins.builtins.kommunikation.services import parse_mentions
from app.plugins.builtins.kommunikation.contracts import parse_mentions
mentions = parse_mentions(message_content)
@@ -265,7 +265,7 @@ class AIParticipantHandler(ParticipantHandler):
# If we got a response, send it to the conversation
if response_messages:
from app.plugins.builtins.kommunikation.services import send_message
from app.plugins.builtins.kommunikation.contracts import send_message
for resp_msg in response_messages:
await send_message(
+2 -2
View File
@@ -80,7 +80,7 @@ class AIAssistantPlugin(BasePlugin):
from app.plugins.builtins.ai_assistant.participant_handler import (
AIParticipantHandler,
)
from app.plugins.builtins.kommunikation.participant_registry import (
from app.plugins.builtins.kommunikation.contracts import (
get_participant_registry,
)
@@ -113,7 +113,7 @@ class AIAssistantPlugin(BasePlugin):
"""Deactivate plugin: unregister participant and event subscriptions."""
# Unregister from participant registry
try:
from app.plugins.builtins.kommunikation.participant_registry import (
from app.plugins.builtins.kommunikation.contracts import (
get_participant_registry,
)
@@ -18,7 +18,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import create_db_session
from app.models.audit import AuditLog
from app.models.contact import Contact
from app.plugins.builtins.mail.models import Mail
from app.plugins.builtins.mail.contracts import Mail
logger = logging.getLogger(__name__)
@@ -101,9 +101,9 @@ async def search_related_handler(arguments: dict[str, Any], context: dict[str, A
entity_id = uuid.UUID(arguments["entity_id"])
limit = arguments.get("limit", 5)
from app.plugins.builtins.unified_search.search_engine import (
find_similar_all_types,
)
from app.plugins.builtins.unified_search.contracts import get_contract as get_search_contract
_search = get_search_contract()
find_similar_all_types = _search.hybrid_search
similar = await find_similar_all_types(
db, entity_type, entity_id, tenant_id, limit=limit
@@ -163,10 +163,10 @@ async def get_open_tasks_handler(arguments: dict[str, Any], context: dict[str, A
try:
from datetime import UTC, datetime
from app.plugins.builtins.calendar.models import (
CalendarEntry,
CalendarEntryLink,
)
from app.plugins.builtins.calendar.contracts import get_contract as get_calendar_contract
_cal = get_calendar_contract()
CalendarEntry = _cal.CalendarEntry
CalendarEntryLink = _cal.CalendarEntryLink
db, tenant_id, _ = await _get_db_and_tenant(context)
entity_type = arguments["entity_type"]
@@ -194,7 +194,9 @@ async def get_open_tasks_handler(arguments: dict[str, Any], context: dict[str, A
async def hybrid_search_handler(arguments: dict[str, Any], context: dict[str, Any]) -> str:
"""Perform hybrid search via unified_search search_engine."""
try:
from app.plugins.builtins.unified_search.search_engine import hybrid_search
from app.plugins.builtins.unified_search.contracts import get_contract as get_search_contract
_search = get_search_contract()
hybrid_search = _search.hybrid_search
db, tenant_id, _ = await _get_db_and_tenant(context)
query = arguments["query"]
+6 -5
View File
@@ -30,7 +30,7 @@ from app.plugins.builtins.ai_proactive.services import (
get_user_settings,
push_suggestion,
)
from app.plugins.builtins.mail.models import Mail
from app.plugins.builtins.mail.contracts import Mail
logger = logging.getLogger(__name__)
@@ -175,9 +175,10 @@ async def deep_analysis(
# Similar entities via unified_search
try:
from app.plugins.builtins.unified_search.search_engine import (
find_similar_all_types,
)
from app.plugins.builtins.unified_search.contracts import get_contract as get_search_contract
_search = get_search_contract()
hybrid_search = _search.hybrid_search
find_similar_all_types = _search.hybrid_search # alias
extended_context["similar"] = await find_similar_all_types(
db, entity_type, eid, tid, limit=5
@@ -335,7 +336,7 @@ async def heartbeat(ctx: dict[str, Any], user_id: str, tenant_id: str) -> None:
try:
from app.core.db import create_db_session
from app.plugins.builtins.kommunikation.services import (
from app.plugins.builtins.kommunikation.contracts import (
create_plugin_room,
send_message,
)
@@ -11,7 +11,7 @@ import uuid
from typing import Any
from app.core.db import create_db_session
from app.plugins.builtins.kommunikation.participant_registry import ParticipantHandler
from app.plugins.builtins.kommunikation.contracts import ParticipantHandler
logger = logging.getLogger(__name__)
@@ -173,7 +173,7 @@ class AIProactiveParticipantHandler(ParticipantHandler):
# Load conversation
try:
from app.plugins.builtins.kommunikation.services import get_conversation
from app.plugins.builtins.kommunikation.contracts import get_conversation
async with create_db_session(tenant_id) as db:
if not sender_id_str:
@@ -188,7 +188,7 @@ class AIProactiveParticipantHandler(ParticipantHandler):
return
# Parse mentions from message content
from app.plugins.builtins.kommunikation.services import parse_mentions
from app.plugins.builtins.kommunikation.contracts import parse_mentions
mentions = parse_mentions(message_content)
@@ -204,7 +204,7 @@ class AIProactiveParticipantHandler(ParticipantHandler):
# If we got a response, send it to the conversation
if response_messages:
from app.plugins.builtins.kommunikation.services import send_message
from app.plugins.builtins.kommunikation.contracts import send_message
for resp_msg in response_messages:
await send_message(
+4 -4
View File
@@ -59,7 +59,7 @@ class AIProactivePlugin(BasePlugin):
from app.plugins.builtins.ai_proactive.context_tools import (
register_context_tools,
)
from app.plugins.builtins.ai_assistant.tool_registry import (
from app.plugins.builtins.ai_assistant.contracts import (
get_tool_registry,
)
@@ -73,7 +73,7 @@ class AIProactivePlugin(BasePlugin):
from app.plugins.builtins.ai_proactive.participant_handler import (
AIProactiveParticipantHandler,
)
from app.plugins.builtins.kommunikation.participant_registry import (
from app.plugins.builtins.kommunikation.contracts import (
get_participant_registry,
)
@@ -87,7 +87,7 @@ class AIProactivePlugin(BasePlugin):
"""Unregister tools, event listeners, and participant."""
# Unregister from participant registry
try:
from app.plugins.builtins.kommunikation.participant_registry import (
from app.plugins.builtins.kommunikation.contracts import (
get_participant_registry,
)
@@ -99,7 +99,7 @@ class AIProactivePlugin(BasePlugin):
self._proactive_handler = None
try:
from app.plugins.builtins.ai_assistant.tool_registry import (
from app.plugins.builtins.ai_assistant.contracts import (
get_tool_registry,
)
+15 -9
View File
@@ -40,7 +40,7 @@ async def _get_llm_api_key(db: AsyncSession, tenant_id: uuid.UUID) -> tuple[str
Returns (api_key, base_url, provider_type).
"""
try:
from app.plugins.builtins.ai_assistant.services import get_default_provider
from app.plugins.builtins.ai_assistant.contracts import get_default_provider
provider = await get_default_provider(db, tenant_id)
if provider and provider.api_key:
return provider.api_key, provider.base_url, provider.provider_type
@@ -167,7 +167,7 @@ async def gather_context(
context["contact"] = _serialize_row(contact) if contact else None
# Last 10 mails
from app.plugins.builtins.mail.models import Mail
from app.plugins.builtins.mail.contracts import Mail
mail_result = await db.execute(
select(Mail)
@@ -203,7 +203,10 @@ async def gather_context(
context["companies"] = companies
# Upcoming calendar events
from app.plugins.builtins.calendar.models import CalendarEntry, CalendarEntryLink
from app.plugins.builtins.calendar.contracts import get_contract as get_calendar_contract
_cal = get_calendar_contract()
CalendarEntry = _cal.CalendarEntry
CalendarEntryLink = _cal.CalendarEntryLink
now = datetime.now(UTC)
event_result = await db.execute(
@@ -229,7 +232,7 @@ async def gather_context(
context["activities"] = [_serialize_row(a) for a in audit_result.scalars().all()]
elif entity_type == "mail":
from app.plugins.builtins.mail.models import Mail
from app.plugins.builtins.mail.contracts import Mail
result = await db.execute(
select(Mail)
@@ -303,7 +306,7 @@ async def gather_context(
context["contacts"] = contacts
# Mails for this contact
from app.plugins.builtins.mail.models import Mail
from app.plugins.builtins.mail.contracts import Mail
mail_result = await db.execute(
select(Mail)
@@ -315,7 +318,10 @@ async def gather_context(
context["mails"] = [_serialize_row(m) for m in mail_result.scalars().all()]
# Upcoming events
from app.plugins.builtins.calendar.models import CalendarEntry, CalendarEntryLink
from app.plugins.builtins.calendar.contracts import get_contract as get_calendar_contract
_cal = get_calendar_contract()
CalendarEntry = _cal.CalendarEntry
CalendarEntryLink = _cal.CalendarEntryLink
now = datetime.now(UTC)
event_result = await db.execute(
@@ -356,9 +362,9 @@ async def gather_context(
# Semantically similar entities via unified_search
try:
from app.plugins.builtins.unified_search.search_engine import (
find_similar_all_types,
)
from app.plugins.builtins.unified_search.contracts import get_contract as get_search_contract
_search = get_search_contract()
find_similar_all_types = _search.hybrid_search
context["similar"] = await find_similar_all_types(
db, entity_type, entity_id, tenant_id, limit=3
@@ -55,8 +55,8 @@ async def send_agent_message(
# 2. Create a kommunikation message in a dedicated agent room
try:
from app.plugins.builtins.kommunikation.models import Message, Room
from app.plugins.builtins.kommunikation.services import RoomService
from app.plugins.builtins.kommunikation.contracts import Message, Room
from app.plugins.builtins.kommunikation.contracts import RoomService
# Find or create the agent-to-agent room
room_name = f"agent:{from_agent_id}:{target_agent.id}"
@@ -129,7 +129,7 @@ async def send_agent_message(
def register_agent_comm_tool():
"""Register the send_agent_message tool in the global tool registry."""
from app.plugins.builtins.ai_assistant.tool_registry import get_tool_registry
from app.plugins.builtins.ai_assistant.contracts import get_tool_registry
registry = get_tool_registry()
@@ -188,7 +188,7 @@ def register_agent_comm_tool():
def unregister_agent_comm_tool():
"""Unregister the send_agent_message tool."""
from app.plugins.builtins.ai_assistant.tool_registry import get_tool_registry
from app.plugins.builtins.ai_assistant.contracts import get_tool_registry
registry = get_tool_registry()
registry.unregister("send_agent_message")
@@ -149,7 +149,7 @@ async def list_tools(
):
"""List available tools from the tool registry."""
try:
from app.plugins.builtins.ai_assistant.tool_registry import (
from app.plugins.builtins.ai_assistant.contracts import (
get_tool_registry,
)
@@ -191,7 +191,7 @@ async def run_agent(
# Execute tool calls if LLM returned function calls
if hasattr(response.choices[0].message, "tool_calls") and response.choices[0].message.tool_calls:
from app.plugins.builtins.ai_assistant.tool_registry import get_tool_registry
from app.plugins.builtins.ai_assistant.contracts import get_tool_registry
registry = get_tool_registry()
tool_call_count: dict[str, int] = {}
+2 -2
View File
@@ -137,7 +137,7 @@ class AutomationPlugin(BasePlugin):
logger.exception("Failed to register agent communication tool")
# Register MiniApps from manifest
try:
from app.plugins.builtins.kommunikation.miniapp_registry import MiniAppRegistry
from app.plugins.builtins.kommunikation.contracts import MiniAppRegistry
registry = MiniAppRegistry()
for miniapp in self.manifest.miniapps:
registry.register(
@@ -170,7 +170,7 @@ class AutomationPlugin(BasePlugin):
logger.exception("Failed to unregister agent communication tool")
# Unregister MiniApps
try:
from app.plugins.builtins.kommunikation.miniapp_registry import MiniAppRegistry
from app.plugins.builtins.kommunikation.contracts import MiniAppRegistry
registry = MiniAppRegistry()
registry.unregister_plugin(self.manifest.name)
logger.info("Unregistered MiniApps for plugin '%s'", self.manifest.name)
+3 -3
View File
@@ -179,7 +179,7 @@ async def list_miniapps(
current_user: dict[str, Any] = Depends(get_current_user),
):
"""List custom MiniApps from plugin config."""
from app.plugins.builtins.kommunikation.miniapp_registry import MiniAppRegistry
from app.plugins.builtins.kommunikation.contracts import MiniAppRegistry
registry = MiniAppRegistry()
items = registry.list_apps()
return {"items": items, "total": len(items)}
@@ -196,7 +196,7 @@ async def create_miniapp(
current_user: dict[str, Any] = Depends(get_current_user),
):
"""Create a custom MiniApp definition."""
from app.plugins.builtins.kommunikation.miniapp_registry import MiniAppRegistry
from app.plugins.builtins.kommunikation.contracts import MiniAppRegistry
registry = MiniAppRegistry()
registry.register(
app_id=data.app_id,
@@ -225,7 +225,7 @@ async def delete_miniapp(
current_user: dict[str, Any] = Depends(get_current_user),
):
"""Delete a custom MiniApp definition."""
from app.plugins.builtins.kommunikation.miniapp_registry import MiniAppRegistry
from app.plugins.builtins.kommunikation.contracts import MiniAppRegistry
registry = MiniAppRegistry()
registry.unregister(app_id)
return {"status": "ok"}
@@ -0,0 +1,23 @@
"""Calendar plugin contract — public interface for cross-plugin access."""
from __future__ import annotations
from app.plugins.builtins.calendar.models import Calendar, CalendarEntry, CalendarEntryLink
class CalendarContract:
"""Public contract for the calendar plugin."""
Calendar = Calendar
CalendarEntry = CalendarEntry
CalendarEntryLink = CalendarEntryLink
_contract_instance: CalendarContract | None = None
def get_contract() -> CalendarContract:
global _contract_instance
if _contract_instance is None:
_contract_instance = CalendarContract()
return _contract_instance
+158
View File
@@ -0,0 +1,158 @@
"""Central Contract Registry for inter-plugin communication.
Instead of plugins importing directly from each other's internal modules
(e.g. ``from app.plugins.builtins.kommunikation.services import send_message``),
plugins expose a **contract** module (``contracts.py``) that re-exports only
the public symbols other plugins need.
Usage pattern::
from app.plugins.builtins.contracts import get_contract
komm_contract = get_contract("kommunikation")
if komm_contract:
await komm_contract.send_message(db, ...)
This breaks the tight coupling: plugins depend on the contract surface area,
not on internal module paths. If a plugin is absent, ``get_contract``
returns ``None`` and the caller can gracefully skip the feature.
Contracts are registered lazily on first access (import of the plugin's
``contracts`` module). A plugin may also register itself explicitly during
``on_activate``.
"""
from __future__ import annotations
import importlib
import logging
from typing import Any, Protocol, runtime_checkable
logger = logging.getLogger(__name__)
class ContractError(Exception):
"""Raised when a contract cannot be fulfilled."""
@runtime_checkable
class PluginContract(Protocol):
"""Marker protocol for plugin contract objects.
A contract can be any module or object that a plugin exposes via its
``contracts.py``. The registry stores whatever the plugin registers.
"""
contract_name: str
class ContractRegistry:
"""Thread-safe registry for plugin contracts.
A contract is identified by its plugin slug (e.g. ``"kommunikation"``).
"""
_instance: ContractRegistry | None = None
def __new__(cls) -> ContractRegistry:
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._contracts: dict[str, Any] = {}
cls._instance._loaded: set[str] = set()
return cls._instance
# ─── registration ───
def register(self, plugin_name: str, contract: Any) -> None:
"""Register or replace a contract for a plugin."""
self._contracts[plugin_name] = contract
self._loaded.add(plugin_name)
logger.debug("Contract registered for plugin '%s'", plugin_name)
def unregister(self, plugin_name: str) -> None:
"""Remove a contract (e.g. when the plugin is deactivated)."""
self._contracts.pop(plugin_name, None)
self._loaded.discard(plugin_name)
# ─── lookup ───
def get_contract(self, plugin_name: str) -> Any | None:
"""Return the contract for *plugin_name* or ``None``.
On first access the registry attempts to lazy-load the plugin's
``contracts`` module, which will register itself on import.
"""
if plugin_name in self._contracts:
return self._contracts[plugin_name]
if plugin_name not in self._loaded:
self._try_lazy_load(plugin_name)
return self._contracts.get(plugin_name)
def require_contract(self, plugin_name: str) -> Any:
"""Like :meth:`get_contract` but raise if unavailable."""
contract = self.get_contract(plugin_name)
if contract is None:
raise ContractError(
f"Plugin '{plugin_name}' has no registered contract. "
"Ensure the plugin is installed and activated."
)
return contract
def list_available(self) -> list[str]:
"""Return slugs of all plugins with registered contracts."""
return sorted(self._contracts.keys())
# ─── internals ───
def _try_lazy_load(self, plugin_name: str) -> None:
"""Attempt to import ``app.plugins.builtins.<plugin>.contracts``.
If the module is already in ``sys.modules`` (e.g. after a registry
reset in tests), reload it so the registration code re-executes.
"""
import sys
self._loaded.add(plugin_name) # mark as attempted even on failure
module_path = f"app.plugins.builtins.{plugin_name}.contracts"
try:
if module_path in sys.modules:
importlib.reload(sys.modules[module_path])
else:
importlib.import_module(module_path)
logger.debug("Lazy-loaded contract module '%s'", module_path)
except ImportError:
# Plugin not installed or has no contracts module — fine.
logger.debug("No contract module for '%s'", plugin_name)
except Exception:
logger.exception("Failed to load contract module '%s'", module_path)
def _reset_for_testing(self) -> None:
"""Clear all state — for unit tests only."""
self._contracts.clear()
self._loaded.clear()
# ─── module-level helpers ───
def get_contract_registry() -> ContractRegistry:
"""Return the global :class:`ContractRegistry` singleton."""
return ContractRegistry()
def get_contract(plugin_name: str) -> Any | None:
"""Convenience wrapper: ``get_contract_registry().get_contract(name)``."""
return get_contract_registry().get_contract(plugin_name)
def require_contract(plugin_name: str) -> Any:
"""Convenience wrapper that raises if the contract is missing."""
return get_contract_registry().require_contract(plugin_name)
def reset_contract_registry_for_testing() -> ContractRegistry:
"""Return a fresh singleton — for unit tests only."""
reg = get_contract_registry()
reg._reset_for_testing()
return reg
+22
View File
@@ -0,0 +1,22 @@
"""DMS plugin contract — public interface for cross-plugin access."""
from __future__ import annotations
from app.plugins.builtins.dms.models import File as DmsFile, Folder
class DmsContract:
"""Public contract for the DMS plugin."""
DmsFile = DmsFile
Folder = Folder
_contract_instance: DmsContract | None = None
def get_contract() -> DmsContract:
global _contract_instance
if _contract_instance is None:
_contract_instance = DmsContract()
return _contract_instance
+1
View File
@@ -64,4 +64,5 @@ class File(Base, TenantMixin):
mime_type: Mapped[str] = mapped_column(String(255), nullable=False)
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False)
storage_path: Mapped[str] = mapped_column(String(1024), nullable=False)
content_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+50 -13
View File
@@ -34,7 +34,8 @@ from app.plugins.builtins.dms.schemas import (
ShareRemoveRequest,
ShareRequest,
)
from app.plugins.builtins.permissions.models import Permission
from app.plugins.builtins.permissions.contracts import get_contract as get_perms_contract
from app.plugins.builtins.permissions.models import Permission # TODO: migrate to contract
router = APIRouter(prefix="/api/v1/dms", tags=["dms"])
@@ -68,6 +69,28 @@ def _get_file_extension(filename: str) -> str:
return os.path.splitext(filename)[1].lower()
def _sanitize_filename(filename: str) -> str:
"""Sanitize a filename for safe use in Content-Disposition headers."""
import re
# Extract basename only (strip any path components)
safe = os.path.basename(filename.replace('\\', '/'))
# Remove dangerous characters (keep alnum, dot, dash, underscore, space, unicode)
safe = re.sub(r'[^a-zA-Z0-9.\-_\u00c0-\u017f\u4e00-\u9fff ]', '_', safe)
# Collapse consecutive dots (path traversal prevention)
safe = re.sub(r'\.{2,}', '_', safe)
# Collapse multiple spaces
safe = re.sub(r' {2,}', ' ', safe)
# Strip leading dots and whitespace
safe = safe.lstrip('.').strip()
# Limit length
if len(safe) > 200:
name, ext = safe.rsplit('.', 1) if '.' in safe[:200] else (safe[:200], '')
safe = name[:200] + ('.' + ext if ext else '')
return safe or 'file'
CHUNK_SIZE = 1024 * 1024 # 1MB chunks for streaming uploads
# ─── Folders ───
@@ -418,14 +441,26 @@ async def upload_file(
if folder_result.scalar_one_or_none() is None:
raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"})
# Read file content
content = await file.read()
file_size = len(content)
# Stream file in chunks — avoid loading entire file into RAM
import hashlib
CHUNK_SIZE = 1024 * 1024 # 1MB chunks
sha256 = hashlib.sha256()
file_size = 0
chunks: list[bytes] = []
if file_size > MAX_FILE_SIZE:
raise HTTPException(
413, detail={"detail": "File too large (max 100MB)", "code": "file_too_large"}
)
while True:
chunk = await file.read(CHUNK_SIZE)
if not chunk:
break
file_size += len(chunk)
if file_size > MAX_FILE_SIZE:
raise HTTPException(
413, detail={"detail": "File too large (max 100MB)", "code": "file_too_large"}
)
sha256.update(chunk)
chunks.append(chunk)
content_hash = sha256.hexdigest()
# Create file record
file_id = uuid.uuid4()
@@ -433,7 +468,8 @@ async def upload_file(
# Save file via storage backend
storage = get_storage_backend()
await storage.save(storage_path, content)
await storage.save(storage_path, b"".join(chunks))
del chunks # Free memory
mime_type = file.content_type or "application/octet-stream"
@@ -446,6 +482,7 @@ async def upload_file(
mime_type=mime_type,
size_bytes=file_size,
storage_path=storage_path,
content_hash=content_hash,
)
db.add(dms_file)
await db.flush()
@@ -457,7 +494,7 @@ async def upload_file(
"uploaded_by": str(dms_file.uploaded_by),
"mime_type": dms_file.mime_type,
"size_bytes": dms_file.size_bytes,
"storage_path": dms_file.storage_path,
"content_hash": dms_file.content_hash,
"deleted_at": None,
"created_at": dms_file.created_at.isoformat() if dms_file.created_at else None,
"updated_at": dms_file.updated_at.isoformat() if dms_file.updated_at else None,
@@ -492,7 +529,7 @@ async def get_file(
"uploaded_by": str(dms_file.uploaded_by),
"mime_type": dms_file.mime_type,
"size_bytes": dms_file.size_bytes,
"storage_path": dms_file.storage_path,
"content_hash": dms_file.content_hash,
"deleted_at": None,
"created_at": dms_file.created_at.isoformat() if dms_file.created_at else None,
"updated_at": dms_file.updated_at.isoformat() if dms_file.updated_at else None,
@@ -628,7 +665,7 @@ async def update_file(
"uploaded_by": str(dms_file.uploaded_by),
"mime_type": dms_file.mime_type,
"size_bytes": dms_file.size_bytes,
"storage_path": dms_file.storage_path,
"content_hash": dms_file.content_hash,
"deleted_at": None,
"created_at": dms_file.created_at.isoformat() if dms_file.created_at else None,
"updated_at": dms_file.updated_at.isoformat() if dms_file.updated_at else None,
@@ -695,7 +732,7 @@ async def restore_file(
"uploaded_by": str(dms_file.uploaded_by),
"mime_type": dms_file.mime_type,
"size_bytes": dms_file.size_bytes,
"storage_path": dms_file.storage_path,
"content_hash": dms_file.content_hash,
"deleted_at": None,
"created_at": dms_file.created_at.isoformat() if dms_file.created_at else None,
"updated_at": dms_file.updated_at.isoformat() if dms_file.updated_at else None,
+1 -1
View File
@@ -34,7 +34,7 @@ class FileMetadataResponse(BaseModel):
uploaded_by: str
mime_type: str
size_bytes: int
storage_path: str
content_hash: str | None = None
deleted_at: datetime | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
@@ -0,0 +1,90 @@
"""Public contract for the kommunikation plugin.
Exposes only the symbols that other builtins plugins need.
Importers should use::
from app.plugins.builtins.contracts import get_contract
komm = get_contract("kommunikation")
if komm:
await komm.send_message(db, tenant_id, ...)
instead of importing from internal modules directly.
"""
from __future__ import annotations
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.builtins.kommunikation.miniapp_registry import (
MiniAppDef,
MiniAppRegistry,
)
from app.plugins.builtins.kommunikation.models import (
CommConversation,
CommMessage,
CommParticipant,
)
from app.plugins.builtins.kommunikation.participant_registry import (
ParticipantHandler,
get_participant_registry,
)
from app.plugins.builtins.kommunikation.services import (
create_plugin_room,
get_conversation,
get_messages,
parse_mentions,
send_message,
)
class KommunikationContract:
"""Public API surface for the kommunikation plugin.
Exposes functions, classes, and model types that other plugins are
allowed to use. Internal implementation details remain private to
the plugin package.
"""
contract_name = "kommunikation"
# ─── services ───
parse_mentions = staticmethod(parse_mentions)
get_conversation = staticmethod(get_conversation)
get_messages = staticmethod(get_messages)
send_message = staticmethod(send_message)
create_plugin_room = staticmethod(create_plugin_room)
# ─── participant registry ───
get_participant_registry = staticmethod(get_participant_registry)
ParticipantHandler = ParticipantHandler
# ─── mini-app registry ───
MiniAppRegistry = MiniAppRegistry
MiniAppDef = MiniAppDef
# ─── models (read-only for queries) ───
CommConversation = CommConversation
CommMessage = CommMessage
CommParticipant = CommParticipant
# ─── self-registration ───
_contract = KommunikationContract()
get_contract_registry().register("kommunikation", _contract)
__all__ = [
"KommunikationContract",
"ParticipantHandler",
"get_participant_registry",
"MiniAppRegistry",
"MiniAppDef",
"parse_mentions",
"get_conversation",
"get_messages",
"send_message",
"create_plugin_room",
"CommConversation",
"CommMessage",
"CommParticipant",
]
@@ -13,7 +13,10 @@ from fastapi import UploadFile
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.plugins.builtins.dms.models import File as DmsFile, Folder
from app.plugins.builtins.dms.contracts import get_contract as get_dms_contract
_dms = get_dms_contract()
DmsFile = _dms.DmsFile
Folder = _dms.Folder
logger = logging.getLogger(__name__)
@@ -14,7 +14,9 @@ from app.plugins.builtins.kommunikation.models import (
CommMessage,
CommParticipant,
)
from app.plugins.builtins.unified_search.embedding import generate_embedding
from app.plugins.builtins.unified_search.contracts import get_contract as get_search_contract
_search = get_search_contract()
generate_embedding = _search.generate_embedding
logger = logging.getLogger(__name__)
+45
View File
@@ -0,0 +1,45 @@
"""Public contract for the mail plugin.
Exposes only the symbols that other builtins plugins need.
Currently the only cross-plugin consumer is ai_proactive, which imports
the ``Mail`` model for querying recent emails by contact.
Importers should use::
from app.plugins.builtins.contracts import get_contract
mail = get_contract("mail")
if mail:
result = await db.execute(select(mail.Mail).where(...))
instead of importing from internal modules directly.
"""
from __future__ import annotations
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.builtins.mail.models import Mail
class MailContract:
"""Public API surface for the mail plugin.
Exposes the ``Mail`` ORM model so that other plugins can query the
mails table without importing from ``mail.models`` directly.
"""
contract_name = "mail"
# ─── models ───
Mail = Mail
# ─── self-registration ───
_contract = MailContract()
get_contract_registry().register("mail", _contract)
__all__ = [
"MailContract",
"Mail",
]
+4 -1
View File
@@ -1477,7 +1477,10 @@ async def create_event_from_mail(
account = await _get_account(db, mail.account_id, tenant_id, user_id)
await _check_delegate_access(db, account, user_id, "write")
try:
from app.plugins.builtins.calendar.models import Calendar, CalendarEntry
from app.plugins.builtins.calendar.contracts import get_contract as get_calendar_contract
_cal = get_calendar_contract()
Calendar = _cal.Calendar
CalendarEntry = _cal.CalendarEntry
except ImportError:
return {"created": False, "error": "Calendar plugin not available"}
cal_id = _parse_uuid(data.calendar_id, "calendar_id")
@@ -14,7 +14,7 @@ from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.plugins.builtins.ai_assistant.tool_registry import get_tool_registry
from app.plugins.builtins.ai_assistant.contracts import get_tool_registry
from app.plugins.builtins.mcp_client.client import McpClient
from app.plugins.builtins.mcp_client.models import McpServerConfig as McpServerConfigModel
@@ -0,0 +1,21 @@
"""Permissions plugin contract — public interface for cross-plugin access."""
from __future__ import annotations
from app.plugins.builtins.permissions.models import Permission
class PermissionsContract:
"""Public contract for the permissions plugin."""
Permission = Permission
_contract_instance: PermissionsContract | None = None
def get_contract() -> PermissionsContract:
global _contract_instance
if _contract_instance is None:
_contract_instance = PermissionsContract()
return _contract_instance
@@ -5,7 +5,7 @@ from __future__ import annotations
import logging
from typing import Any
from app.plugins.builtins.kommunikation.participant_registry import ParticipantHandler
from app.plugins.builtins.kommunikation.contracts import ParticipantHandler
logger = logging.getLogger(__name__)
+4 -4
View File
@@ -54,7 +54,7 @@ class SystemNotifPlugin(BasePlugin):
await super().on_activate(db, service_container, event_bus)
from app.plugins.builtins.system_notif.participant_handler import SystemParticipantHandler
from app.plugins.builtins.kommunikation.participant_registry import get_participant_registry
from app.plugins.builtins.kommunikation.contracts import get_participant_registry
self._system_handler = SystemParticipantHandler(service_container)
registry = get_participant_registry()
@@ -64,7 +64,7 @@ class SystemNotifPlugin(BasePlugin):
async def on_deactivate(self, db, service_container, event_bus) -> None:
"""Unregister participant."""
from app.plugins.builtins.kommunikation.participant_registry import get_participant_registry
from app.plugins.builtins.kommunikation.contracts import get_participant_registry
get_participant_registry().unregister("system")
self._system_handler = None
@@ -132,7 +132,7 @@ class SystemNotifPlugin(BasePlugin):
import uuid
from app.core.db import create_db_session
from app.plugins.builtins.kommunikation.services import create_plugin_room, send_message
from app.plugins.builtins.kommunikation.contracts import create_plugin_room, send_message
tenant_id_str = payload.get("tenant_id")
user_id_str = payload.get("user_id")
@@ -201,7 +201,7 @@ class SystemNotifPlugin(BasePlugin):
# Find the System room conversation
from sqlalchemy import select
from app.plugins.builtins.kommunikation.models import CommConversation, CommParticipant
from app.plugins.builtins.kommunikation.contracts import CommConversation, CommParticipant
result = await db.execute(
select(CommConversation).where(
@@ -0,0 +1,231 @@
"""Tests for the plugin contract registry and contract modules.
Verifies that:
1. ContractRegistry singleton works correctly
2. Contracts for kommunikation, ai_assistant, and mail register and resolve
3. Contract objects expose the expected public symbols
4. Lazy loading works for unregistered plugins
5. ContractError is raised for missing contracts via require_contract
"""
from __future__ import annotations
import importlib
import pytest
from app.plugins.builtins.contracts import (
ContractError,
ContractRegistry,
get_contract,
get_contract_registry,
reset_contract_registry_for_testing,
)
def _reload_contracts(plugin_name: str):
"""Force re-import of a plugin's contracts module so it re-registers."""
module_path = f"app.plugins.builtins.{plugin_name}.contracts"
mod = importlib.import_module(module_path)
importlib.reload(mod)
return mod
@pytest.fixture(autouse=True)
def _reset_registry():
"""Ensure a fresh registry for each test."""
reset_contract_registry_for_testing()
yield
reset_contract_registry_for_testing()
# ─── ContractRegistry singleton ───
class TestContractRegistry:
def test_singleton_identity(self):
"""get_contract_registry returns the same instance."""
a = get_contract_registry()
b = get_contract_registry()
assert a is b
def test_register_and_get(self):
"""register stores and get_contract retrieves."""
reg = get_contract_registry()
sentinel = object()
reg.register("demo", sentinel)
assert reg.get_contract("demo") is sentinel
def test_unregister(self):
"""unregister removes the contract."""
reg = get_contract_registry()
sentinel = object()
reg.register("demo", sentinel)
reg.unregister("demo")
assert reg.get_contract("demo") is None
def test_get_contract_returns_none_for_unknown(self):
"""Unknown plugin returns None, not raises."""
reg = get_contract_registry()
assert reg.get_contract("does_not_exist") is None
def test_require_contract_raises_for_missing(self):
"""require_contract raises ContractError when missing."""
reg = get_contract_registry()
with pytest.raises(ContractError):
reg.require_contract("does_not_exist")
def test_require_contract_returns_contract(self):
"""require_contract returns the contract when registered."""
reg = get_contract_registry()
sentinel = object()
reg.register("demo", sentinel)
assert reg.require_contract("demo") is sentinel
def test_list_available(self):
"""list_available returns sorted plugin names."""
reg = get_contract_registry()
reg.register("zebra", object())
reg.register("alpha", object())
assert reg.list_available() == ["alpha", "zebra"]
def test_module_level_get_contract(self):
"""Module-level get_contract function works."""
reg = get_contract_registry()
sentinel = object()
reg.register("demo", sentinel)
assert get_contract("demo") is sentinel
def test_reset_for_testing_clears_state(self):
"""reset clears all registered contracts."""
reg = get_contract_registry()
reg.register("a", object())
reg.register("b", object())
assert len(reg.list_available()) == 2
reset_contract_registry_for_testing()
assert reg.list_available() == []
# ─── Kommunikation contract ───
class TestKommunikationContract:
@pytest.fixture(autouse=True)
def _load_komm(self):
"""Reload kommunikation contracts so it re-registers after reset."""
_reload_contracts("kommunikation")
def test_contract_registers(self):
"""Importing kommunikation.contracts registers it in the registry."""
contract = get_contract("kommunikation")
assert contract is not None
assert contract.contract_name == "kommunikation"
def test_exposes_services(self):
"""Contract exposes service functions."""
contract = get_contract("kommunikation")
assert callable(contract.parse_mentions)
assert callable(contract.get_conversation)
assert callable(contract.get_messages)
assert callable(contract.send_message)
assert callable(contract.create_plugin_room)
def test_exposes_participant_registry(self):
"""Contract exposes participant registry types."""
contract = get_contract("kommunikation")
assert callable(contract.get_participant_registry)
assert contract.ParticipantHandler is not None
def test_exposes_miniapp_registry(self):
"""Contract exposes MiniAppRegistry."""
contract = get_contract("kommunikation")
assert contract.MiniAppRegistry is not None
assert contract.MiniAppDef is not None
def test_exposes_models(self):
"""Contract exposes ORM models."""
contract = get_contract("kommunikation")
assert contract.CommConversation is not None
assert contract.CommMessage is not None
assert contract.CommParticipant is not None
def test_parse_mentions_works(self):
"""parse_mentions actually parses @mentions."""
contract = get_contract("kommunikation")
result = contract.parse_mentions("hello @ai_proactive and @system")
assert result == ["ai_proactive", "system"]
# ─── AI Assistant contract ───
class TestAIAssistantContract:
@pytest.fixture(autouse=True)
def _load_ai(self):
"""Reload ai_assistant contracts so it re-registers after reset."""
_reload_contracts("ai_assistant")
def test_contract_registers(self):
"""Importing ai_assistant.contracts registers it."""
contract = get_contract("ai_assistant")
assert contract is not None
assert contract.contract_name == "ai_assistant"
def test_exposes_tool_registry(self):
"""Contract exposes tool registry functions and types."""
contract = get_contract("ai_assistant")
assert callable(contract.get_tool_registry)
assert contract.ToolRegistry is not None
assert contract.AITool is not None
def test_exposes_get_default_provider(self):
"""Contract exposes get_default_provider."""
contract = get_contract("ai_assistant")
assert callable(contract.get_default_provider)
def test_tool_registry_singleton_works(self):
"""get_tool_registry returns a working singleton."""
contract = get_contract("ai_assistant")
reg = contract.get_tool_registry()
assert reg is not None
reg2 = contract.get_tool_registry()
assert reg is reg2
# ─── Mail contract ───
class TestMailContract:
@pytest.fixture(autouse=True)
def _load_mail(self):
"""Reload mail contracts so it re-registers after reset."""
_reload_contracts("mail")
def test_contract_registers(self):
"""Importing mail.contracts registers it."""
contract = get_contract("mail")
assert contract is not None
assert contract.contract_name == "mail"
def test_exposes_mail_model(self):
"""Contract exposes the Mail ORM model."""
contract = get_contract("mail")
assert contract.Mail is not None
from app.plugins.builtins.mail.models import Mail as MailModel
assert contract.Mail is MailModel
# ─── Lazy loading ───
class TestLazyLoading:
def test_lazy_load_on_first_access(self):
"""get_contract triggers lazy load of contracts module."""
reg = get_contract_registry()
contract = reg.get_contract("kommunikation")
assert contract is not None
assert contract.contract_name == "kommunikation"
def test_lazy_load_missing_plugin_returns_none(self):
"""Lazy load of non-existent plugin returns None."""
reg = get_contract_registry()
assert reg.get_contract("nonexistent_plugin_xyz") is None
@@ -0,0 +1,23 @@
"""Unified Search plugin contract — public interface for cross-plugin access."""
from __future__ import annotations
from app.plugins.builtins.unified_search.embedding import generate_embedding
from app.plugins.builtins.unified_search.search_engine import hybrid_search
class UnifiedSearchContract:
"""Public contract for the unified_search plugin."""
generate_embedding = staticmethod(generate_embedding)
hybrid_search = staticmethod(hybrid_search)
_contract_instance: UnifiedSearchContract | None = None
def get_contract() -> UnifiedSearchContract:
global _contract_instance
if _contract_instance is None:
_contract_instance = UnifiedSearchContract()
return _contract_instance
@@ -40,7 +40,7 @@ async def _get_api_credentials(
# Fallback to DB provider
if db and tenant_id:
try:
from app.plugins.builtins.ai_assistant.services import get_default_provider
from app.plugins.builtins.ai_assistant.contracts import get_default_provider
provider = await get_default_provider(db, tenant_id)
if provider and provider.api_key:
return provider.api_key, provider.base_url, provider.provider_type
@@ -38,7 +38,7 @@ async def _get_api_credentials(
"""
if db and tenant_id:
try:
from app.plugins.builtins.ai_assistant.services import get_default_provider
from app.plugins.builtins.ai_assistant.contracts import get_default_provider
provider = await get_default_provider(db, tenant_id)
if provider and provider.api_key:
return provider.api_key, provider.base_url, provider.provider_type