feat(claude-code): serve gateway-hosted plugin archives for Claude Desktop marketplaces

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yassin 2026-10-14 11:18:34 +00:00
parent 7d7e28829f
commit 65fffa4136
23 changed files with 1714 additions and 192 deletions

View file

@ -0,0 +1,27 @@
SET LOCAL lock_timeout = '10s';
ALTER TABLE "LiteLLM_ClaudeCodePluginTable" ADD COLUMN IF NOT EXISTS "archive_sha256" TEXT;
CREATE TABLE IF NOT EXISTS "LiteLLM_ClaudeCodePluginArchiveTable" (
"plugin_id" TEXT NOT NULL,
"content" BYTEA NOT NULL,
"size_bytes" INTEGER NOT NULL,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LiteLLM_ClaudeCodePluginArchiveTable_pkey" PRIMARY KEY ("plugin_id")
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'LiteLLM_ClaudeCodePluginArchiveTable_plugin_id_fkey'
AND conrelid = '"LiteLLM_ClaudeCodePluginArchiveTable"'::regclass
) THEN
ALTER TABLE "LiteLLM_ClaudeCodePluginArchiveTable"
ADD CONSTRAINT "LiteLLM_ClaudeCodePluginArchiveTable_plugin_id_fkey"
FOREIGN KEY ("plugin_id") REFERENCES "LiteLLM_ClaudeCodePluginTable"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;

View file

@ -1608,14 +1608,26 @@ model LiteLLM_ClaudeCodePluginTable {
description String?
manifest_json String?
files_json String? @default("{}")
archive_sha256 String?
enabled Boolean @default(true)
created_at DateTime? @default(now())
updated_at DateTime? @default(now()) @updatedAt
created_by String?
archive LiteLLM_ClaudeCodePluginArchiveTable?
@@map("LiteLLM_ClaudeCodePluginTable")
}
model LiteLLM_ClaudeCodePluginArchiveTable {
plugin_id String @id
content Bytes
size_bytes Int
updated_at DateTime @default(now()) @updatedAt
plugin LiteLLM_ClaudeCodePluginTable @relation(fields: [plugin_id], references: [id], onDelete: Cascade)
@@map("LiteLLM_ClaudeCodePluginArchiveTable")
}
// User/team-scoped memory store with a GLOBAL unique key.
// `value` is a string (typically markdown/text meant for LLM context);
// `metadata` is an optional JSON envelope for structured tags without schema changes.

View file

@ -91,6 +91,7 @@ OTLP_MAX_ATTRIBUTE_VALUE_BYTES: Final = get_env_int("OTLP_MAX_ATTRIBUTE_VALUE_BY
OTLP_RETRY_AFTER_SECONDS: Final = get_env_int("OTLP_RETRY_AFTER_SECONDS", 2)
TRACE_READ_RETRY_AFTER_SECONDS: Final = get_env_int("TRACE_READ_RETRY_AFTER_SECONDS", 2)
OTLP_MAX_CONCURRENT_INGESTS: Final = get_env_int("OTLP_MAX_CONCURRENT_INGESTS", 2)
CLAUDE_CODE_PLUGIN_ARCHIVE_MAX_BYTES: Final = get_env_int("CLAUDE_CODE_PLUGIN_ARCHIVE_MAX_BYTES", 50 * 1024 * 1024)
AGENT_TRACING_INPUT_PREVIEW_CHARS: Final = get_env_int("AGENT_TRACING_INPUT_PREVIEW_CHARS", 240)
AGENT_TRACING_LIST_PAGE_SIZE: Final = get_env_int("AGENT_TRACING_LIST_PAGE_SIZE", 50)
AGENT_TRACING_AGENT_LIST_LIMIT: Final = get_env_int("AGENT_TRACING_AGENT_LIST_LIMIT", 500)

View file

@ -338,6 +338,7 @@ _PRISMA_MODELS: Final[frozenset[str]] = frozenset(
"LiteLLM_TelemetryReport",
"LiteLLM_AccessGroupTable",
"LiteLLM_ClaudeCodePluginTable",
"LiteLLM_ClaudeCodePluginArchiveTable",
"LiteLLM_MemoryTable",
"LiteLLM_AdaptiveRouterState",
"LiteLLM_AdaptiveRouterSession",

View file

@ -7148,6 +7148,17 @@
"PluginListItem": {
"description": "Plugin item in list responses.",
"properties": {
"archive_sha256": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Archive Sha256"
},
"author": {
"anyOf": [
{
@ -7462,7 +7473,7 @@
"type": "null"
}
],
"description": "Emitted as installationPreference on this plugin's marketplace.json entry. Needs an archive source, the only plugins Claude Desktop lists from a marketplace.json URL, and 'auto_install' or 'required' also need its sha256. Claude Desktop honors the mark only when the archive and marketplace.json share its inference gateway origin",
"description": "Emitted as installationPreference on this plugin's marketplace.json entry. Needs an archive source, the only plugins Claude Desktop lists from a marketplace.json URL. LiteLLM supplies the sha256 for gateway-hosted archives. Claude Desktop honors the mark only when the archive and marketplace.json share its inference gateway origin",
"title": "Installation Preference"
},
"keywords": {
@ -7502,7 +7513,7 @@
"additionalProperties": {
"type": "string"
},
"description": "Plugin source reference. Supported formats:\n- GitHub: {'source': 'github', 'repo': 'org/repo'}\n- Git URL: {'source': 'url', 'url': 'https://github.com/org/repo.git'}\n- Git Subdir: {'source': 'git-subdir', 'url': 'https://github.com/org/repo.git', 'path': 'plugins/plugin-name'}\n- Zip archive on any https host (e.g. S3): {'source': 'archive', 'url': 'https://bucket.s3.amazonaws.com/plugin.zip', 'sha256': '<optional hex digest>'}",
"description": "Plugin source reference. Supported formats:\n- GitHub: {'source': 'github', 'repo': 'org/repo'}\n- Git URL: {'source': 'url', 'url': 'https://github.com/org/repo.git'}\n- Git Subdir: {'source': 'git-subdir', 'url': 'https://github.com/org/repo.git', 'path': 'plugins/plugin-name'}\n- Zip archive on any https host (e.g. S3): {'source': 'archive', 'url': 'https://bucket.s3.amazonaws.com/plugin.zip', 'sha256': '<optional hex digest>'}\n- Gateway-hosted archive: {'source': 'hosted'}; upload the zip to LiteLLM after registering the plugin",
"title": "Source",
"type": "object"
},
@ -7629,7 +7640,7 @@
"type": "null"
}
],
"description": "Emitted as installationPreference on this plugin's marketplace.json entry. Needs an archive source, the only plugins Claude Desktop lists from a marketplace.json URL, and 'auto_install' or 'required' also need its sha256. Claude Desktop honors the mark only when the archive and marketplace.json share its inference gateway origin",
"description": "Emitted as installationPreference on this plugin's marketplace.json entry. Needs an archive source, the only plugins Claude Desktop lists from a marketplace.json URL. LiteLLM supplies the sha256 for gateway-hosted archives. Claude Desktop honors the mark only when the archive and marketplace.json share its inference gateway origin",
"title": "Installation Preference"
},
"keywords": {
@ -7663,7 +7674,7 @@
"additionalProperties": {
"type": "string"
},
"description": "Plugin source reference. Supported formats:\n- GitHub: {'source': 'github', 'repo': 'org/repo'}\n- Git URL: {'source': 'url', 'url': 'https://github.com/org/repo.git'}\n- Git Subdir: {'source': 'git-subdir', 'url': 'https://github.com/org/repo.git', 'path': 'plugins/plugin-name'}\n- Zip archive on any https host (e.g. S3): {'source': 'archive', 'url': 'https://bucket.s3.amazonaws.com/plugin.zip', 'sha256': '<optional hex digest>'}",
"description": "Plugin source reference. Supported formats:\n- GitHub: {'source': 'github', 'repo': 'org/repo'}\n- Git URL: {'source': 'url', 'url': 'https://github.com/org/repo.git'}\n- Git Subdir: {'source': 'git-subdir', 'url': 'https://github.com/org/repo.git', 'path': 'plugins/plugin-name'}\n- Zip archive on any https host (e.g. S3): {'source': 'archive', 'url': 'https://bucket.s3.amazonaws.com/plugin.zip', 'sha256': '<optional hex digest>'}\n- Gateway-hosted archive: {'source': 'hosted'}; upload the zip to LiteLLM after registering the plugin",
"title": "Source",
"type": "object"
},
@ -7686,6 +7697,31 @@
"title": "UpdatePluginRequest",
"type": "object"
},
"UploadPluginArchiveResponse": {
"properties": {
"plugin": {
"$ref": "#/components/schemas/PluginResponse",
"description": "Updated plugin information"
},
"sha256": {
"description": "SHA-256 digest of the uploaded archive",
"title": "Sha256",
"type": "string"
},
"size_bytes": {
"description": "Size of the uploaded archive in bytes",
"title": "Size Bytes",
"type": "integer"
}
},
"required": [
"plugin",
"sha256",
"size_bytes"
],
"title": "UploadPluginArchiveResponse",
"type": "object"
},
"ValidationError": {
"properties": {
"ctx": {
@ -8017,6 +8053,128 @@
]
}
},
"/claude-code/plugins/{plugin_name}/archive": {
"put": {
"description": "Upload and validate a zip archive for an existing hosted plugin.",
"operationId": "upload_plugin_archive_claude_code_plugins__plugin_name__archive_put",
"parameters": [
{
"in": "path",
"name": "plugin_name",
"required": true,
"schema": {
"title": "Plugin Name",
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/octet-stream": {
"schema": {
"format": "binary",
"type": "string"
}
},
"application/zip": {
"schema": {
"format": "binary",
"type": "string"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UploadPluginArchiveResponse"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"security": [
{
"APIKeyHeader": []
}
],
"summary": "Upload Plugin Archive",
"tags": [
"claude_code_marketplace"
]
}
},
"/claude-code/plugins/{plugin_name}/archive.zip": {
"get": {
"description": "Download a hosted plugin archive when it is visible to the caller.",
"operationId": "download_plugin_archive_claude_code_plugins__plugin_name__archive_zip_get",
"parameters": [
{
"in": "path",
"name": "plugin_name",
"required": true,
"schema": {
"title": "Plugin Name",
"type": "string"
}
},
{
"in": "query",
"name": "key",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Key"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Download Plugin Archive",
"tags": [
"claude_code_marketplace"
]
}
},
"/claude-code/plugins/{plugin_name}/disable": {
"post": {
"description": "Disable a plugin without deleting it.\n\nRequires a proxy admin API key.\n\nParameters:\n - plugin_name: The name of the plugin to disable",

View file

@ -451,6 +451,8 @@ class LiteLLMRoutes(enum.Enum):
"/claude-code/marketplace.json",
"/claude-code/plugins",
"/claude-code/plugins/{plugin_name}",
"/claude-code/plugins/{plugin_name}/archive",
"/claude-code/plugins/{plugin_name}/archive.zip",
]
# MCP tool-call / passthrough routes — data-plane. Gated by DISABLE_LLM_API_ENDPOINTS.

View file

@ -17,19 +17,28 @@ Endpoints:
/claude-code/plugins/{name} - DELETE - Delete a plugin (proxy admin only)
"""
from __future__ import annotations
import asyncio
import base64
import hashlib
import io
import json
import re
from collections.abc import Mapping, Sequence
import zipfile
from collections.abc import Callable, Mapping, Sequence
from datetime import datetime, timezone
from typing import Annotated, Final, Protocol, TypedDict, get_args
from urllib.parse import urlsplit
from pathlib import PurePosixPath, PureWindowsPath
from typing import TYPE_CHECKING, Annotated, Final, Protocol, TypedDict, TypeVar, get_args
from urllib.parse import quote, urlsplit
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse
from pydantic import TypeAdapter
from fastapi.responses import JSONResponse, Response
from pydantic import TypeAdapter, ValidationError
from typing_extensions import ReadOnly
from litellm._logging import verbose_proxy_logger
from litellm.constants import CLAUDE_CODE_PLUGIN_ARCHIVE_MAX_BYTES
from litellm.proxy._types import CommonProxyErrors, ProxyException, UserAPIKeyAuth
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_skill_access import (
SkillVisibility,
@ -37,7 +46,7 @@ from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_skill_a
)
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_utils.resource_ownership import is_proxy_admin
from litellm.repositories.table_repositories import ClaudeCodePluginRepository
from litellm.repositories.table_repositories import ClaudeCodePluginArchiveRepository, ClaudeCodePluginRepository
from litellm.types.proxy.claude_code_endpoints import (
InstallationPreference,
ListPluginsResponse,
@ -47,8 +56,13 @@ from litellm.types.proxy.claude_code_endpoints import (
RegisterPluginRequest,
RegisterPluginResponse,
UpdatePluginRequest,
UploadPluginArchiveResponse,
)
if TYPE_CHECKING:
from litellm.proxy.utils import PrismaClient
router: Final = APIRouter()
@ -62,25 +76,33 @@ class _PluginRecord(Protocol):
created_at: datetime | None
updated_at: datetime | None
created_by: str | None
archive_sha256: str | None
class _PluginArchiveRecord(Protocol):
def model_dump(self) -> Mapping[str, object]: ...
class _MarketplaceEntry(TypedDict, total=False):
name: str
source: object
version: str
description: str
author: object
homepage: object
keywords: object
category: object
name: ReadOnly[str]
source: ReadOnly[object]
installationPreference: ReadOnly[InstallationPreference]
version: ReadOnly[str]
description: ReadOnly[str]
author: ReadOnly[object]
homepage: ReadOnly[object]
keywords: ReadOnly[object]
category: ReadOnly[object]
_INSTALLATION_PREFERENCES: Final[tuple[InstallationPreference, ...]] = get_args(InstallationPreference)
MARKETPLACE_ENTRY_FIELDS: Final = TypeAdapter(_MarketplaceEntry)
MANIFEST_FIELDS: Final = TypeAdapter(dict[str, object])
PLUGIN_LIST_ITEM_FIELDS: Final = TypeAdapter(PluginListItem)
_SELF_INSTALLING_PREFERENCES: Final[frozenset[InstallationPreference]] = frozenset({"auto_install", "required"})
_EncodedArchive = TypeVar("_EncodedArchive") # rebind-ok: generic encoder type must be module scoped
def get_installation_preference(manifest: Mapping[str, object]) -> InstallationPreference | None:
@ -93,7 +115,7 @@ def get_installation_preference(manifest: Mapping[str, object]) -> InstallationP
return None
async def get_prisma_client() -> object:
async def get_prisma_client() -> PrismaClient:
"""Get the prisma client from proxy_server."""
from litellm.proxy.proxy_server import prisma_client
@ -105,6 +127,75 @@ async def get_prisma_client() -> object:
return prisma_client
def _hosted_marketplace_source(
plugin: _PluginRecord,
marketplace_base_url: str,
) -> object | None:
if plugin.archive_sha256 is None:
verbose_proxy_logger.warning("Hosted plugin %s has no archive, skipping", plugin.name)
return None
return {
"source": "archive",
"url": f"{marketplace_base_url}/claude-code/plugins/{quote(plugin.name, safe='')}/archive.zip",
"sha256": plugin.archive_sha256,
}
def _marketplace_entry(
plugin: _PluginRecord,
marketplace_base_url: str,
visibility: SkillVisibility,
) -> _MarketplaceEntry | None:
if not visibility.allows(plugin):
return None
try:
manifest: Final[Mapping[str, object]] = MANIFEST_FIELDS.validate_json(plugin.manifest_json or "{}")
except ValidationError:
verbose_proxy_logger.warning("Plugin %s has invalid manifest JSON, skipping", plugin.name)
return None
if "source" not in manifest:
verbose_proxy_logger.warning("Plugin %s has no source field, skipping", plugin.name)
return None
source: Final = manifest["source"]
marketplace_source: Final[object] = (
_hosted_marketplace_source(plugin, marketplace_base_url) if source == {"source": "hosted"} else source
)
if marketplace_source is None:
return None
installation_preference: Final = get_installation_preference(manifest)
return MARKETPLACE_ENTRY_FIELDS.validate_python(
{
"name": plugin.name,
"source": marketplace_source,
**({"installationPreference": installation_preference} if installation_preference is not None else {}),
**({"version": plugin.version} if plugin.version else {}),
**({"description": plugin.description} if plugin.description else {}),
**({"author": manifest["author"]} if "author" in manifest else {}),
**({"homepage": manifest["homepage"]} if "homepage" in manifest else {}),
**({"keywords": manifest["keywords"]} if "keywords" in manifest else {}),
**({"category": manifest["category"]} if "category" in manifest else {}),
}
)
async def _update_plugin_and_delete_archive(
prisma_client: PrismaClient,
plugin_id: str,
plugin_name: str,
update_data: Mapping[str, object],
) -> _PluginRecord | None:
async with prisma_client.db.tx() as transaction:
await transaction.litellm_claudecodepluginarchivetable.delete_many(where={"plugin_id": plugin_id})
return await transaction.litellm_claudecodeplugintable.update(
where={"name": plugin_name},
data=update_data,
)
_get_prisma_client: Final = get_prisma_client
@ -136,55 +227,19 @@ async def get_marketplace(request: Request, key: str | None = None):
"""
try:
prisma_client: Final = await get_prisma_client()
from litellm.proxy._experimental.mcp_server.oauth_utils import get_request_base_url
caller: Final[UserAPIKeyAuth | None] = (
await user_api_key_auth(request=request, api_key=f"Bearer {key}") if key else None
)
marketplace_base_url: Final = get_request_base_url(request)
caller: Final[UserAPIKeyAuth | None] = await _resolve_marketplace_caller(request, key)
visibility: Final[SkillVisibility] = await resolve_skill_visibility(caller)
plugins: Final[Sequence[_PluginRecord]] = await ClaudeCodePluginRepository(prisma_client).table.find_many(
where=visibility.where()
)
plugin_list: Final = []
for plugin in plugins:
if not visibility.allows(plugin):
continue
try:
manifest: Mapping[str, object] = json.loads(plugin.manifest_json or "{}")
except json.JSONDecodeError:
verbose_proxy_logger.warning("Plugin %s has invalid manifest JSON, skipping", plugin.name)
continue
# Source must be specified for URL-based marketplaces
if "source" not in manifest:
verbose_proxy_logger.warning("Plugin %s has no source field, skipping", plugin.name)
continue
installation_preference = get_installation_preference(manifest)
entry: _MarketplaceEntry = (
{"name": plugin.name, "source": manifest["source"]}
if installation_preference is None
else {
"name": plugin.name,
"source": manifest["source"],
"installationPreference": installation_preference,
}
)
if plugin.version:
entry["version"] = plugin.version
if plugin.description:
entry["description"] = plugin.description
if "author" in manifest:
entry["author"] = manifest["author"]
if "homepage" in manifest:
entry["homepage"] = manifest["homepage"]
if "keywords" in manifest:
entry["keywords"] = manifest["keywords"]
if "category" in manifest:
entry["category"] = manifest["category"]
plugin_list.append(entry)
plugin_entries: Final = tuple(
_marketplace_entry(plugin, marketplace_base_url, visibility) for plugin in plugins
)
plugin_list: Final = tuple(entry for entry in plugin_entries if entry is not None)
marketplace: Final = {
"name": "litellm",
@ -204,6 +259,145 @@ async def get_marketplace(request: Request, key: str | None = None):
)
async def _resolve_marketplace_caller(request: Request, key: str | None) -> UserAPIKeyAuth | None:
if key:
return await user_api_key_auth(request=request, api_key=f"Bearer {key}")
authorization: Final = request.headers.get("Authorization")
if isinstance(authorization, str) and authorization:
return await user_api_key_auth(request=request, api_key=authorization)
return None
def _archive_bytes(archive: _PluginArchiveRecord) -> bytes:
content: Final = archive.model_dump().get("content")
if isinstance(content, str):
return base64.b64decode(content, validate=True)
if isinstance(content, bytes):
return content
raise ValueError("Stored plugin archive content is not bytes")
def _archive_not_found() -> HTTPException:
return _error_response(404, "Plugin not found")
def _validate_and_hash_plugin_archive(content: bytes) -> str:
try:
with zipfile.ZipFile(io.BytesIO(content)) as archive:
paths: Final = tuple(PurePosixPath(info.filename.replace("\\", "/")) for info in archive.infolist())
except (OSError, RuntimeError, zipfile.BadZipFile) as exc:
raise ValueError("Uploaded file is not a valid zip archive") from exc
if any(
path.is_absolute()
or ".." in path.parts
or PureWindowsPath(path.as_posix()).is_absolute()
or bool(PureWindowsPath(path.as_posix()).drive)
for path in paths
):
raise ValueError("Archive paths must not be absolute or contain '..' segments")
if not any(
path.parts == (".claude-plugin", "plugin.json")
or (len(path.parts) == 3 and path.parts[1:] == (".claude-plugin", "plugin.json"))
for path in paths
):
raise ValueError(
"Archive must contain .claude-plugin/plugin.json at the root or under exactly one top-level folder"
)
return hashlib.sha256(content).hexdigest()
def _validate_and_encode_plugin_archive(
content: bytes,
encode: Callable[[bytes], _EncodedArchive],
) -> tuple[str, _EncodedArchive]:
return _validate_and_hash_plugin_archive(content), encode(content)
async def _read_plugin_archive_body(request: Request) -> bytes:
chunks: tuple[bytes, ...] = () # rebind-ok: request bytes must be accumulated within the size limit
total_bytes: int = 0 # rebind-ok: request bytes must be counted while streaming
async for chunk in request.stream():
if total_bytes + len(chunk) > CLAUDE_CODE_PLUGIN_ARCHIVE_MAX_BYTES:
raise _error_response(413, "Plugin archive exceeds the maximum allowed size")
total_bytes += len(chunk)
chunks += (chunk,)
return b"".join(chunks)
def _plugin_list_item(plugin: _PluginRecord) -> PluginListItem:
manifest: Final[Mapping[str, object]] = json.loads(plugin.manifest_json) if plugin.manifest_json else {}
return PLUGIN_LIST_ITEM_FIELDS.validate_python(
{
"id": plugin.id,
"name": plugin.name,
"version": plugin.version,
"description": plugin.description,
"source": manifest.get("source", {}),
"author": manifest.get("author"),
"homepage": manifest.get("homepage"),
"keywords": manifest.get("keywords"),
"category": manifest.get("category"),
"domain": manifest.get("domain"),
"namespace": manifest.get("namespace"),
"installation_preference": get_installation_preference(manifest),
"archive_sha256": (plugin.archive_sha256 if manifest.get("source") == {"source": "hosted"} else None),
"enabled": plugin.enabled,
"created_at": plugin.created_at.isoformat() if plugin.created_at else None,
"updated_at": plugin.updated_at.isoformat() if plugin.updated_at else None,
}
)
@router.get(
"/claude-code/plugins/{plugin_name}/archive.zip",
tags=["Claude Code Marketplace"],
)
async def download_plugin_archive(
plugin_name: str,
request: Request,
key: str | None = None,
) -> Response:
"""Download a hosted plugin archive when it is visible to the caller."""
try:
prisma_client: Final = await get_prisma_client()
caller: Final = await _resolve_marketplace_caller(request, key)
visibility: Final = await resolve_skill_visibility(caller)
plugin: Final[_PluginRecord | None] = await ClaudeCodePluginRepository(prisma_client).table.find_unique(
where={"name": plugin_name}
)
if plugin is None or not visibility.allows(plugin):
raise _archive_not_found()
try:
manifest_value: Final[Mapping[str, object]] = MANIFEST_FIELDS.validate_json(plugin.manifest_json or "{}")
except ValidationError:
raise _archive_not_found()
if manifest_value.get("source") != {"source": "hosted"} or plugin.archive_sha256 is None:
raise _archive_not_found()
archive: Final[_PluginArchiveRecord | None] = await ClaudeCodePluginArchiveRepository(
prisma_client
).table.find_unique(where={"plugin_id": plugin.id})
if archive is None:
raise _archive_not_found()
return Response(
content=_archive_bytes(archive),
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{plugin.name}.zip"'},
)
except HTTPException:
raise
except ProxyException:
raise
except Exception as e: # noqa: BLE001 # unexpected download errors map to HTTP 500
verbose_proxy_logger.exception("Error downloading plugin archive: %s", e)
raise HTTPException(status_code=500, detail={"error": str(e)})
# Allowlist for git-subdir paths: one or more segments separated by '/'.
# Each segment must start with an alphanumeric character and contain only
# alphanumeric characters, dots, hyphens, and underscores.
@ -269,17 +463,21 @@ def _validate_plugin_source(source: Mapping[str, str]) -> None:
status_code=400,
detail={"error": "archive 'sha256' must be a 64-character hex digest"},
)
elif source_type == "hosted":
if set(source) != {"source"}:
raise HTTPException(status_code=400, detail={"error": "hosted source must contain only 'source'"})
else:
raise HTTPException(
status_code=400,
detail={"error": "source.source must be 'github', 'url', 'git-subdir', or 'archive'"},
detail={"error": "source.source must be 'github', 'url', 'git-subdir', 'archive', or 'hosted'"},
)
def _validate_installation_preference(spec: PluginSpec) -> None:
if spec.installation_preference is None:
return
if spec.source.get("source") != "archive":
source_type: Final = spec.source.get("source")
if source_type not in {"archive", "hosted"}:
raise HTTPException(
status_code=400,
detail={
@ -287,6 +485,8 @@ def _validate_installation_preference(spec: PluginSpec) -> None:
"from a marketplace.json URL"
},
)
if source_type == "hosted":
return
if spec.installation_preference in _SELF_INSTALLING_PREFERENCES and not spec.source.get("sha256"):
raise HTTPException(
status_code=400,
@ -469,32 +669,7 @@ async def list_plugins(
where={"enabled": True} if enabled_only else visibility.where()
)
plugin_list: Final = []
for p in plugins:
if not visibility.allows(p):
continue
# Parse manifest to get additional fields
manifest = json.loads(p.manifest_json) if p.manifest_json else {}
plugin_list.append(
PluginListItem(
id=p.id,
name=p.name,
version=p.version,
description=p.description,
source=manifest.get("source", {}),
author=manifest.get("author"),
homepage=manifest.get("homepage"),
keywords=manifest.get("keywords"),
category=manifest.get("category"),
domain=manifest.get("domain"),
namespace=manifest.get("namespace"),
installation_preference=get_installation_preference(MANIFEST_FIELDS.validate_python(manifest)),
enabled=p.enabled,
created_at=p.created_at.isoformat() if p.created_at else None,
updated_at=p.updated_at.isoformat() if p.updated_at else None,
)
)
plugin_list: Final = [_plugin_list_item(p) for p in plugins if visibility.allows(p)]
# Sort by created_at descending (newest first)
plugin_list.sort(key=lambda x: x.created_at or "", reverse=True)
@ -564,6 +739,7 @@ async def get_plugin(
"keywords": manifest.get("keywords"),
"category": manifest.get("category"),
"installation_preference": get_installation_preference(manifest),
"archive_sha256": plugin.archive_sha256 if manifest.get("source") == {"source": "hosted"} else None,
"enabled": plugin.enabled,
"created_at": plugin.created_at.isoformat() if plugin.created_at else None,
"updated_at": plugin.updated_at.isoformat() if plugin.updated_at else None,
@ -647,17 +823,26 @@ async def update_plugin(
if not existing:
raise _error_response(404, f"Plugin '{plugin_name}' not found")
existing_manifest: Final[Mapping[str, object]] = MANIFEST_FIELDS.validate_json(existing.manifest_json or "{}")
clear_archive: Final = existing_manifest.get("source") == {"source": "hosted"} and request.source != {
"source": "hosted"
}
manifest: Final[Mapping[str, object]] = _build_plugin_manifest(plugin_name, request)
plugin: Final[_PluginRecord | None] = await ClaudeCodePluginRepository(prisma_client).table.update(
where={"name": plugin_name},
data={
"version": request.version,
"description": request.description,
"manifest_json": json.dumps(manifest),
"files_json": "{}",
"updated_at": datetime.now(timezone.utc),
},
update_data: Final = {
"version": request.version,
"description": request.description,
"manifest_json": json.dumps(manifest),
"files_json": "{}",
"updated_at": datetime.now(timezone.utc),
**({"archive_sha256": None} if clear_archive else {}),
}
plugin: Final[_PluginRecord | None] = (
await _update_plugin_and_delete_archive(prisma_client, existing.id, plugin_name, update_data)
if clear_archive
else await ClaudeCodePluginRepository(prisma_client).table.update(
where={"name": plugin_name},
data=update_data,
)
)
if plugin is None:
raise _error_response(404, f"Plugin '{plugin_name}' not found")
@ -685,6 +870,110 @@ async def update_plugin(
raise _error_response(500, f"Update failed: {e}")
@router.put(
"/claude-code/plugins/{plugin_name}/archive",
tags=["Claude Code Marketplace"],
response_model=UploadPluginArchiveResponse,
openapi_extra={
"requestBody": {
"required": True,
"content": {
"application/zip": {"schema": {"type": "string", "format": "binary"}},
"application/octet-stream": {"schema": {"type": "string", "format": "binary"}},
},
}
},
)
async def upload_plugin_archive(
plugin_name: str,
request: Request,
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
) -> UploadPluginArchiveResponse:
"""Upload and validate a zip archive for an existing hosted plugin."""
try:
_require_proxy_admin(user_api_key_dict)
prisma_client: Final = await get_prisma_client()
plugin: Final[_PluginRecord | None] = await ClaudeCodePluginRepository(prisma_client).table.find_unique(
where={"name": plugin_name}
)
if plugin is None:
raise _error_response(404, f"Plugin '{plugin_name}' not found")
if (content_length := request.headers.get("content-length")) is not None:
try:
declared_length: Final = int(content_length)
except ValueError:
raise _error_response(400, "Invalid Content-Length") from None
if declared_length < 0:
raise _error_response(400, "Invalid Content-Length")
if declared_length > CLAUDE_CODE_PLUGIN_ARCHIVE_MAX_BYTES:
raise _error_response(413, "Plugin archive exceeds the maximum allowed size")
content_type: Final = request.headers.get("content-type", "").split(";", maxsplit=1)[0].strip().lower()
if content_type not in ("application/zip", "application/octet-stream"):
raise _error_response(415, "Content-Type must be application/zip or application/octet-stream")
content: Final[bytes] = await _read_plugin_archive_body(request)
from prisma.fields import Base64
try:
sha256, encoded_content = await asyncio.to_thread(
_validate_and_encode_plugin_archive, content, Base64.encode
)
except ValueError as exc:
raise _error_response(400, str(exc)) from exc
manifest: Final[Mapping[str, object]] = MANIFEST_FIELDS.validate_json(plugin.manifest_json or "{}")
hosted_manifest: Final = {**manifest, "source": {"source": "hosted"}}
updated_at: Final = datetime.now(timezone.utc)
async with prisma_client.db.tx() as transaction:
await transaction.litellm_claudecodepluginarchivetable.upsert(
where={"plugin_id": plugin.id},
data={
"create": {
"plugin_id": plugin.id,
"content": encoded_content,
"size_bytes": len(content),
"updated_at": updated_at,
},
"update": {
"content": encoded_content,
"size_bytes": len(content),
"updated_at": updated_at,
},
},
)
updated_plugin: Final[_PluginRecord | None] = await transaction.litellm_claudecodeplugintable.update(
where={"name": plugin_name},
data={
"manifest_json": json.dumps(hosted_manifest),
"archive_sha256": sha256,
"updated_at": updated_at,
},
)
if updated_plugin is None:
raise _error_response(404, f"Plugin '{plugin_name}' not found")
return UploadPluginArchiveResponse(
plugin=PluginResponse(
id=updated_plugin.id,
name=updated_plugin.name,
version=updated_plugin.version,
description=updated_plugin.description,
source={"source": "hosted"},
installation_preference=get_installation_preference(hosted_manifest),
enabled=updated_plugin.enabled,
),
sha256=sha256,
size_bytes=len(content),
)
except HTTPException:
raise
except Exception as e: # noqa: BLE001 # unexpected upload errors map to HTTP 500
verbose_proxy_logger.exception("Error uploading plugin archive: %s", e)
raise HTTPException(status_code=500, detail={"error": str(e)})
@router.post(
"/claude-code/plugins/{plugin_name}/enable",
tags=["Claude Code Marketplace"],

View file

@ -85,6 +85,9 @@ class _PluginRow(Protocol):
@property
def manifest_json(self) -> str | None: ...
@property
def archive_sha256(self) -> str | None: ...
class _PluginTableActions(Protocol):
def find_many(self, *, where: Mapping[str, bool]) -> Awaitable[Sequence[_PluginRow]]: ...
@ -355,6 +358,7 @@ async def public_skill_hub():
domain=manifest.get("domain"),
namespace=manifest.get("namespace"),
installation_preference=get_installation_preference(MANIFEST_FIELDS.validate_python(manifest)),
archive_sha256=plugin.archive_sha256 if manifest.get("source") == {"source": "hosted"} else None,
)
)
return ListPluginsResponse(plugins=items, count=len(items))

View file

@ -1608,14 +1608,26 @@ model LiteLLM_ClaudeCodePluginTable {
description String?
manifest_json String?
files_json String? @default("{}")
archive_sha256 String?
enabled Boolean @default(true)
created_at DateTime? @default(now())
updated_at DateTime? @default(now()) @updatedAt
created_by String?
archive LiteLLM_ClaudeCodePluginArchiveTable?
@@map("LiteLLM_ClaudeCodePluginTable")
}
model LiteLLM_ClaudeCodePluginArchiveTable {
plugin_id String @id
content Bytes
size_bytes Int
updated_at DateTime @default(now()) @updatedAt
plugin LiteLLM_ClaudeCodePluginTable @relation(fields: [plugin_id], references: [id], onDelete: Cascade)
@@map("LiteLLM_ClaudeCodePluginArchiveTable")
}
// User/team-scoped memory store with a GLOBAL unique key.
// `value` is a string (typically markdown/text meant for LLM context);
// `metadata` is an optional JSON envelope for structured tags without schema changes.

View file

@ -92,6 +92,10 @@ class ClaudeCodePluginRepository(PrismaTableRepository["prisma_models.LiteLLM_Cl
table_name = "litellm_claudecodeplugintable"
class ClaudeCodePluginArchiveRepository(PrismaTableRepository["prisma_models.LiteLLM_ClaudeCodePluginArchiveTable"]):
table_name = "litellm_claudecodepluginarchivetable"
class TeamMembershipRepository(PrismaTableRepository["prisma_models.LiteLLM_TeamMembership"]):
table_name = "litellm_teammembership"

View file

@ -4,7 +4,7 @@ Claude Code Marketplace endpoint types for LiteLLM Proxy
from typing import Final, Literal, TypeAlias
from pydantic import AliasChoices, Field, TypeAdapter, model_validator
from pydantic import AliasChoices, ConfigDict, Field, TypeAdapter, model_validator
from litellm.types.llms.base import LiteLLMBaseModel
@ -38,7 +38,9 @@ class PluginSpec(LiteLLMBaseModel):
"- Git URL: {'source': 'url', 'url': 'https://github.com/org/repo.git'}\n"
"- Git Subdir: {'source': 'git-subdir', 'url': 'https://github.com/org/repo.git', 'path': 'plugins/plugin-name'}\n"
"- Zip archive on any https host (e.g. S3): "
"{'source': 'archive', 'url': 'https://bucket.s3.amazonaws.com/plugin.zip', 'sha256': '<optional hex digest>'}"
"{'source': 'archive', 'url': 'https://bucket.s3.amazonaws.com/plugin.zip', "
"'sha256': '<optional hex digest>'}\n"
"- Gateway-hosted archive: {'source': 'hosted'}; upload the zip to LiteLLM after registering the plugin"
),
)
version: str | None = Field("1.0.0", description="Semantic version")
@ -54,9 +56,9 @@ class PluginSpec(LiteLLMBaseModel):
validation_alias=AliasChoices("installation_preference", "installationPreference"),
description=(
"Emitted as installationPreference on this plugin's marketplace.json entry. Needs an archive source, "
"the only plugins Claude Desktop lists from a marketplace.json URL, and 'auto_install' or 'required' "
"also need its sha256. Claude Desktop honors the mark only when the archive and marketplace.json share "
"its inference gateway origin"
"the only plugins Claude Desktop lists from a marketplace.json URL. LiteLLM supplies the sha256 for "
"gateway-hosted archives. Claude Desktop honors the mark only when the archive and marketplace.json "
"share its inference gateway origin"
),
)
@ -115,6 +117,14 @@ class PluginResponse(LiteLLMBaseModel):
enabled: bool = Field(..., description="Whether plugin is enabled")
class UploadPluginArchiveResponse(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True)
plugin: PluginResponse = Field(..., description="Updated plugin information")
sha256: str = Field(..., description="SHA-256 digest of the uploaded archive")
size_bytes: int = Field(..., description="Size of the uploaded archive in bytes")
class RegisterPluginResponse(LiteLLMBaseModel):
"""Response from plugin registration."""
@ -138,6 +148,7 @@ class PluginListItem(LiteLLMBaseModel):
domain: str | None = None
namespace: str | None = None
installation_preference: InstallationPreference | None = None
archive_sha256: str | None = None
enabled: bool
created_at: str | None
updated_at: str | None

View file

@ -1608,14 +1608,26 @@ model LiteLLM_ClaudeCodePluginTable {
description String?
manifest_json String?
files_json String? @default("{}")
archive_sha256 String?
enabled Boolean @default(true)
created_at DateTime? @default(now())
updated_at DateTime? @default(now()) @updatedAt
created_by String?
archive LiteLLM_ClaudeCodePluginArchiveTable?
@@map("LiteLLM_ClaudeCodePluginTable")
}
model LiteLLM_ClaudeCodePluginArchiveTable {
plugin_id String @id
content Bytes
size_bytes Int
updated_at DateTime @default(now()) @updatedAt
plugin LiteLLM_ClaudeCodePluginTable @relation(fields: [plugin_id], references: [id], onDelete: Cascade)
@@map("LiteLLM_ClaudeCodePluginArchiveTable")
}
// User/team-scoped memory store with a GLOBAL unique key.
// `value` is a string (typically markdown/text meant for LLM context);
// `metadata` is an optional JSON envelope for structured tags without schema changes.

View file

@ -4,29 +4,42 @@ Unit tests for claude_code_marketplace.py source validation.
Covers the git-subdir and archive source types added alongside the existing github and url types.
"""
import base64
import hashlib
import io
import json
from typing import Final
import zipfile
from contextlib import asynccontextmanager
from typing import Final, cast
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import HTTPException
from fastapi import FastAPI, HTTPException, Request
from fastapi.testclient import TestClient
from pydantic import ValidationError
from starlette.types import Message, Receive, Scope, Send
import litellm
from litellm.proxy import proxy_server
from litellm.proxy._types import LiteLLM_ObjectPermissionTable, ProxyException, UserAPIKeyAuth
from litellm.proxy.anthropic_endpoints.claude_code_endpoints import claude_code_marketplace
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace import (
delete_plugin,
disable_plugin,
download_plugin_archive,
enable_plugin,
get_marketplace,
get_plugin,
list_plugins,
register_plugin,
update_plugin,
upload_plugin_archive,
)
from litellm.proxy.auth import auth_checks
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.proxy_server import LitellmUserRoles
from litellm.types.proxy.claude_code_endpoints import (
InstallationPreference,
MarketplaceResponse,
RegisterPluginRequest,
UpdatePluginRequest,
@ -36,7 +49,8 @@ from tests._master_key import MASTER_KEY
def _make_mock_prisma():
"""Stateful prisma mock that supports find_unique, find_many, create, and update."""
store: dict = {}
store: dict[str, MagicMock] = {}
archive_store: dict[str, MagicMock] = {}
mock_client = MagicMock()
mock_client.proxy_logging_obj = MagicMock()
@ -65,6 +79,7 @@ def _make_mock_prisma():
record.enabled = data.get("enabled", True)
record.created_at = data.get("created_at")
record.updated_at = data.get("updated_at")
record.archive_sha256 = data.get("archive_sha256")
store[data["name"]] = record
return record
@ -79,6 +94,36 @@ def _make_mock_prisma():
mock_table.create = AsyncMock(side_effect=_create)
mock_table.update = AsyncMock(side_effect=_update)
mock_client.db.litellm_claudecodeplugintable = mock_table
archive_table = MagicMock()
async def _find_archive(where: dict[str, str]):
return archive_store.get(where.get("plugin_id"))
async def _upsert_archive(where: dict[str, str], data: dict[str, object]):
archive_data = data["update"] if where["plugin_id"] in archive_store else data["create"]
record = MagicMock()
record.plugin_id = archive_data["plugin_id"]
record.content = archive_data["content"]
record.size_bytes = archive_data["size_bytes"]
record.updated_at = archive_data["updated_at"]
record.model_dump = MagicMock(return_value={"content": base64.b64encode(record.content.decode()).decode()})
archive_store[where["plugin_id"]] = record
return record
async def _delete_archive(where: dict[str, str]):
archive_store.pop(where["plugin_id"], None)
archive_table.find_unique = AsyncMock(side_effect=_find_archive)
archive_table.upsert = AsyncMock(side_effect=_upsert_archive)
archive_table.delete_many = AsyncMock(side_effect=_delete_archive)
mock_client.db.litellm_claudecodepluginarchivetable = archive_table
@asynccontextmanager
async def _transaction():
yield mock_client.db
mock_client.db.tx = _transaction
return mock_client
@ -325,6 +370,21 @@ async def test_list_plugins_shows_disabled_plugin_only_to_granted_key_or_admin()
assert await _listed_names(_USER) == {"public-skill", "private-skill"}
@pytest.mark.asyncio
async def test_list_plugins_returns_500_for_invalid_manifest_json():
await register_plugin(
request=RegisterPluginRequest(name="invalid-manifest", source=_GIT_SUBDIR_SOURCE, version="1.0.0"),
user_api_key_dict=_USER,
)
table: Final = litellm.proxy.proxy_server.prisma_client.db.litellm_claudecodeplugintable
await table.update(where={"name": "invalid-manifest"}, data={"manifest_json": "{"})
with pytest.raises(HTTPException) as exc_info:
await list_plugins(user_api_key_dict=_USER)
assert exc_info.value.status_code == 500
@pytest.mark.asyncio
async def test_get_plugin_returns_403_for_disabled_plugin_the_key_is_not_granted():
await _register_public_and_private_plugins()
@ -362,9 +422,529 @@ async def test_get_marketplace_key_query_param_adds_granted_disabled_plugins(mon
assert await _marketplace_names("sk-plain") == ["public-skill"]
assert await _marketplace_names("sk-granted") == ["private-skill", "public-skill"]
header_request: Final = MagicMock(headers={"Authorization": "Bearer sk-granted"})
header_response: Final = await get_marketplace(request=header_request)
assert sorted(plugin["name"] for plugin in json.loads(header_response.body)["plugins"]) == [
"private-skill",
"public-skill",
]
with pytest.raises(ProxyException) as exc_info:
await get_marketplace(request=MagicMock(), key="sk-bogus")
assert exc_info.value.code == "401"
with pytest.raises(ProxyException) as header_error:
await get_marketplace(request=MagicMock(headers={"Authorization": "Bearer sk-bogus"}))
assert header_error.value.code == "401"
def _plugin_archive(manifest_path: str = ".claude-plugin/plugin.json") -> bytes:
archive_bytes: Final = io.BytesIO()
with zipfile.ZipFile(archive_bytes, "w") as archive:
archive.writestr(manifest_path, '{"name":"hosted-plugin"}')
return archive_bytes.getvalue()
def _archive_request(
content: bytes,
*,
content_type: str = "application/zip",
content_length: str | None = None,
receive: Receive | None = None,
) -> Request:
content_headers: Final = ((b"content-type", content_type.encode()),)
length_headers: Final = () if content_length is None else ((b"content-length", content_length.encode()),)
headers: Final = (*content_headers, *length_headers)
async def receive_body() -> Message:
return {"type": "http.request", "body": content, "more_body": False}
scope: Final[Scope] = {
"type": "http",
"http_version": "1.1",
"method": "PUT",
"scheme": "http",
"path": "/claude-code/plugins/hosted-plugin/archive",
"raw_path": b"/claude-code/plugins/hosted-plugin/archive",
"query_string": b"",
"headers": list(headers),
"server": ("testserver", 80),
"client": ("127.0.0.1", 1234),
}
return Request(scope, receive or receive_body)
def _register_request(
name: str,
source: dict[str, str],
installation_preference: InstallationPreference | None = None,
) -> RegisterPluginRequest:
return RegisterPluginRequest(
name=name,
source=source,
version="1.0.0",
description=None,
author=None,
homepage=None,
keywords=None,
category=None,
domain=None,
namespace=None,
installation_preference=installation_preference,
)
def _update_request(source: dict[str, str]) -> UpdatePluginRequest:
return UpdatePluginRequest(
source=source,
version=None,
description=None,
author=None,
homepage=None,
keywords=None,
category=None,
domain=None,
namespace=None,
installation_preference=None,
)
async def _register_hosted_plugin(name: str = "hosted-plugin") -> None:
await register_plugin(
request=_register_request(name, {"source": "hosted"}),
user_api_key_dict=_USER,
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"manifest_path",
[".claude-plugin/plugin.json", "bundle/.claude-plugin/plugin.json"],
)
async def test_hosted_plugin_archive_upload_and_download(manifest_path: str):
await _register_hosted_plugin()
content: Final = _plugin_archive(manifest_path)
result: Final = await upload_plugin_archive(
plugin_name="hosted-plugin",
request=_archive_request(content),
user_api_key_dict=_USER,
)
assert result.sha256 == hashlib.sha256(content).hexdigest()
downloaded: Final = await download_plugin_archive(
plugin_name="hosted-plugin",
request=MagicMock(headers={}),
)
assert downloaded.body == content
assert downloaded.media_type == "application/zip"
assert downloaded.headers["content-disposition"] == 'attachment; filename="hosted-plugin.zip"'
listed: Final = await list_plugins(user_api_key_dict=_USER)
stored_plugin: Final = await get_plugin(plugin_name="hosted-plugin", user_api_key_dict=_USER)
assert next(plugin for plugin in listed.plugins if plugin.name == "hosted-plugin").archive_sha256 == result.sha256
assert stored_plugin["archive_sha256"] == result.sha256
@pytest.mark.asyncio
@pytest.mark.parametrize("content_type", ["application/zip", "application/octet-stream"])
async def test_upload_plugin_archive_accepts_raw_body_route(
monkeypatch: pytest.MonkeyPatch,
content_type: str,
):
await _register_hosted_plugin()
prisma_client: Final = litellm.proxy.proxy_server.prisma_client
monkeypatch.setattr(claude_code_marketplace, "get_prisma_client", AsyncMock(return_value=prisma_client))
app: Final = FastAPI()
app.include_router(claude_code_marketplace.router)
app.dependency_overrides[user_api_key_auth] = lambda: _USER
content: Final = _plugin_archive()
with TestClient(app) as client:
response = client.put(
"/claude-code/plugins/hosted-plugin/archive",
content=content,
headers={"Content-Type": content_type},
)
assert response.status_code == 200
assert response.json()["sha256"] == hashlib.sha256(content).hexdigest()
@pytest.mark.asyncio
async def test_upload_plugin_archive_rejects_unauthenticated_request_before_storage(monkeypatch: pytest.MonkeyPatch):
await _register_hosted_plugin()
prisma_client: Final = litellm.proxy.proxy_server.prisma_client
monkeypatch.setattr(claude_code_marketplace, "get_prisma_client", AsyncMock(return_value=prisma_client))
app: Final = FastAPI()
app.include_router(claude_code_marketplace.router)
def reject_unauthenticated_request() -> UserAPIKeyAuth:
raise HTTPException(status_code=401, detail="Unauthorized")
app.dependency_overrides[user_api_key_auth] = reject_unauthenticated_request
content: Final = _plugin_archive()
receive_spy: Final = MagicMock()
async def track_body_reads(scope: Scope, receive: Receive, send: Send) -> None:
async def tracked_receive() -> Message:
message: Final = await receive()
if message["type"] == "http.request" and message.get("body"):
receive_spy(message)
return message
await app(scope, tracked_receive, send)
with TestClient(track_body_reads) as client:
response = client.put(
"/claude-code/plugins/hosted-plugin/archive",
content=content,
headers={"Content-Type": "application/zip"},
)
archive: Final = await prisma_client.db.litellm_claudecodepluginarchivetable.find_unique(
where={"plugin_id": "test-id"}
)
assert response.status_code == 401
assert receive_spy.call_count == 0
assert archive is None
@pytest.mark.asyncio
async def test_private_hosted_archive_uses_inference_key_visibility(monkeypatch: pytest.MonkeyPatch):
await _register_hosted_plugin("private-plugin")
await disable_plugin(plugin_name="private-plugin", user_api_key_dict=_USER)
content: Final = _plugin_archive()
await upload_plugin_archive(
plugin_name="private-plugin",
request=_archive_request(content),
user_api_key_dict=_USER,
)
keys: dict[str, UserAPIKeyAuth] = {"sk-granted": _granted_user(["private-plugin"]), "sk-plain": _NON_ADMIN_USER}
async def _fake_auth(request: Request, api_key: str) -> UserAPIKeyAuth:
token = api_key.removeprefix("Bearer ")
if token not in keys:
raise ProxyException(message="invalid key", type="auth_error", param="key", code=401)
return keys[token]
monkeypatch.setattr(claude_code_marketplace, "user_api_key_auth", _fake_auth)
hidden_request = MagicMock(headers={"Authorization": "Bearer sk-plain"})
with pytest.raises(HTTPException) as hidden_error:
await download_plugin_archive("private-plugin", hidden_request)
assert hidden_error.value.status_code == 404
granted_request = MagicMock(headers={"Authorization": "Bearer sk-granted"})
visible_archive: Final = await download_plugin_archive("private-plugin", granted_request)
assert visible_archive.body == content
@pytest.mark.asyncio
async def test_access_group_grant_exposes_disabled_hosted_archive_and_marketplace(monkeypatch: pytest.MonkeyPatch):
await _register_hosted_plugin("group-only-plugin")
await disable_plugin(plugin_name="group-only-plugin", user_api_key_dict=_USER)
content: Final = _plugin_archive()
await upload_plugin_archive(
plugin_name="group-only-plugin",
request=_archive_request(content),
user_api_key_dict=_USER,
)
group_key: Final = UserAPIKeyAuth(
api_key="sk-group-only",
token="sk-group-only",
user_role=LitellmUserRoles.INTERNAL_USER,
access_group_ids=["plugin-access-group"],
)
access_group: Final = MagicMock()
access_group.access_group_id = "plugin-access-group"
access_group.access_skill_names = ["group-only-plugin"]
access_group.assigned_team_ids = []
access_group.assigned_key_ids = [group_key.token]
keys: Final[dict[str, UserAPIKeyAuth]] = {
"sk-group-only": group_key,
"sk-no-group": _NON_ADMIN_USER,
}
async def _fake_auth(request: Request, api_key: str) -> UserAPIKeyAuth:
assert request.headers.get("Authorization") == api_key
return keys[api_key.removeprefix("Bearer ")]
monkeypatch.setattr(claude_code_marketplace, "user_api_key_auth", _fake_auth)
monkeypatch.setattr(auth_checks, "get_access_object", AsyncMock(return_value=access_group))
monkeypatch.setattr(auth_checks, "get_skill_names_from_access_groups", AsyncMock(return_value=frozenset()))
monkeypatch.setattr(proxy_server, "user_api_key_cache", MagicMock())
monkeypatch.setenv("PROXY_BASE_URL", "https://marketplace.example.com")
grouped_request: Final = cast(Request, MagicMock(headers={"Authorization": "Bearer sk-group-only"}))
marketplace_response: Final = await get_marketplace(request=grouped_request)
marketplace_plugins: Final = json.loads(bytes(marketplace_response.body))["plugins"]
assert marketplace_plugins == [
{
"name": "group-only-plugin",
"source": {
"source": "archive",
"url": "https://marketplace.example.com/claude-code/plugins/group-only-plugin/archive.zip",
"sha256": hashlib.sha256(content).hexdigest(),
},
"version": "1.0.0",
}
]
archive: Final = await download_plugin_archive("group-only-plugin", grouped_request)
assert archive.status_code == 200
assert archive.body == content
without_group_request: Final = cast(Request, MagicMock(headers={"Authorization": "Bearer sk-no-group"}))
with pytest.raises(HTTPException) as hidden_error:
await download_plugin_archive("group-only-plugin", without_group_request)
assert hidden_error.value.status_code == 404
assert hidden_error.value.detail == {"error": "Plugin not found"}
@pytest.mark.asyncio
@pytest.mark.parametrize(
"source",
[{"source": "hosted", "url": "https://example.com/archive.zip"}, {"source": "hosted", "sha256": "abc"}],
)
async def test_register_rejects_hosted_source_extra_keys(source: dict[str, str]):
with pytest.raises(HTTPException) as exc_info:
await register_plugin(
request=_register_request("hosted-plugin", source),
user_api_key_dict=_USER,
)
assert exc_info.value.status_code == 400
@pytest.mark.asyncio
@pytest.mark.parametrize(
"source",
[{"source": "hosted", "url": "https://example.com/archive.zip"}, {"source": "hosted", "sha256": "abc"}],
)
async def test_update_rejects_hosted_source_extra_keys(source: dict[str, str]):
await register_plugin(
request=_register_request("existing-plugin", _GIT_SUBDIR_SOURCE),
user_api_key_dict=_USER,
)
with pytest.raises(HTTPException) as exc_info:
await update_plugin(
plugin_name="existing-plugin",
request=_update_request(source),
user_api_key_dict=_USER,
)
assert exc_info.value.status_code == 400
@pytest.mark.asyncio
async def test_hosted_marketplace_uses_request_origin_and_digest(monkeypatch: pytest.MonkeyPatch):
await _register_hosted_plugin()
content: Final = _plugin_archive()
await upload_plugin_archive(
plugin_name="hosted-plugin",
request=_archive_request(content),
user_api_key_dict=_USER,
)
monkeypatch.setenv("PROXY_BASE_URL", "https://proxy.example.com/root")
response: Final = await get_marketplace(request=MagicMock(headers={}))
hosted_entry: Final = next(
plugin for plugin in json.loads(bytes(response.body))["plugins"] if plugin["name"] == "hosted-plugin"
)
assert hosted_entry["source"] == {
"source": "archive",
"url": "https://proxy.example.com/root/claude-code/plugins/hosted-plugin/archive.zip",
"sha256": hashlib.sha256(content).hexdigest(),
}
@pytest.mark.asyncio
async def test_hosted_marketplace_uses_request_origin_without_configured_proxy_url(monkeypatch: pytest.MonkeyPatch):
await _register_hosted_plugin()
content: Final = _plugin_archive()
await upload_plugin_archive(
plugin_name="hosted-plugin",
request=_archive_request(content),
user_api_key_dict=_USER,
)
monkeypatch.delenv("PROXY_BASE_URL", raising=False)
request: Final = Request(
{
"type": "http",
"http_version": "1.1",
"method": "GET",
"scheme": "https",
"path": "/claude-code/marketplace.json",
"raw_path": b"/claude-code/marketplace.json",
"query_string": b"",
"headers": [],
"server": ("marketplace.example.com", 443),
"client": ("127.0.0.1", 1234),
}
)
response: Final = await get_marketplace(request=request)
hosted_entry: Final = next(
plugin for plugin in json.loads(bytes(response.body))["plugins"] if plugin["name"] == "hosted-plugin"
)
assert hosted_entry["source"]["url"] == (
"https://marketplace.example.com/claude-code/plugins/hosted-plugin/archive.zip"
)
@pytest.mark.asyncio
async def test_hosted_plugin_without_archive_is_omitted_from_marketplace(caplog: pytest.LogCaptureFixture):
await _register_hosted_plugin()
response: Final = await get_marketplace(request=MagicMock(headers={}))
assert "hosted-plugin" not in {plugin["name"] for plugin in json.loads(bytes(response.body))["plugins"]}
assert "has no archive" in caplog.text
@pytest.mark.asyncio
@pytest.mark.parametrize(
"plugin_name",
["missing-plugin", "non-hosted-plugin", "hosted-plugin"],
)
async def test_archive_download_hides_missing_non_hosted_and_archive_less_plugins(plugin_name: str):
await register_plugin(
request=_register_request("non-hosted-plugin", _GIT_SUBDIR_SOURCE),
user_api_key_dict=_USER,
)
await _register_hosted_plugin()
with pytest.raises(HTTPException) as exc_info:
await download_plugin_archive(plugin_name=plugin_name, request=MagicMock(headers={}))
assert exc_info.value.status_code == 404
assert exc_info.value.detail == {"error": "Plugin not found"}
@pytest.mark.asyncio
async def test_upload_archive_requires_admin_and_rejects_invalid_zip():
await _register_hosted_plugin()
with pytest.raises(HTTPException) as auth_error:
await upload_plugin_archive("hosted-plugin", _archive_request(b"not a zip"), _NON_ADMIN_USER)
assert auth_error.value.status_code == 403
with pytest.raises(HTTPException) as zip_error:
await upload_plugin_archive(
"hosted-plugin",
_archive_request(b"not a zip"),
_USER,
)
assert zip_error.value.status_code == 400
@pytest.mark.asyncio
async def test_upload_archive_enforces_stream_size_limit(monkeypatch: pytest.MonkeyPatch):
await _register_hosted_plugin()
monkeypatch.setattr(claude_code_marketplace, "CLAUDE_CODE_PLUGIN_ARCHIVE_MAX_BYTES", 4)
with pytest.raises(HTTPException) as exc_info:
await upload_plugin_archive(
"hosted-plugin",
_archive_request(b"12345"),
_USER,
)
assert exc_info.value.status_code == 413
archive: Final = await litellm.proxy.proxy_server.prisma_client.db.litellm_claudecodepluginarchivetable.find_unique(
where={"plugin_id": "test-id"}
)
assert archive is None
@pytest.mark.asyncio
async def test_upload_archive_rejects_over_limit_content_length_without_reading_body(
monkeypatch: pytest.MonkeyPatch,
):
await _register_hosted_plugin()
monkeypatch.setattr(claude_code_marketplace, "CLAUDE_CODE_PLUGIN_ARCHIVE_MAX_BYTES", 4)
receive: Final = AsyncMock(return_value={"type": "http.request", "body": _plugin_archive(), "more_body": False})
request: Final = _archive_request(
_plugin_archive(),
content_length="5",
receive=cast(Receive, receive),
)
with pytest.raises(HTTPException) as exc_info:
await upload_plugin_archive("hosted-plugin", request, _USER)
archive: Final = await litellm.proxy.proxy_server.prisma_client.db.litellm_claudecodepluginarchivetable.find_unique(
where={"plugin_id": "test-id"}
)
assert exc_info.value.status_code == 413
assert receive.await_count == 0
assert archive is None
@pytest.mark.asyncio
async def test_upload_archive_rejects_unsupported_content_type():
await _register_hosted_plugin()
with pytest.raises(HTTPException) as exc_info:
await upload_plugin_archive(
"hosted-plugin",
_archive_request(_plugin_archive(), content_type="application/json"),
_USER,
)
assert exc_info.value.status_code == 415
@pytest.mark.asyncio
async def test_hosted_plugin_accepts_auto_install_preference():
response: Final = await register_plugin(
request=_register_request("hosted-auto-install", {"source": "hosted"}, "auto_install"),
user_api_key_dict=_USER,
)
assert response.plugin.source == {"source": "hosted"}
assert response.plugin.installation_preference == "auto_install"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"manifest_path",
["../.claude-plugin/plugin.json", "/.claude-plugin/plugin.json", "folder/extra/.claude-plugin/plugin.json"],
)
async def test_upload_archive_rejects_unsafe_or_deep_manifest_paths(manifest_path: str):
await _register_hosted_plugin()
with pytest.raises(HTTPException) as exc_info:
await upload_plugin_archive(
"hosted-plugin",
_archive_request(_plugin_archive(manifest_path)),
_USER,
)
assert exc_info.value.status_code == 400
@pytest.mark.asyncio
async def test_update_away_from_hosted_source_clears_archive():
await _register_hosted_plugin()
await upload_plugin_archive(
"hosted-plugin",
_archive_request(_plugin_archive()),
_USER,
)
await update_plugin(
plugin_name="hosted-plugin",
request=_update_request(_GIT_SUBDIR_SOURCE),
user_api_key_dict=_USER,
)
plugin: Final = await litellm.proxy.proxy_server.prisma_client.db.litellm_claudecodeplugintable.find_unique(
where={"name": "hosted-plugin"}
)
archive: Final = await litellm.proxy.proxy_server.prisma_client.db.litellm_claudecodepluginarchivetable.find_unique(
where={"plugin_id": plugin.id}
)
assert plugin.archive_sha256 is None
assert archive is None
@pytest.mark.asyncio
@ -386,6 +966,52 @@ async def test_get_marketplace_emits_installation_preference(preference):
]
@pytest.mark.asyncio
async def test_get_marketplace_preserves_non_hosted_entry_key_order_and_content():
await register_plugin(
request=RegisterPluginRequest(
name="marketplace-shape",
source=_ARCHIVE_SOURCE,
version="2.3.4",
description="Marketplace compatibility",
author={"name": "Ada Lovelace", "email": "ada@example.com"},
homepage="https://example.com/plugin",
keywords=["analysis", "tools"],
category="development",
installation_preference="available",
),
user_api_key_dict=_USER,
)
request = Request(
{
"type": "http",
"method": "GET",
"scheme": "https",
"server": ("gateway.example", 443),
"client": ("127.0.0.1", 12345),
"headers": [],
"path": "/claude-code/marketplace.json",
"query_string": b"",
}
)
marketplace = json.loads((await get_marketplace(request=request)).body)
entry = marketplace["plugins"][0]
expected = {
"name": "marketplace-shape",
"source": _ARCHIVE_SOURCE,
"installationPreference": "available",
"version": "2.3.4",
"description": "Marketplace compatibility",
"author": {"name": "Ada Lovelace", "email": "ada@example.com"},
"homepage": "https://example.com/plugin",
"keywords": ["analysis", "tools"],
"category": "development",
}
assert tuple(entry.items()) == tuple(expected.items())
@pytest.mark.parametrize(
"build_request",
[

View file

@ -1,4 +1,4 @@
import { render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
@ -10,6 +10,7 @@ import ClaudeCodePluginsPanel from "./ClaudeCodePluginsPanel";
vi.mock("@/components/networking", () => ({
getClaudeCodePluginsList: vi.fn(),
deleteClaudeCodePlugin: vi.fn(),
getProxyBaseUrl: vi.fn(() => "https://proxy.example.com/root/"),
}));
vi.mock("./PluginTable", () => ({
@ -18,28 +19,30 @@ vi.mock("./PluginTable", () => ({
isLoading,
pluginsList,
onDeleteClick,
onPluginClick,
}: {
isLoading: boolean;
pluginsList: Plugin[];
onDeleteClick: (pluginName: string, displayName: string) => void;
onPluginClick: (id: string) => void;
}) => (
<div data-testid="plugin-table">
{isLoading ? "table-loading" : "table-loaded"}
{pluginsList.map((plugin) => (
<button
key={plugin.id}
data-testid={`row-delete-${plugin.id}`}
onClick={() => onDeleteClick(plugin.name, plugin.name)}
>
row delete
</button>
<div key={plugin.id}>
<button data-testid={`row-delete-${plugin.id}`} onClick={() => onDeleteClick(plugin.name, plugin.name)}>
row delete
</button>
<button data-testid={`row-open-${plugin.id}`} onClick={() => onPluginClick(plugin.id)}>
Open plugin
</button>
</div>
))}
</div>
),
}));
vi.mock("./add_plugin_form", () => ({ __esModule: true, default: () => null }));
vi.mock("@/components/claude_code_plugins/skill_detail", () => ({ __esModule: true, default: () => null }));
const mockGetClaudeCodePluginsList = vi.mocked(getClaudeCodePluginsList);
const mockDeleteClaudeCodePlugin = vi.mocked(deleteClaudeCodePlugin);
@ -50,6 +53,7 @@ const skill: Plugin = {
source: { source: "github", repo: "acme/my-skill" },
enabled: true,
};
const hostedSkill: Plugin = { ...skill, source: { source: "hosted" } };
describe("ClaudeCodePluginsPanel loading state", () => {
beforeEach(() => {
@ -76,6 +80,28 @@ describe("ClaudeCodePluginsPanel loading state", () => {
expect(await screen.findByText("table-loaded")).toBeInTheDocument();
expect(mockGetClaudeCodePluginsList).toHaveBeenCalledWith("sk-test", false);
});
it("shows the Claude Desktop marketplace URL and configuration", async () => {
mockGetClaudeCodePluginsList.mockResolvedValue({ plugins: [], count: 0 });
render(<ClaudeCodePluginsPanel accessToken="sk-test" userRole="Admin" />);
expect(await screen.findByText("https://proxy.example.com/root/claude-code/marketplace.json")).toBeInTheDocument();
expect(screen.getByText("Connect Claude Desktop")).toBeInTheDocument();
expect(screen.getByText((_, element) => element?.tagName === "PRE")).toHaveTextContent(
'"credentialKind": "inferenceCredential"',
);
expect(screen.getByRole("button", { name: "Copy JSON" })).toBeInTheDocument();
});
it("shows hosted plugins as hosted rather than a URL", async () => {
mockGetClaudeCodePluginsList.mockResolvedValue({ plugins: [hostedSkill], count: 1 });
render(<ClaudeCodePluginsPanel accessToken="sk-test" userRole="Admin" />);
fireEvent.click(await screen.findByTestId("row-open-plugin-1"));
expect(await screen.findByText("Hosted on LiteLLM")).toBeInTheDocument();
expect(screen.queryByRole("link")).not.toBeInTheDocument();
});
});
describe("ClaudeCodePluginsPanel delete confirmation", () => {

View file

@ -1,4 +1,5 @@
import React, { useState, useEffect } from "react";
import React, { useEffect, useState } from "react";
import { Copy } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
@ -9,7 +10,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { getClaudeCodePluginsList, deleteClaudeCodePlugin } from "@/components/networking";
import { getClaudeCodePluginsList, deleteClaudeCodePlugin, getProxyBaseUrl } from "@/components/networking";
import AddPluginForm from "./add_plugin_form";
import PluginTable from "./PluginTable";
import SkillDetail from "@/components/claude_code_plugins/skill_detail";
@ -27,6 +28,7 @@ const ClaudeCodePluginsPanel: React.FC<ClaudeCodePluginsPanelProps> = ({ accessT
const [isAddModalVisible, setIsAddModalVisible] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isDeleting, setIsDeleting] = useState(false);
const marketplaceUrl = `${getProxyBaseUrl().replace(/\/+$/, "")}/claude-code/marketplace.json`;
const [pluginToDelete, setPluginToDelete] = useState<{
name: string;
displayName: string;
@ -56,6 +58,28 @@ const ClaudeCodePluginsPanel: React.FC<ClaudeCodePluginsPanelProps> = ({ accessT
fetchPlugins();
}, [accessToken]);
const marketplaceConfig = JSON.stringify(
[
{
source: "url",
url: marketplaceUrl,
credentialKind: "inferenceCredential",
installationPreference: "available",
},
],
null,
2,
);
const handleCopyMarketplaceConfig = async () => {
try {
await navigator.clipboard.writeText(marketplaceConfig);
toast.success("Claude Desktop configuration copied");
} catch {
toast.error("Could not copy Claude Desktop configuration");
}
};
const handleDeleteClick = (pluginName: string, displayName: string) => {
setPluginToDelete({ name: pluginName, displayName });
};
@ -95,6 +119,18 @@ const ClaudeCodePluginsPanel: React.FC<ClaudeCodePluginsPanelProps> = ({ accessT
Register Claude Code skills. Published skills appear in the Skill Hub for all users and are served via{" "}
<code className="bg-muted px-1 rounded-sm">/claude-code/marketplace.json</code>.
</p>
<section className="mt-2 rounded-lg border p-4">
<h2 className="text-sm font-semibold">Connect Claude Desktop</h2>
<p className="mt-1 text-sm text-muted-foreground">Marketplace URL</p>
<code className="break-all text-xs">{marketplaceUrl}</code>
<div className="mt-3 flex items-start justify-between gap-3">
<pre className="overflow-x-auto rounded-md bg-muted p-3 text-xs">{marketplaceConfig}</pre>
<Button variant="outline" size="sm" onClick={handleCopyMarketplaceConfig}>
<Copy className="size-4" />
Copy JSON
</Button>
</div>
</section>
<div className="mt-2 flex gap-2">
<Button onClick={() => setIsAddModalVisible(true)} disabled={!accessToken || !isAdmin}>
+ Add Skill

View file

@ -3,14 +3,16 @@ import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderWithProviders } from "@/../tests/test-utils";
import AddPluginForm from "./add_plugin_form";
import { registerClaudeCodePlugin } from "@/components/networking";
import { registerClaudeCodePlugin, uploadClaudeCodePluginArchive } from "@/components/networking";
import { toast } from "@/lib/toast";
vi.mock("@/components/networking", () => ({
registerClaudeCodePlugin: vi.fn().mockResolvedValue({ status: "success" }),
uploadClaudeCodePluginArchive: vi.fn().mockResolvedValue({}),
}));
const mockRegister = vi.mocked(registerClaudeCodePlugin);
const mockUpload = vi.mocked(uploadClaudeCodePluginArchive);
const mockMessageError = vi.mocked(toast.error);
const DEFAULT_PROPS = {
@ -182,6 +184,49 @@ describe("AddPluginForm", () => {
});
});
it("creates hosted plugins before uploading their zip archive", async () => {
renderWithProviders(<AddPluginForm {...DEFAULT_PROPS} />);
const file = new File(["zip"], "hosted-plugin.zip", { type: "application/zip" });
fireEvent.click(screen.getByLabelText("Upload a zip to LiteLLM"));
fireEvent.change(screen.getByPlaceholderText("my-skill"), { target: { value: "hosted-plugin" } });
fireEvent.change(screen.getByLabelText("Plugin archive (.zip)"), { target: { files: [file] } });
await submit();
await waitFor(() => {
expect(mockRegister).toHaveBeenCalledWith(
"sk-test",
expect.objectContaining({ name: "hosted-plugin", source: { source: "hosted" } }),
);
expect(mockUpload).toHaveBeenCalledWith("sk-test", "hosted-plugin", file);
});
expect(mockRegister.mock.invocationCallOrder[0]).toBeLessThan(mockUpload.mock.invocationCallOrder[0]);
});
it("keeps a created hosted plugin available and retries only the failed upload", async () => {
mockUpload.mockRejectedValueOnce(new Error("Archive upload failed"));
renderWithProviders(<AddPluginForm {...DEFAULT_PROPS} />);
const file = new File(["zip"], "hosted-plugin.zip", { type: "application/zip" });
fireEvent.click(screen.getByLabelText("Upload a zip to LiteLLM"));
fireEvent.change(screen.getByPlaceholderText("my-skill"), { target: { value: "hosted-plugin" } });
fireEvent.change(screen.getByLabelText("Plugin archive (.zip)"), { target: { files: [file] } });
await submit();
expect(await screen.findByRole("button", { name: "Retry Upload" })).toBeInTheDocument();
expect(mockMessageError).toHaveBeenCalledWith("Archive upload failed");
expect(DEFAULT_PROPS.onSuccess).toHaveBeenCalled();
expect(DEFAULT_PROPS.onClose).not.toHaveBeenCalled();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Retry Upload" }));
});
expect(mockRegister).toHaveBeenCalledTimes(1);
expect(mockUpload).toHaveBeenCalledTimes(2);
expect(DEFAULT_PROPS.onClose).toHaveBeenCalled();
});
it("submits a github subdir source from a tree URL", async () => {
renderWithProviders(<AddPluginForm {...DEFAULT_PROPS} />);

View file

@ -2,7 +2,7 @@ import React, { useState } from "react";
import { CircleHelp } from "lucide-react";
import { z } from "zod";
import { toast } from "@/lib/toast";
import { registerClaudeCodePlugin } from "@/components/networking";
import { registerClaudeCodePlugin, uploadClaudeCodePluginArchive } from "@/components/networking";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { Button } from "@/components/ui/button";
@ -40,7 +40,7 @@ interface AddPluginFormProps {
}
const addPluginShape = {
skillUrl: z.string().min(1, "Please enter a repository or zip archive URL"),
skillUrl: z.string(),
subPath: z
.string()
.refine(
@ -151,6 +151,9 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessT
const [isSubmitting, setIsSubmitting] = useState(false);
const [urlPreview, setUrlPreview] = useState<SkillSourcePreview | null>(null);
const [subPathLock, setSubPathLock] = useState<SubPathLock | null>(null);
const [isHostedUpload, setIsHostedUpload] = useState(false);
const [archiveFile, setArchiveFile] = useState<File | null>(null);
const [createdHostedPluginName, setCreatedHostedPluginName] = useState<string | null>(null);
const recomputePreview = (skillUrl: string, subPath: string) => {
const lock = subPathLockFor(parseSkillSource(skillUrl)?.parsed.source);
@ -169,12 +172,18 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessT
};
const handleSubmit = async (values: AddPluginFormValues) => {
const selectedArchiveFile = archiveFile;
if (!accessToken) {
toast.error("No access token available");
return;
}
if (!urlPreview) {
if (isHostedUpload && selectedArchiveFile === null) {
toast.error("Please choose a .zip archive");
return;
}
if (!isHostedUpload && !urlPreview) {
toast.error("Please enter a valid repository or zip archive URL");
return;
}
@ -196,12 +205,30 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessT
setIsSubmitting(true);
try {
await registerClaudeCodePlugin(accessToken, buildRegisterRequest(values, urlPreview.parsed));
if (isHostedUpload) {
const pluginName = values.name.trim();
if (createdHostedPluginName !== pluginName) {
await registerClaudeCodePlugin(accessToken, buildRegisterRequest(values, { source: "hosted" }));
setCreatedHostedPluginName(pluginName);
onSuccess();
}
if (selectedArchiveFile === null) {
return;
}
await uploadClaudeCodePluginArchive(accessToken, pluginName, selectedArchiveFile);
} else if (urlPreview) {
await registerClaudeCodePlugin(accessToken, buildRegisterRequest(values, urlPreview.parsed));
}
toast.success("Skill registered successfully");
form.reset(EMPTY_VALUES);
setUrlPreview(null);
setSubPathLock(null);
onSuccess();
setIsHostedUpload(false);
setArchiveFile(null);
setCreatedHostedPluginName(null);
if (!isHostedUpload) {
onSuccess();
}
onClose();
} catch (error) {
console.error("Error registering skill:", error);
@ -215,9 +242,14 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessT
form.reset(EMPTY_VALUES);
setUrlPreview(null);
setSubPathLock(null);
setIsHostedUpload(false);
setArchiveFile(null);
setCreatedHostedPluginName(null);
onClose();
};
const submitButtonText = createdHostedPluginName ? "Retry Upload" : "Add Skill";
return (
<Dialog open={visible} onOpenChange={(open) => !open && handleCancel()}>
<DialogContent className="top-8 max-h-[calc(100dvh-4rem)] translate-y-0 overflow-y-auto sm:max-w-[700px]">
@ -227,71 +259,111 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessT
<TooltipProvider>
<form onSubmit={form.handleSubmit(handleSubmit)} noValidate className="mt-4">
<FieldGroup>
<FormField
control={form.control}
name="skillUrl"
label={labelWithHint(
"Source URL",
"Paste an HTTPS git repository URL from GitHub, GitLab, Bitbucket, or a self-hosted host (e.g. github.com/org/repo or github.com/org/repo/tree/main/my-skill), or an HTTPS link to a .zip archive of the skill hosted on S3 or any static file server. For a private repository use its SSH clone URL (git@ghe.example.com:org/repo.git) so Claude Code clones it with your own SSH key.",
)}
>
{({ ref, onChange, ...field }) => (
<Input
{...field}
ref={ref}
placeholder="https://github.com/org/repo or https://bucket.s3.amazonaws.com/my-skill.zip"
className="rounded-lg"
onChange={(event) => {
onChange(event);
recomputePreview(event.target.value, form.getValues("subPath"));
}}
<div role="group" aria-label="Plugin source" className="flex gap-4 text-sm">
<label className="flex items-center gap-2">
<input
type="radio"
name="plugin-source"
checked={!isHostedUpload}
disabled={createdHostedPluginName !== null}
onChange={() => setIsHostedUpload(false)}
/>
)}
</FormField>
<FormField
control={form.control}
name="subPath"
label={labelWithHint(
"Subfolder path (Optional)",
"Path within the repository where the skill lives (e.g., plugins/my-skill). Leave empty if the skill is at the repo root.",
)}
description={subPathLock ? SUB_PATH_LOCK_REASON[subPathLock] : undefined}
>
{({ ref, onChange, ...field }) => (
<Input
{...field}
ref={ref}
placeholder="plugins/my-skill"
className="rounded-lg"
onChange={(event) => {
onChange(event);
recomputePreview(form.getValues("skillUrl"), event.target.value);
}}
disabled={subPathLock !== null}
Use a URL
</label>
<label className="flex items-center gap-2">
<input
type="radio"
name="plugin-source"
checked={isHostedUpload}
disabled={createdHostedPluginName !== null}
onChange={() => setIsHostedUpload(true)}
/>
)}
</FormField>
Upload a zip to LiteLLM
</label>
</div>
{urlPreview?.parsed.source === "archive" && (
<FormField
control={form.control}
name="sha256"
label={labelWithHint(
"Archive SHA-256 (Optional)",
"Hex digest of the zip file. Claude Code refuses to install the archive if its checksum does not match.",
)}
>
{({ ref, ...field }) => (
<Input {...field} ref={ref} placeholder="64 hex characters" className="rounded-lg font-mono" />
)}
</FormField>
)}
{urlPreview && (
<div className="rounded-lg border border-info/20 bg-info/10 px-3 py-2 text-sm text-info">
Detected: {urlPreview.label}
{isHostedUpload ? (
<div className="flex flex-col gap-2">
<label htmlFor="plugin-archive-file" className="text-sm font-medium">
Plugin archive (.zip)
</label>
<Input
id="plugin-archive-file"
type="file"
accept=".zip,application/zip"
aria-label="Plugin archive (.zip)"
onChange={(event) => setArchiveFile(event.target.files?.[0] ?? null)}
/>
</div>
) : (
<>
<FormField
control={form.control}
name="skillUrl"
label={labelWithHint(
"Source URL",
"Paste an HTTPS git repository URL from GitHub, GitLab, Bitbucket, or a self-hosted host (e.g. github.com/org/repo or github.com/org/repo/tree/main/my-skill), or an HTTPS link to a .zip archive of the skill hosted on S3 or any static file server. For a private repository use its SSH clone URL (git@ghe.example.com:org/repo.git) so Claude Code clones it with your own SSH key.",
)}
>
{({ ref, onChange, ...field }) => (
<Input
{...field}
ref={ref}
placeholder="https://github.com/org/repo or https://bucket.s3.amazonaws.com/my-skill.zip"
className="rounded-lg"
onChange={(event) => {
onChange(event);
recomputePreview(event.target.value, form.getValues("subPath"));
}}
/>
)}
</FormField>
<FormField
control={form.control}
name="subPath"
label={labelWithHint(
"Subfolder path (Optional)",
"Path within the repository where the skill lives (e.g., plugins/my-skill). Leave empty if the skill is at the repo root.",
)}
description={subPathLock ? SUB_PATH_LOCK_REASON[subPathLock] : undefined}
>
{({ ref, onChange, ...field }) => (
<Input
{...field}
ref={ref}
placeholder="plugins/my-skill"
className="rounded-lg"
onChange={(event) => {
onChange(event);
recomputePreview(form.getValues("skillUrl"), event.target.value);
}}
disabled={subPathLock !== null}
/>
)}
</FormField>
{urlPreview?.parsed.source === "archive" && (
<FormField
control={form.control}
name="sha256"
label={labelWithHint(
"Archive SHA-256 (Optional)",
"Hex digest of the zip file. Claude Code refuses to install the archive if its checksum does not match.",
)}
>
{({ ref, ...field }) => (
<Input {...field} ref={ref} placeholder="64 hex characters" className="rounded-lg font-mono" />
)}
</FormField>
)}
{urlPreview && (
<div className="rounded-lg border border-info/20 bg-info/10 px-3 py-2 text-sm text-info">
Detected: {urlPreview.label}
</div>
)}
</>
)}
<FormField
@ -299,7 +371,15 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessT
name="name"
label={labelWithHint("Skill Name", "Unique identifier in kebab-case format (e.g., my-skill)")}
>
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="my-skill" className="rounded-lg" />}
{({ ref, ...field }) => (
<Input
{...field}
ref={ref}
placeholder="my-skill"
className="rounded-lg"
disabled={createdHostedPluginName !== null}
/>
)}
</FormField>
<div className="flex gap-4">
@ -414,7 +494,7 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessT
</Button>
<Button type="submit" disabled={isSubmitting} aria-busy={isSubmitting}>
{isSubmitting && <UiLoadingSpinner className="size-4" />}
{isSubmitting ? "Adding..." : "Add Skill"}
{isSubmitting ? "Adding..." : submitButtonText}
</Button>
</div>
</form>

View file

@ -54,4 +54,10 @@ describe("SkillDetail source", () => {
expect(screen.getByText("git@ghe.example.com:org/repo.git @ plugins/x")).toBeInTheDocument();
expect(screen.queryByRole("link")).not.toBeInTheDocument();
});
it("labels a hosted source without rendering it as a URL", () => {
render(<SkillDetail skill={buildSkill({ source: "hosted" })} onBack={vi.fn()} />);
expect(screen.getByText("Hosted on LiteLLM")).toBeInTheDocument();
expect(screen.queryByRole("link")).not.toBeInTheDocument();
});
});

View file

@ -5,6 +5,14 @@ import { buildMarketplaceSettingsSnippet, formatInstallCommand, getSourceDisplay
import { Plugin } from "./types";
const SkillSource: React.FC<{ source: Plugin["source"] }> = ({ source }) => {
if (source.source === "hosted") {
return (
<div className="mb-6">
<div className="mb-1 text-xs text-muted-foreground">Source</div>
<div className="break-all text-[13px] text-foreground">Hosted on LiteLLM</div>
</div>
);
}
const link = getSourceLink(source);
const href = link && source.source === "git-subdir" && source.path ? `${link}/tree/main/${source.path}` : link;
if (href) {

View file

@ -6,7 +6,7 @@
import type { components } from "@/lib/http/schema";
export interface PluginSource {
source: "github" | "url" | "git-subdir" | "archive";
source: "github" | "url" | "git-subdir" | "archive" | "hosted";
repo?: string; // Format: "org/repo" for GitHub
url?: string; // Full URL for other sources
path?: string; // Subdirectory path for git-subdir

View file

@ -152,6 +152,31 @@ describe("exchangeLoginCode - storeLoginToken integration", () => {
});
});
describe("uploadClaudeCodePluginArchive", () => {
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
});
it("sends the zip bytes as a raw application/zip body", async () => {
const fetchMock = vi.fn(async () => new Response(null, { status: 204 }));
global.fetch = fetchMock as unknown as typeof fetch;
const file = new File(["zip bytes"], "plugin.zip", { type: "application/zip" });
await Networking.uploadClaudeCodePluginArchive("sk-test", "hosted plugin", file);
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toContain("/claude-code/plugins/hosted%20plugin/archive");
expect(init.method).toBe("PUT");
expect(init.body).toBe(file);
expect(init.headers).toMatchObject({
Authorization: "Bearer sk-test",
"Content-Type": "application/zip",
});
});
});
describe("modelInfoCall", () => {
let currentFetch: typeof global.fetch;

View file

@ -7301,6 +7301,15 @@ export const registerClaudeCodePlugin = async (accessToken: string, pluginData:
}
};
export const uploadClaudeCodePluginArchive = async (accessToken: string, pluginName: string, file: File) => {
const path = `/claude-code/plugins/${encodeURIComponent(pluginName)}/archive`;
return apiClient.put<void>(path, {
accessToken,
rawBody: file,
headers: { "Content-Type": "application/zip" },
});
};
/**
* Enable a Claude Code plugin (admin only)
* @param accessToken - Admin access token

View file

@ -2550,6 +2550,46 @@ export interface paths {
patch?: never;
trace?: never;
};
"/claude-code/plugins/{plugin_name}/archive": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/**
* Upload Plugin Archive
* @description Upload and validate a zip archive for an existing hosted plugin.
*/
put: operations["upload_plugin_archive_claude_code_plugins__plugin_name__archive_put"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/claude-code/plugins/{plugin_name}/archive.zip": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Download Plugin Archive
* @description Download a hosted plugin archive when it is visible to the caller.
*/
get: operations["download_plugin_archive_claude_code_plugins__plugin_name__archive_zip_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/claude-code/plugins/{plugin_name}/disable": {
parameters: {
query?: never;
@ -40366,6 +40406,8 @@ export interface components {
* @description Plugin item in list responses.
*/
PluginListItem: {
/** Archive Sha256 */
archive_sha256?: string | null;
author?: components["schemas"]["PluginAuthor"] | null;
/** Category */
category?: string | null;
@ -41843,7 +41885,7 @@ export interface components {
homepage?: string | null;
/**
* Installation Preference
* @description Emitted as installationPreference on this plugin's marketplace.json entry. Needs an archive source, the only plugins Claude Desktop lists from a marketplace.json URL, and 'auto_install' or 'required' also need its sha256. Claude Desktop honors the mark only when the archive and marketplace.json share its inference gateway origin
* @description Emitted as installationPreference on this plugin's marketplace.json entry. Needs an archive source, the only plugins Claude Desktop lists from a marketplace.json URL. LiteLLM supplies the sha256 for gateway-hosted archives. Claude Desktop honors the mark only when the archive and marketplace.json share its inference gateway origin
*/
installation_preference?: ("available" | "auto_install" | "required") | null;
/**
@ -41868,6 +41910,7 @@ export interface components {
* - Git URL: {'source': 'url', 'url': 'https://github.com/org/repo.git'}
* - Git Subdir: {'source': 'git-subdir', 'url': 'https://github.com/org/repo.git', 'path': 'plugins/plugin-name'}
* - Zip archive on any https host (e.g. S3): {'source': 'archive', 'url': 'https://bucket.s3.amazonaws.com/plugin.zip', 'sha256': '<optional hex digest>'}
* - Gateway-hosted archive: {'source': 'hosted'}; upload the zip to LiteLLM after registering the plugin
*/
source: {
[key: string]: string;
@ -48009,7 +48052,7 @@ export interface components {
homepage?: string | null;
/**
* Installation Preference
* @description Emitted as installationPreference on this plugin's marketplace.json entry. Needs an archive source, the only plugins Claude Desktop lists from a marketplace.json URL, and 'auto_install' or 'required' also need its sha256. Claude Desktop honors the mark only when the archive and marketplace.json share its inference gateway origin
* @description Emitted as installationPreference on this plugin's marketplace.json entry. Needs an archive source, the only plugins Claude Desktop lists from a marketplace.json URL. LiteLLM supplies the sha256 for gateway-hosted archives. Claude Desktop honors the mark only when the archive and marketplace.json share its inference gateway origin
*/
installation_preference?: ("available" | "auto_install" | "required") | null;
/**
@ -48029,6 +48072,7 @@ export interface components {
* - Git URL: {'source': 'url', 'url': 'https://github.com/org/repo.git'}
* - Git Subdir: {'source': 'git-subdir', 'url': 'https://github.com/org/repo.git', 'path': 'plugins/plugin-name'}
* - Zip archive on any https host (e.g. S3): {'source': 'archive', 'url': 'https://bucket.s3.amazonaws.com/plugin.zip', 'sha256': '<optional hex digest>'}
* - Gateway-hosted archive: {'source': 'hosted'}; upload the zip to LiteLLM after registering the plugin
*/
source: {
[key: string]: string;
@ -48553,6 +48597,21 @@ export interface components {
/** User Role */
user_role?: ("proxy_admin" | "proxy_admin_viewer" | "internal_user" | "internal_user_viewer") | null;
};
/** UploadPluginArchiveResponse */
UploadPluginArchiveResponse: {
/** @description Updated plugin information */
plugin: components["schemas"]["PluginResponse"];
/**
* Sha256
* @description SHA-256 digest of the uploaded archive
*/
sha256: string;
/**
* Size Bytes
* @description Size of the uploaded archive in bytes
*/
size_bytes: number;
};
/** UsageAIChatRequest */
UsageAIChatRequest: {
/**
@ -54310,6 +54369,75 @@ export interface operations {
};
};
};
upload_plugin_archive_claude_code_plugins__plugin_name__archive_put: {
parameters: {
query?: never;
header?: never;
path: {
plugin_name: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/octet-stream": string;
"application/zip": string;
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["UploadPluginArchiveResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
download_plugin_archive_claude_code_plugins__plugin_name__archive_zip_get: {
parameters: {
query?: {
key?: string | null;
};
header?: never;
path: {
plugin_name: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
disable_plugin_claude_code_plugins__plugin_name__disable_post: {
parameters: {
query?: never;