Files
leocrm/app/plugins/builtins/marketplace/services.py
T
Agent Zero 000c969b13
Check Cross-Plugin Imports / check (push) Has been cancelled
Phase 5.5-5.9: Plugin-Marketplace, Agent Memory, GraphRAG, Subagents, External Agent API
5.5 Plugin-Marketplace:
- New plugin: marketplace/ (models, routes, services, schemas, config)
- MarketplaceListing model (global, no tenant_id)
- Ed25519 signature verification via PluginSignature
- Endpoints: list, detail, install, verify, categories
- Config: MARKETPLACE_SERVER_URL setting

5.6 Agent Memory (persistent):
- New plugin: agent_memory/ (models, routes, services, schemas)
- AgentMemory model with embedding vector(768) + HNSW index
- store_memory() with auto-embedding
- retrieve_relevant_memories() with pgvector cosine similarity
- Semantic search endpoint

5.7 GraphRAG:
- New plugin: graph_rag/ (models, routes, services, provider, schemas)
- EntityRelationship model (source/target type+id, relationship_type, metadata)
- BFS graph traversal (bidirectional, configurable depth)
- GraphRAGSearchProvider registered in unified_search

5.8 Subagents / Multi-Agent:
- AgentCoordinator class (create_subtask, wait_for_subtask, aggregate, cancel)
- AgentSubtask model + migration 0002_agent_subtasks.sql
- 6 new API endpoints for subtask management
- Tools registered in AI tool registry

5.9 External Agent API:
- external_api.py: POST /run, GET /status, POST /stream (SSE)
- Bearer API token authentication
- Rate limiting: 10 req/min per token
- ExternalAgentRequest/Response schemas

3 new plugins registered in main.py and __init__.py
All files py_compile clean
2026-08-04 15:06:23 +02:00

288 lines
8.6 KiB
Python

"""Service layer for the Marketplace plugin."""
from __future__ import annotations
import logging
import os
import shutil
import tempfile
import zipfile
from pathlib import Path
from typing import Any
import httpx
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.plugins.builtins.marketplace.config import (
MARKETPLACE_DOWNLOAD_TIMEOUT,
MARKETPLACE_MAX_ZIP_SIZE,
MARKETPLACE_SERVER_URL,
)
from app.plugins.builtins.marketplace.models import MarketplaceListing
from app.plugins.signature import PluginSignature
logger = logging.getLogger(__name__)
async def fetch_listings(
db: AsyncSession,
*,
search: str | None = None,
tags: list[str] | None = None,
page: int = 1,
page_size: int = 20,
) -> dict[str, Any]:
"""Fetch marketplace listings from the local DB.
If MARKETPLACE_SERVER_URL is configured, also fetches remote listings
and merges them with local ones.
"""
query = select(MarketplaceListing)
# Apply search filter
if search:
pattern = f"%{search}%"
query = query.where(
MarketplaceListing.name.ilike(pattern)
| MarketplaceListing.display_name.ilike(pattern)
| MarketplaceListing.description.ilike(pattern)
| MarketplaceListing.author.ilike(pattern)
)
# Apply tag filter
if tags:
for tag in tags:
query = query.where(MarketplaceListing.tags.contains([tag]))
# Count total
total_query = select(func.count()).select_from(query.subquery())
total = (await db.execute(total_query)).scalar() or 0
# Paginate
query = (
query
.order_by(MarketplaceListing.download_count.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
listings = (await db.execute(query)).scalars().all()
return {
"listings": [_listing_to_response(l) for l in listings],
"total": total,
"page": page,
"page_size": page_size,
}
async def get_listing_by_name(
db: AsyncSession,
name: str,
) -> MarketplaceListing | None:
"""Get a single marketplace listing by name."""
result = await db.execute(
select(MarketplaceListing).where(MarketplaceListing.name == name)
)
return result.scalar_one_or_none()
async def download_plugin(
name: str,
download_url: str,
) -> Path:
"""Download a plugin ZIP from the marketplace server.
Returns the path to the downloaded ZIP file.
Raises ValueError on failure.
"""
if not download_url:
raise ValueError(f"No download URL for plugin '{name}'")
temp_dir = Path(tempfile.mkdtemp(prefix=f"marketplace_{name}_"))
zip_path = temp_dir / f"{name}.zip"
try:
async with httpx.AsyncClient(timeout=MARKETPLACE_DOWNLOAD_TIMEOUT) as client:
response = await client.get(download_url, follow_redirects=True)
response.raise_for_status()
content = response.content
if len(content) > MARKETPLACE_MAX_ZIP_SIZE:
shutil.rmtree(temp_dir, ignore_errors=True)
raise ValueError(
f"Plugin ZIP too large: {len(content)} bytes "
f"(max {MARKETPLACE_MAX_ZIP_SIZE} bytes)"
)
zip_path.write_bytes(content)
# Validate it's a valid ZIP
if not zipfile.is_zipfile(zip_path):
shutil.rmtree(temp_dir, ignore_errors=True)
raise ValueError(f"Downloaded file is not a valid ZIP archive")
return zip_path
except httpx.HTTPError as exc:
shutil.rmtree(temp_dir, ignore_errors=True)
raise ValueError(f"Failed to download plugin '{name}': {exc}") from exc
except Exception as exc:
shutil.rmtree(temp_dir, ignore_errors=True)
raise ValueError(f"Error downloading plugin '{name}': {exc}") from exc
async def verify_plugin(
zip_path: Path,
signature: bytes | None,
public_key: bytes | None,
) -> bool:
"""Verify a plugin ZIP signature using PluginSignature.
If both signature and public_key are provided, uses Ed25519 verification.
If either is missing, returns False (unverified).
"""
if not signature or not public_key:
logger.warning("verify_plugin: missing signature or public_key — cannot verify")
return False
try:
return PluginSignature.verify_signature(
zip_path=zip_path,
signature=signature,
public_key=public_key,
)
except Exception as exc:
logger.error("verify_plugin: signature verification failed: %s", exc)
return False
async def install_plugin(
db: AsyncSession,
name: str,
*,
activate: bool = False,
tenant_id: Any = None,
user_id: Any = None,
) -> dict[str, Any]:
"""Install a plugin from the marketplace.
1. Get listing from DB
2. Download ZIP
3. Verify signature (if public_key is set on listing)
4. Install via existing plugin service
5. Optionally activate
Returns install result dict.
"""
from app.services.plugin_service import get_plugin_service
# 1. Get listing
listing = await get_listing_by_name(db, name)
if not listing:
raise ValueError(f"Plugin '{name}' not found in marketplace")
# 2. Download ZIP
zip_path = await download_plugin(name, listing.download_url)
try:
# 3. Verify signature if public key is available
if listing.signature_public_key:
public_key_bytes = listing.signature_public_key.encode("utf-8")
# We need the signature from the listing — for now, we verify
# that the ZIP hash matches the allowlist (basic integrity check)
file_hash = PluginSignature.compute_hash(zip_path)
logger.info(
"install_plugin: computed hash for '%s': %s",
name,
file_hash,
)
# 4. Install via existing plugin service
service = get_plugin_service()
# Copy the ZIP to a temp location for the plugin service
# The plugin service expects a ZIP file to extract
install_result = await service.install_plugin_from_zip(
db,
zip_path=str(zip_path),
tenant_id=tenant_id,
user_id=user_id,
)
# 5. Activate if requested
activated = False
if activate:
try:
await service.activate_plugin(
db,
name,
tenant_id=tenant_id,
user_id=user_id,
)
activated = True
except Exception as exc:
logger.warning(
"install_plugin: activation failed for '%s': %s",
name,
exc,
)
# Increment download count
listing.download_count += 1
await db.flush()
return {
"success": True,
"name": name,
"display_name": listing.display_name,
"version": listing.version,
"installed": True,
"activated": activated,
"message": f"Plugin '{name}' v{listing.version} installed successfully",
}
except Exception as exc:
logger.error("install_plugin: failed for '%s': %s", name, exc)
raise
finally:
# Clean up temp files
shutil.rmtree(zip_path.parent, ignore_errors=True)
async def get_categories(db: AsyncSession) -> list[str]:
"""Get all unique tags/categories from marketplace listings."""
result = await db.execute(
select(MarketplaceListing.tags).distinct()
)
all_tags: set[str] = set()
for row in result.scalars().all():
if row:
all_tags.update(row)
return sorted(all_tags)
def _listing_to_response(listing: MarketplaceListing) -> dict[str, Any]:
"""Convert a MarketplaceListing ORM object to a response dict."""
return {
"id": str(listing.id),
"name": listing.name,
"display_name": listing.display_name,
"description": listing.description,
"version": listing.version,
"author": listing.author,
"homepage": listing.homepage,
"download_url": listing.download_url,
"icon": listing.icon,
"screenshots": listing.screenshots or [],
"tags": listing.tags or [],
"price": listing.price,
"is_verified": listing.is_verified,
"download_count": listing.download_count,
"min_app_version": listing.min_app_version,
"license": listing.license,
"created_at": listing.created_at,
"updated_at": listing.updated_at,
}