Files
leocrm/app/plugins/builtins/marketplace/routes.py
T
Agent Zero 7d976276ae
Check Cross-Plugin Imports / check (push) Has been cancelled
test: Add 126 tests for Phase 5 plugins + fix 2 source bugs
Tests (5 files, 126 tests, all passing):
- test_agent_memory.py: 22 tests (store, retrieve, delete, routes, tenant isolation)
- test_graph_rag.py: 22 tests (create, traverse BFS, bidirectional, max_hops, cycles, routes)
- test_marketplace.py: 26 tests (fetch, download, verify, install, categories, routes)
- test_agent_subtasks.py: 25 tests (create, wait, cancel, aggregate, list, model)
- test_external_agent_api.py: 31 tests (run, status, stream, auth, rate limit)

Bugfixes:
- graph_rag/models.py: metadata -> meta (SQLAlchemy reserved attribute)
- marketplace/routes.py: fix default parameter validation
2026-08-04 16:02:36 +02:00

215 lines
7.6 KiB
Python

"""Marketplace plugin routes — browse, search, verify, install."""
from __future__ import annotations
import logging
import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.deps import require_admin, require_permission
from app.plugins.builtins.marketplace.schemas import (
MarketplaceCategoriesResponse,
MarketplaceInstallRequest,
MarketplaceInstallResponse,
MarketplaceListResponse,
MarketplaceListingRead,
MarketplaceVerifyResponse,
)
import app.plugins.builtins.marketplace.services as marketplace_services
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/marketplace", tags=["marketplace"])
@router.get("/listings", response_model=MarketplaceListResponse)
async def list_marketplace_listings(
search: str | None = Query(None, min_length=1, description="Search term for name, display_name, description, or author"),
tags: str | None = Query(None, description="Comma-separated list of tags to filter by"),
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(20, ge=1, le=100, description="Items per page"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("marketplace:read")),
):
"""List all available plugins in the marketplace.
Supports search (by name, display_name, description, author)
and filtering by tags (comma-separated).
"""
tag_list = [t.strip() for t in tags.split(",") if t.strip()] if tags else None
result = await marketplace_services.fetch_listings(
db,
search=search,
tags=tag_list,
page=page,
page_size=page_size,
)
return MarketplaceListResponse(
listings=[MarketplaceListingRead(**l) for l in result["listings"]],
total=result["total"],
page=result["page"],
page_size=result["page_size"],
)
@router.get("/listings/{name}", response_model=MarketplaceListingRead)
async def get_marketplace_listing(
name: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("marketplace:read")),
):
"""Get details for a specific marketplace plugin."""
listing = await marketplace_services.get_listing_by_name(db, name)
if not listing:
raise HTTPException(
status_code=404,
detail={"detail": f"Plugin '{name}' not found in marketplace", "code": "not_found"},
)
return MarketplaceListingRead(
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,
)
@router.post("/install/{name}", response_model=MarketplaceInstallResponse)
async def install_from_marketplace(
name: str,
body: MarketplaceInstallRequest = MarketplaceInstallRequest.model_construct(name=""),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_admin),
):
"""Download, verify, and install a plugin from the marketplace.
1. Looks up the plugin in marketplace_listings
2. Downloads the ZIP from the listing's download_url
3. Verifies the Ed25519 signature (if signature and public_key provided)
4. Installs via the existing plugin installation logic
5. Optionally activates the plugin
"""
import uuid as uuid_mod
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
user_id = uuid_mod.UUID(current_user["user_id"])
try:
result = await marketplace_services.install_plugin(
db,
name,
activate=body.activate,
tenant_id=tenant_id,
user_id=user_id,
)
return MarketplaceInstallResponse(**result)
except ValueError as exc:
raise HTTPException(
status_code=400,
detail={"detail": str(exc), "code": "install_error"},
) from None
except Exception as exc:
logger.exception("install_from_marketplace: unexpected error for '%s'", name)
raise HTTPException(
status_code=500,
detail={"detail": f"Installation failed: {exc}", "code": "install_error"},
) from None
@router.post("/verify/{name}", response_model=MarketplaceVerifyResponse)
async def verify_plugin_signature(
name: str,
body: MarketplaceInstallRequest = MarketplaceInstallRequest.model_construct(name=""),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("marketplace:read")),
):
"""Verify a plugin's signature without installing it.
Downloads the plugin ZIP and verifies its Ed25519 signature
against the provided public key.
"""
listing = await marketplace_services.get_listing_by_name(db, name)
if not listing:
raise HTTPException(
status_code=404,
detail={"detail": f"Plugin '{name}' not found in marketplace", "code": "not_found"},
)
# Use signature from request, or fall back to listing's stored public key
signature_bytes = body.signature.encode("utf-8") if body.signature else None
public_key_bytes = (
body.public_key.encode("utf-8") if body.public_key
else (listing.signature_public_key.encode("utf-8") if listing.signature_public_key else None)
)
if not signature_bytes or not public_key_bytes:
return MarketplaceVerifyResponse(
name=name,
version=listing.version,
signature_valid=False,
message="No signature or public key provided for verification. "
"Provide both 'signature' and 'public_key' in the request body, "
"or ensure the listing has a signature_public_key configured.",
)
try:
zip_path = await marketplace_services.download_plugin(name, listing.download_url)
try:
is_valid = await marketplace_services.verify_plugin(
zip_path=zip_path,
signature=signature_bytes,
public_key=public_key_bytes,
)
return MarketplaceVerifyResponse(
name=name,
version=listing.version,
signature_valid=is_valid,
message=(
"Signature is valid." if is_valid
else "Signature verification failed. The plugin may have been tampered with."
),
)
finally:
# Clean up temp files
import shutil
shutil.rmtree(zip_path.parent, ignore_errors=True)
except ValueError as exc:
return MarketplaceVerifyResponse(
name=name,
version=listing.version,
signature_valid=False,
message=str(exc),
)
@router.get("/categories", response_model=MarketplaceCategoriesResponse)
async def list_categories(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("marketplace:read")),
):
"""List all unique categories/tags from marketplace listings."""
categories = await marketplace_services.get_categories(db)
return MarketplaceCategoriesResponse(
categories=categories,
total=len(categories),
)