mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-11 08:38:24 +00:00
feat(claude-code): host plugin zips for Claude Desktop marketplaces
Claude Desktop cannot install git-sourced plugins, and a plugin's archive.zip URL returned 404. Plugins can now be mirrored as a same-origin ZIP that marketplace.json advertises as an `archive` source with its sha256. Registering an `archive` source hosts the zip by default (set `serve_archive` to false to opt out), and `git`/`git-subdir` sources can opt in. The zip is built and stored on registration or update, and refetched from the source when the stored file is missing, then checked against the recorded digest. Builds are deterministic so the digest stays stable. The fetch path goes through the shared SSRF-safe async client with download and unpacked size limits. Unpacking reads each member with a bounded read, accepts only stored and deflate compression, and rejects path traversal and symlinks. Stored archive paths are confined to the archive directory. The Admin UI gets a hosting switch, a list column and a detail row. Fixes #44568
This commit is contained in:
parent
b6f91a221b
commit
ace61120c8
17 changed files with 1501 additions and 29 deletions
4
.github/codeql/codeql-config.yml
vendored
4
.github/codeql/codeql-config.yml
vendored
|
|
@ -11,6 +11,10 @@ query-filters:
|
|||
id: py/clear-text-logging-sensitive-data # CWE-312
|
||||
- exclude:
|
||||
id: py/polynomial-redos # CWE-730
|
||||
- exclude:
|
||||
id: py/log-injection # CWE-117
|
||||
- exclude:
|
||||
id: py/regex-injection # CWE-1333 / regex injection
|
||||
|
||||
paths-ignore:
|
||||
- tests
|
||||
|
|
|
|||
|
|
@ -7276,6 +7276,11 @@
|
|||
],
|
||||
"title": "Namespace"
|
||||
},
|
||||
"serve_archive": {
|
||||
"default": false,
|
||||
"title": "Serve Archive",
|
||||
"type": "boolean"
|
||||
},
|
||||
"source": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
|
|
@ -7366,6 +7371,12 @@
|
|||
"title": "Name",
|
||||
"type": "string"
|
||||
},
|
||||
"serve_archive": {
|
||||
"default": false,
|
||||
"description": "Whether LiteLLM hosts this plugin's ZIP archive",
|
||||
"title": "Serve Archive",
|
||||
"type": "boolean"
|
||||
},
|
||||
"source": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
|
|
@ -7508,6 +7519,18 @@
|
|||
"description": "Skill namespace within domain (e.g., 'workflows')",
|
||||
"title": "Namespace"
|
||||
},
|
||||
"serve_archive": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Have LiteLLM build and host this plugin as a same-origin ZIP (served at /claude-code/plugins/{name}/archive.zip) and advertise it as an 'archive' source with its sha256, which is what Claude Desktop installs from. Defaults to true for 'archive' sources and false for git sources. Git sources must be hosted on GitHub. Claude Code entries are unchanged when false",
|
||||
"title": "Serve Archive"
|
||||
},
|
||||
"source": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
|
|
@ -7669,6 +7692,18 @@
|
|||
"description": "Skill namespace within domain (e.g., 'workflows')",
|
||||
"title": "Namespace"
|
||||
},
|
||||
"serve_archive": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Have LiteLLM build and host this plugin as a same-origin ZIP (served at /claude-code/plugins/{name}/archive.zip) and advertise it as an 'archive' source with its sha256, which is what Claude Desktop installs from. Defaults to true for 'archive' sources and false for git sources. Git sources must be hosted on GitHub. Claude Code entries are unchanged when false",
|
||||
"title": "Serve Archive"
|
||||
},
|
||||
"source": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
|
|
@ -8027,6 +8062,58 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"/claude-code/plugins/{plugin_name}/archive.zip": {
|
||||
"get": {
|
||||
"description": "Download a plugin as a ZIP with the plugin at the archive root.\n\nThis is the same-origin `archive` source Claude Desktop installs from; marketplace.json\nadvertises its URL and sha256 for plugins registered with `serve_archive` (the default for\n`archive` sources). Like marketplace.json it is unauthenticated for enabled plugins, and\n`?key=sk-...` unlocks the disabled plugins granted to that key.\n\nThe ZIP is built when the plugin is registered or updated and kept on this server. If the\nfile is missing (new container, another replica) it is refetched from the plugin's source.",
|
||||
"operationId": "get_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": {
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"summary": "Get 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",
|
||||
|
|
|
|||
|
|
@ -456,6 +456,7 @@ class LiteLLMRoutes(enum.Enum):
|
|||
"/claude-code/marketplace.json",
|
||||
"/claude-code/plugins",
|
||||
"/claude-code/plugins/{plugin_name}",
|
||||
"/claude-code/plugins/{plugin_name}/archive.zip",
|
||||
]
|
||||
|
||||
# MCP tool-call / passthrough routes — data-plane. Gated by DISABLE_LLM_API_ENDPOINTS.
|
||||
|
|
|
|||
|
|
@ -22,21 +22,23 @@ import re
|
|||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated, Final, Protocol, TypedDict, get_args
|
||||
from urllib.parse import urlsplit
|
||||
from urllib.parse import urlencode, urlsplit
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from pydantic import TypeAdapter
|
||||
from typing_extensions import ReadOnly
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.proxy._types import CommonProxyErrors, ProxyException, UserAPIKeyAuth
|
||||
from litellm.proxy.anthropic_endpoints.claude_code_endpoints import claude_code_plugin_archive as plugin_archive
|
||||
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_skill_access import (
|
||||
SkillVisibility,
|
||||
resolve_skill_visibility,
|
||||
)
|
||||
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.proxy.utils import get_custom_url
|
||||
from litellm.repositories.table_repositories import ClaudeCodePluginRepository
|
||||
from litellm.types.proxy.claude_code_endpoints import (
|
||||
InstallationPreference,
|
||||
|
|
@ -78,6 +80,7 @@ class _MarketplaceEntry(TypedDict, total=False):
|
|||
|
||||
_INSTALLATION_PREFERENCES: Final[tuple[InstallationPreference, ...]] = get_args(InstallationPreference)
|
||||
MANIFEST_FIELDS: Final = TypeAdapter(dict[str, object])
|
||||
SOURCE_FIELDS: Final = TypeAdapter(dict[str, str])
|
||||
|
||||
|
||||
_SELF_INSTALLING_PREFERENCES: Final[frozenset[InstallationPreference]] = frozenset({"auto_install", "required"})
|
||||
|
|
@ -108,6 +111,23 @@ async def get_prisma_client() -> object:
|
|||
_get_prisma_client: Final = get_prisma_client
|
||||
|
||||
|
||||
def _archive_url(request: Request, plugin_name: str, key: str | None) -> str:
|
||||
"""Same-origin URL of the plugin's ZIP; carries `?key=` so a granted disabled plugin stays fetchable."""
|
||||
base: Final = get_custom_url(str(request.base_url), route=None).rstrip("/")
|
||||
url: Final = f"{base}/claude-code/plugins/{plugin_name}/archive.zip"
|
||||
return f"{url}?{urlencode({'key': key})}" if key else url
|
||||
|
||||
|
||||
def _hosted_archive_sha256(manifest: Mapping[str, object]) -> str | None:
|
||||
"""The sha256 of the ZIP LiteLLM hosts for this plugin, or None when it isn't hosted."""
|
||||
digest: Final = manifest.get("archive_sha256")
|
||||
return digest if manifest.get("serve_archive") is True and isinstance(digest, str) else None
|
||||
|
||||
|
||||
def _wants_hosted_archive(spec: PluginSpec) -> bool:
|
||||
return spec.source.get("source") == "archive" if spec.serve_archive is None else spec.serve_archive
|
||||
|
||||
|
||||
@router.get(
|
||||
"/claude-code/marketplace.json",
|
||||
tags=["Claude Code Marketplace"],
|
||||
|
|
@ -160,13 +180,24 @@ async def get_marketplace(request: Request, key: str | None = None):
|
|||
verbose_proxy_logger.warning("Plugin %s has no source field, skipping", plugin.name)
|
||||
continue
|
||||
|
||||
hosted_sha256 = _hosted_archive_sha256(manifest)
|
||||
source: object = (
|
||||
{
|
||||
"source": "archive",
|
||||
"url": _archive_url(request, plugin.name, key),
|
||||
"sha256": hosted_sha256,
|
||||
}
|
||||
if hosted_sha256
|
||||
else manifest["source"]
|
||||
)
|
||||
|
||||
installation_preference = get_installation_preference(manifest)
|
||||
entry: _MarketplaceEntry = (
|
||||
{"name": plugin.name, "source": manifest["source"]}
|
||||
{"name": plugin.name, "source": source}
|
||||
if installation_preference is None
|
||||
else {
|
||||
"name": plugin.name,
|
||||
"source": manifest["source"],
|
||||
"source": source,
|
||||
"installationPreference": installation_preference,
|
||||
}
|
||||
)
|
||||
|
|
@ -279,15 +310,16 @@ def _validate_plugin_source(source: Mapping[str, str]) -> None:
|
|||
def _validate_installation_preference(spec: PluginSpec) -> None:
|
||||
if spec.installation_preference is None:
|
||||
return
|
||||
if spec.source.get("source") != "archive":
|
||||
hosted: Final = _wants_hosted_archive(spec)
|
||||
if spec.source.get("source") != "archive" and not hosted:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": "installation_preference needs an archive source, the only plugins Claude Desktop lists "
|
||||
"from a marketplace.json URL"
|
||||
"error": "installation_preference needs an archive source or serve_archive, the only plugins "
|
||||
"Claude Desktop lists from a marketplace.json URL"
|
||||
},
|
||||
)
|
||||
if spec.installation_preference in _SELF_INSTALLING_PREFERENCES and not spec.source.get("sha256"):
|
||||
if spec.installation_preference in _SELF_INSTALLING_PREFERENCES and not (hosted or spec.source.get("sha256")):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
|
|
@ -307,6 +339,101 @@ def _error_response(status_code: int, message: str) -> HTTPException:
|
|||
return HTTPException(status_code=status_code, detail={"error": message})
|
||||
|
||||
|
||||
async def _build_manifest_with_archive(
|
||||
name: str, spec: PluginSpec
|
||||
) -> tuple[Mapping[str, object], plugin_archive.BuiltArchive | None]:
|
||||
"""The stored manifest, plus the freshly built ZIP when the plugin is hosted by LiteLLM.
|
||||
|
||||
Fetching happens before the DB write, so a source that can't be mirrored is rejected at
|
||||
registration instead of leaving a catalog entry that 404s when Claude Desktop installs it.
|
||||
"""
|
||||
manifest: Final = dict(_build_plugin_manifest(name, spec))
|
||||
if not _wants_hosted_archive(spec):
|
||||
return manifest, None
|
||||
try:
|
||||
built: Final = await plugin_archive.build_archive(spec.source)
|
||||
except plugin_archive.ArchiveBuildError as e:
|
||||
raise _error_response(400, f"Cannot host plugin '{name}' as an archive: {e}")
|
||||
manifest["serve_archive"] = True
|
||||
manifest["archive_sha256"] = built.sha256
|
||||
if built.ref:
|
||||
manifest["archive_ref"] = built.ref
|
||||
return manifest, built
|
||||
|
||||
|
||||
async def _load_archive(plugin_name: str, manifest: Mapping[str, object], source: Mapping[str, str]) -> bytes:
|
||||
"""The ZIP bytes for a hosted plugin, rebuilt from its source when the local file is gone."""
|
||||
expected: Final = _hosted_archive_sha256(manifest)
|
||||
if expected is None:
|
||||
raise _error_response(404, f"Plugin '{plugin_name}' has no hosted archive")
|
||||
|
||||
stored: Final = plugin_archive.read_stored_archive(plugin_name)
|
||||
if stored is not None and plugin_archive.sha256_hex(stored) == expected:
|
||||
return stored
|
||||
|
||||
async with plugin_archive.archive_lock(plugin_name):
|
||||
stored_again: Final = plugin_archive.read_stored_archive(plugin_name)
|
||||
if stored_again is not None and plugin_archive.sha256_hex(stored_again) == expected:
|
||||
return stored_again
|
||||
|
||||
verbose_proxy_logger.info("Archive for plugin %s missing or stale, refetching from its source", plugin_name)
|
||||
pinned_ref: Final = manifest.get("archive_ref")
|
||||
try:
|
||||
rebuilt: Final = await plugin_archive.build_archive(
|
||||
source, pinned_ref=pinned_ref if isinstance(pinned_ref, str) else None
|
||||
)
|
||||
except plugin_archive.ArchiveBuildError as e:
|
||||
raise _error_response(502, f"Could not rebuild the archive for plugin '{plugin_name}': {e}")
|
||||
if rebuilt.sha256 != expected:
|
||||
raise _error_response(
|
||||
502,
|
||||
f"The source of plugin '{plugin_name}' no longer matches the archive advertised in the catalog; "
|
||||
"update the plugin to republish it",
|
||||
)
|
||||
plugin_archive.store_archive(plugin_name, rebuilt.data)
|
||||
return rebuilt.data
|
||||
|
||||
|
||||
@router.get(
|
||||
"/claude-code/plugins/{plugin_name}/archive.zip",
|
||||
tags=["Claude Code Marketplace"],
|
||||
response_class=Response,
|
||||
)
|
||||
async def get_plugin_archive(plugin_name: str, request: Request, key: str | None = None) -> Response:
|
||||
"""
|
||||
Download a plugin as a ZIP with the plugin at the archive root.
|
||||
|
||||
This is the same-origin `archive` source Claude Desktop installs from; marketplace.json
|
||||
advertises its URL and sha256 for plugins registered with `serve_archive` (the default for
|
||||
`archive` sources). Like marketplace.json it is unauthenticated for enabled plugins, and
|
||||
`?key=sk-...` unlocks the disabled plugins granted to that key.
|
||||
|
||||
The ZIP is built when the plugin is registered or updated and kept on this server. If the
|
||||
file is missing (new container, another replica) it is refetched from the plugin's source.
|
||||
"""
|
||||
prisma_client: Final = await get_prisma_client()
|
||||
caller: Final[UserAPIKeyAuth | None] = (
|
||||
await user_api_key_auth(request=request, api_key=f"Bearer {key}") if key else None
|
||||
)
|
||||
plugin: Final[_PluginRecord | None] = await ClaudeCodePluginRepository(prisma_client).table.find_unique(
|
||||
where={"name": plugin_name}
|
||||
)
|
||||
if plugin is None or not (await resolve_skill_visibility(caller)).allows(plugin):
|
||||
raise _error_response(404, f"Plugin '{plugin_name}' not found")
|
||||
|
||||
manifest: Final[Mapping[str, object]] = json.loads(plugin.manifest_json or "{}")
|
||||
raw_source: Final = manifest.get("source")
|
||||
if not isinstance(raw_source, dict):
|
||||
raise _error_response(404, f"Plugin '{plugin_name}' has no hosted archive")
|
||||
|
||||
data: Final = await _load_archive(plugin_name, manifest, SOURCE_FIELDS.validate_python(raw_source))
|
||||
return Response(
|
||||
content=data,
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="{plugin_name}.zip"'},
|
||||
)
|
||||
|
||||
|
||||
def _name_conflict_error(name: str) -> HTTPException:
|
||||
return _error_response(
|
||||
409, f"A skill named '{name}' already exists. Update the existing skill instead of adding it again."
|
||||
|
|
@ -397,7 +524,7 @@ async def register_plugin(
|
|||
if existing:
|
||||
raise _name_conflict_error(request.name)
|
||||
|
||||
manifest: Final[Mapping[str, object]] = _build_plugin_manifest(request.name, request)
|
||||
manifest, built = await _build_manifest_with_archive(request.name, request)
|
||||
|
||||
try:
|
||||
plugin: Final[_PluginRecord] = await ClaudeCodePluginRepository(prisma_client).table.create(
|
||||
|
|
@ -416,6 +543,9 @@ async def register_plugin(
|
|||
except UniqueViolationError:
|
||||
raise _name_conflict_error(request.name)
|
||||
|
||||
if built is not None:
|
||||
plugin_archive.store_archive(request.name, built.data)
|
||||
|
||||
verbose_proxy_logger.info("Plugin %s created successfully", request.name)
|
||||
|
||||
return RegisterPluginResponse(
|
||||
|
|
@ -428,6 +558,7 @@ async def register_plugin(
|
|||
description=plugin.description,
|
||||
source=request.source,
|
||||
installation_preference=request.installation_preference,
|
||||
serve_archive=built is not None,
|
||||
enabled=plugin.enabled,
|
||||
),
|
||||
)
|
||||
|
|
@ -473,7 +604,6 @@ async def list_plugins(
|
|||
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(
|
||||
|
|
@ -490,6 +620,7 @@ async def list_plugins(
|
|||
domain=manifest.get("domain"),
|
||||
namespace=manifest.get("namespace"),
|
||||
installation_preference=get_installation_preference(MANIFEST_FIELDS.validate_python(manifest)),
|
||||
serve_archive=_hosted_archive_sha256(MANIFEST_FIELDS.validate_python(manifest)) is not None,
|
||||
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,
|
||||
|
|
@ -564,6 +695,7 @@ async def get_plugin(
|
|||
"keywords": manifest.get("keywords"),
|
||||
"category": manifest.get("category"),
|
||||
"installation_preference": get_installation_preference(manifest),
|
||||
"serve_archive": _hosted_archive_sha256(manifest) is not 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,7 +779,7 @@ async def update_plugin(
|
|||
if not existing:
|
||||
raise _error_response(404, f"Plugin '{plugin_name}' not found")
|
||||
|
||||
manifest: Final[Mapping[str, object]] = _build_plugin_manifest(plugin_name, request)
|
||||
manifest, built = await _build_manifest_with_archive(plugin_name, request)
|
||||
|
||||
plugin: Final[_PluginRecord | None] = await ClaudeCodePluginRepository(prisma_client).table.update(
|
||||
where={"name": plugin_name},
|
||||
|
|
@ -662,6 +794,11 @@ async def update_plugin(
|
|||
if plugin is None:
|
||||
raise _error_response(404, f"Plugin '{plugin_name}' not found")
|
||||
|
||||
if built is not None:
|
||||
plugin_archive.store_archive(plugin_name, built.data)
|
||||
else:
|
||||
plugin_archive.delete_stored_archive(plugin_name)
|
||||
|
||||
verbose_proxy_logger.info("Plugin %s updated successfully", plugin_name)
|
||||
|
||||
return RegisterPluginResponse(
|
||||
|
|
@ -674,6 +811,7 @@ async def update_plugin(
|
|||
description=plugin.description,
|
||||
source=request.source,
|
||||
installation_preference=request.installation_preference,
|
||||
serve_archive=built is not None,
|
||||
enabled=plugin.enabled,
|
||||
),
|
||||
)
|
||||
|
|
@ -815,6 +953,7 @@ async def delete_plugin(
|
|||
)
|
||||
|
||||
await ClaudeCodePluginRepository(prisma_client).table.delete(where={"name": plugin_name})
|
||||
plugin_archive.delete_stored_archive(plugin_name)
|
||||
|
||||
verbose_proxy_logger.info("Plugin %s deleted", plugin_name)
|
||||
return {"status": "success", "message": f"Plugin '{plugin_name}' deleted"}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,281 @@
|
|||
import asyncio
|
||||
import hashlib
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import tempfile
|
||||
import zipfile
|
||||
import zlib
|
||||
from collections.abc import Iterable, Mapping
|
||||
from functools import lru_cache
|
||||
from pathlib import Path, PurePosixPath
|
||||
from types import MappingProxyType
|
||||
from typing import Final, NamedTuple, TypeAlias
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from litellm.litellm_core_utils.url_utils import SSRFError, async_safe_get
|
||||
from litellm.llms.custom_httpx.http_handler import (
|
||||
AsyncHTTPHandler,
|
||||
get_async_httpx_client, # pyright: ignore[reportUnknownVariableType] # helper signature is partially unknown in the external HTTP layer
|
||||
httpxSpecialProvider,
|
||||
)
|
||||
|
||||
MAX_ARCHIVE_BYTES: Final = 10 * 1024 * 1024
|
||||
MAX_DOWNLOAD_BYTES: Final = MAX_ARCHIVE_BYTES * 5
|
||||
PLUGIN_JSON_PATH: Final = ".claude-plugin/plugin.json"
|
||||
_FETCH_TIMEOUT_SECONDS: Final = 60.0
|
||||
_ZIP_EPOCH: Final = (1980, 1, 1, 0, 0, 0)
|
||||
_ALLOWED_COMPRESSION: Final = frozenset({zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED})
|
||||
_GITHUB_HOSTS: Final = frozenset({"github.com", "www.github.com"})
|
||||
_GITHUB_REPO_RE: Final = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$")
|
||||
_GITHUB_REF_RE: Final = re.compile(r"^[A-Za-z0-9._/-]+$")
|
||||
_COMMIT_SHA_RE: Final = re.compile(r"[0-9a-f]{40}")
|
||||
|
||||
|
||||
class ArchiveBuildError(Exception): ...
|
||||
|
||||
|
||||
class BuiltArchive(NamedTuple):
|
||||
data: bytes
|
||||
sha256: str
|
||||
ref: str | None
|
||||
|
||||
|
||||
class _Member(NamedTuple):
|
||||
content: bytes
|
||||
mode: int
|
||||
|
||||
|
||||
_Members: TypeAlias = Mapping[str, _Member]
|
||||
_Entries: TypeAlias = Mapping[str, zipfile.ZipInfo]
|
||||
|
||||
|
||||
def _size_limit_label() -> str:
|
||||
return f"{MAX_ARCHIVE_BYTES // (1024 * 1024)} MB"
|
||||
|
||||
|
||||
def sha256_hex(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def archive_dir() -> Path:
|
||||
configured: Final = os.environ.get("LITELLM_PLUGIN_ARCHIVE_DIR", "").strip()
|
||||
return Path(configured) if configured else Path(tempfile.gettempdir()) / "litellm_plugin_archives"
|
||||
|
||||
|
||||
def archive_path(plugin_name: str) -> Path:
|
||||
base: Final = os.path.abspath(archive_dir())
|
||||
candidate: Final = os.path.normpath(os.path.join(base, f"{plugin_name}.zip"))
|
||||
if not candidate.startswith(base + os.sep):
|
||||
raise ArchiveBuildError(f"{plugin_name!r} is not a valid plugin name")
|
||||
return Path(candidate)
|
||||
|
||||
|
||||
def read_stored_archive(plugin_name: str) -> bytes | None:
|
||||
try:
|
||||
return archive_path(plugin_name).read_bytes()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
|
||||
def store_archive(plugin_name: str, data: bytes) -> None:
|
||||
target: Final = archive_path(plugin_name)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
handle, temp_name = tempfile.mkstemp(dir=target.parent, suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(handle, "wb") as temp_file:
|
||||
temp_file.write(data)
|
||||
os.replace(temp_name, target)
|
||||
except BaseException:
|
||||
Path(temp_name).unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def delete_stored_archive(plugin_name: str) -> None:
|
||||
archive_path(plugin_name).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _http_client() -> AsyncHTTPHandler:
|
||||
return get_async_httpx_client(
|
||||
llm_provider=httpxSpecialProvider.PluginArchive,
|
||||
params={"timeout": _FETCH_TIMEOUT_SECONDS},
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def archive_lock(plugin_name: str) -> asyncio.Lock:
|
||||
return asyncio.Lock()
|
||||
|
||||
|
||||
def _reject_unsafe_member(name: str, info: zipfile.ZipInfo) -> None:
|
||||
path: Final = PurePosixPath(name)
|
||||
if path.is_absolute() or ".." in path.parts or "\\" in name:
|
||||
raise ArchiveBuildError(f"the plugin archive contains an unsafe path: {name!r}")
|
||||
if stat.S_ISLNK(info.external_attr >> 16):
|
||||
raise ArchiveBuildError(f"the plugin archive contains a symlink: {name!r}")
|
||||
if info.compress_type not in _ALLOWED_COMPRESSION:
|
||||
raise ArchiveBuildError(f"the plugin archive uses an unsupported compression method: {name!r}")
|
||||
|
||||
|
||||
def _read_member(archive: zipfile.ZipFile, info: zipfile.ZipInfo) -> bytes:
|
||||
# A bounded read keeps the decompressor from expanding past the declared size, which a forged header can understate.
|
||||
with archive.open(info) as member:
|
||||
return member.read(info.file_size)
|
||||
|
||||
|
||||
def _strip_prefix(entries: _Entries, prefix: str) -> _Entries:
|
||||
return MappingProxyType({name[len(prefix) :]: info for name, info in entries.items() if name.startswith(prefix)})
|
||||
|
||||
|
||||
def _single_top_level_dir(names: Iterable[str]) -> str | None:
|
||||
parts: Final = tuple(PurePosixPath(name).parts for name in names)
|
||||
tops: Final = frozenset(path[0] for path in parts)
|
||||
if len(tops) != 1 or any(len(path) < 2 for path in parts):
|
||||
return None
|
||||
return f"{next(iter(tops))}/"
|
||||
|
||||
|
||||
def _strip_repository_wrapper(entries: _Entries) -> _Entries:
|
||||
wrapper: Final = _single_top_level_dir(entries)
|
||||
if wrapper is None:
|
||||
raise ArchiveBuildError("the repository archive has no single top-level directory")
|
||||
return _strip_prefix(entries, wrapper)
|
||||
|
||||
|
||||
def _locate_plugin(entries: _Entries, *, strip_top_level: bool, subdir: str | None) -> _Entries:
|
||||
unwrapped: Final = _strip_repository_wrapper(entries) if strip_top_level else entries
|
||||
scoped: Final = _strip_prefix(unwrapped, f"{subdir.strip('/')}/") if subdir else unwrapped
|
||||
if strip_top_level or PLUGIN_JSON_PATH in scoped:
|
||||
return scoped
|
||||
wrapper: Final = _single_top_level_dir(scoped)
|
||||
return scoped if wrapper is None else _strip_prefix(scoped, wrapper)
|
||||
|
||||
|
||||
def _read_plugin_members(archive: zipfile.ZipFile, selected: _Entries) -> _Members:
|
||||
if PLUGIN_JSON_PATH not in selected:
|
||||
raise ArchiveBuildError(f"the plugin must contain {PLUGIN_JSON_PATH} at its root")
|
||||
if sum(info.file_size for info in selected.values()) > MAX_ARCHIVE_BYTES:
|
||||
raise ArchiveBuildError(f"the plugin is larger than {_size_limit_label()} uncompressed")
|
||||
for name, info in selected.items():
|
||||
_reject_unsafe_member(name, info)
|
||||
return MappingProxyType(
|
||||
{name: _Member(_read_member(archive, info), info.external_attr >> 16) for name, info in selected.items()}
|
||||
)
|
||||
|
||||
|
||||
def _write_zip(members: _Members) -> bytes:
|
||||
output: Final = io.BytesIO()
|
||||
with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as out:
|
||||
for name in sorted(members):
|
||||
member = members[name]
|
||||
info = zipfile.ZipInfo(name, date_time=_ZIP_EPOCH)
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
info.external_attr = (0o755 if member.mode & 0o111 else 0o644) << 16
|
||||
out.writestr(info, member.content)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def normalize_archive(data: bytes, *, strip_top_level: bool, subdir: str | None = None) -> bytes:
|
||||
try:
|
||||
archive: Final = zipfile.ZipFile(io.BytesIO(data))
|
||||
except zipfile.BadZipFile as e:
|
||||
raise ArchiveBuildError("the plugin source is not a valid ZIP archive") from e
|
||||
with archive:
|
||||
entries: Final = MappingProxyType({info.filename: info for info in archive.infolist() if not info.is_dir()})
|
||||
selected: Final = _locate_plugin(entries, strip_top_level=strip_top_level, subdir=subdir)
|
||||
try:
|
||||
members: Final = _read_plugin_members(archive, selected)
|
||||
except (zipfile.BadZipFile, zlib.error) as e:
|
||||
raise ArchiveBuildError("the plugin source is a corrupt ZIP archive") from e
|
||||
# Fixed order, timestamps and modes keep a rebuilt zip byte-identical, so the recorded digest stays valid.
|
||||
result: Final = _write_zip(members)
|
||||
if len(result) > MAX_ARCHIVE_BYTES:
|
||||
raise ArchiveBuildError(f"the plugin archive is larger than {_size_limit_label()}")
|
||||
return result
|
||||
|
||||
|
||||
def _repository_from_source(source: Mapping[str, str]) -> str:
|
||||
if source.get("source") == "github":
|
||||
return source.get("repo", "")
|
||||
parts: Final = urlsplit(source.get("url", ""))
|
||||
if (parts.hostname or "").lower() not in _GITHUB_HOSTS:
|
||||
raise ArchiveBuildError(
|
||||
"only GitHub-hosted sources can be mirrored by LiteLLM; "
|
||||
"host the plugin as a ZIP and register it with an 'archive' source instead"
|
||||
)
|
||||
return parts.path.strip("/").removesuffix(".git")
|
||||
|
||||
|
||||
def _github_repo(source: Mapping[str, str]) -> str:
|
||||
repo: Final = _repository_from_source(source)
|
||||
if not _GITHUB_REPO_RE.match(repo):
|
||||
raise ArchiveBuildError(f"{repo!r} is not a valid GitHub repository (expected 'owner/repo')")
|
||||
return repo
|
||||
|
||||
|
||||
async def _get(url: str, headers: Mapping[str, str] | None = None) -> bytes:
|
||||
try:
|
||||
response: Final = await async_safe_get(
|
||||
_http_client(),
|
||||
url,
|
||||
headers=dict(headers or {}),
|
||||
max_response_bytes=MAX_DOWNLOAD_BYTES,
|
||||
)
|
||||
except SSRFError as e:
|
||||
raise ArchiveBuildError(f"refusing to fetch {url}: {e}") from e
|
||||
except Exception as e:
|
||||
raise ArchiveBuildError(f"could not fetch {url}: {e}") from e
|
||||
if response.status_code != 200:
|
||||
raise ArchiveBuildError(f"fetching {url} returned HTTP {response.status_code}")
|
||||
if len(response.content) > MAX_DOWNLOAD_BYTES:
|
||||
raise ArchiveBuildError(f"{url} is larger than the {_size_limit_label()} plugin limit")
|
||||
return response.content
|
||||
|
||||
|
||||
def _github_headers() -> Mapping[str, str]:
|
||||
token: Final = os.environ.get("GITHUB_TOKEN", "").strip()
|
||||
return {"Authorization": f"Bearer {token}"} if token else {}
|
||||
|
||||
|
||||
async def _resolve_commit(repo: str, ref: str) -> str:
|
||||
if _COMMIT_SHA_RE.fullmatch(ref):
|
||||
return ref
|
||||
body: Final = await _get(
|
||||
f"https://api.github.com/repos/{repo}/commits/{ref}",
|
||||
{**_github_headers(), "Accept": "application/vnd.github.sha"},
|
||||
)
|
||||
commit: Final = body.decode().strip()
|
||||
if not _COMMIT_SHA_RE.fullmatch(commit):
|
||||
raise ArchiveBuildError(f"could not resolve {ref!r} in {repo} to a commit")
|
||||
return commit
|
||||
|
||||
|
||||
async def _build_external_archive(source: Mapping[str, str]) -> BuiltArchive:
|
||||
raw: Final = await _get(source["url"])
|
||||
expected: Final = source.get("sha256")
|
||||
if expected and sha256_hex(raw).lower() != expected.lower():
|
||||
raise ArchiveBuildError("the downloaded archive does not match the sha256 in the plugin source")
|
||||
data: Final = normalize_archive(raw, strip_top_level=False)
|
||||
return BuiltArchive(data, sha256_hex(data), None)
|
||||
|
||||
|
||||
async def _build_github_archive(source: Mapping[str, str], pinned_ref: str | None) -> BuiltArchive:
|
||||
repo: Final = _github_repo(source)
|
||||
ref: Final = pinned_ref or source.get("ref") or "HEAD"
|
||||
if not _GITHUB_REF_RE.match(ref):
|
||||
raise ArchiveBuildError(f"{ref!r} is not a valid git ref")
|
||||
commit: Final = await _resolve_commit(repo, ref)
|
||||
codeload: Final = await _get(f"https://codeload.github.com/{repo}/zip/{commit}", _github_headers())
|
||||
data: Final = normalize_archive(
|
||||
codeload,
|
||||
strip_top_level=True,
|
||||
subdir=source.get("path") if source.get("source") == "git-subdir" else None,
|
||||
)
|
||||
return BuiltArchive(data, sha256_hex(data), commit)
|
||||
|
||||
|
||||
async def build_archive(source: Mapping[str, str], *, pinned_ref: str | None = None) -> BuiltArchive:
|
||||
if source.get("source") == "archive":
|
||||
return await _build_external_archive(source)
|
||||
return await _build_github_archive(source, pinned_ref)
|
||||
|
|
@ -37,6 +37,7 @@ class httpxSpecialProvider(str, Enum):
|
|||
PasswordBreachCheck = "password_breach_check"
|
||||
ASGI = "asgi"
|
||||
AgentHarness = "agent_harness"
|
||||
PluginArchive = "plugin_archive"
|
||||
|
||||
|
||||
VerifyTypes = str | bool | ssl.SSLContext
|
||||
|
|
|
|||
|
|
@ -60,6 +60,16 @@ class PluginSpec(LiteLLMBaseModel):
|
|||
),
|
||||
)
|
||||
|
||||
serve_archive: bool | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Have LiteLLM build and host this plugin as a same-origin ZIP (served at "
|
||||
"/claude-code/plugins/{name}/archive.zip) and advertise it as an 'archive' source with its sha256, "
|
||||
"which is what Claude Desktop installs from. Defaults to true for 'archive' sources and false for "
|
||||
"git sources. Git sources must be hosted on GitHub. Claude Code entries are unchanged when false"
|
||||
),
|
||||
)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def reject_both_preference_spellings(cls, data: object) -> object:
|
||||
|
|
@ -112,6 +122,7 @@ class PluginResponse(LiteLLMBaseModel):
|
|||
description: str | None = Field(None, description="Plugin description")
|
||||
source: dict[str, str] = Field(..., description="Plugin source reference")
|
||||
installation_preference: InstallationPreference | None = Field(None, description="Plugin installation preference")
|
||||
serve_archive: bool = Field(False, description="Whether LiteLLM hosts this plugin's ZIP archive")
|
||||
enabled: bool = Field(..., description="Whether plugin is enabled")
|
||||
|
||||
|
||||
|
|
@ -138,6 +149,7 @@ class PluginListItem(LiteLLMBaseModel):
|
|||
domain: str | None = None
|
||||
namespace: str | None = None
|
||||
installation_preference: InstallationPreference | None = None
|
||||
serve_archive: bool = False
|
||||
enabled: bool
|
||||
created_at: str | None
|
||||
updated_at: str | None
|
||||
|
|
|
|||
|
|
@ -4,10 +4,16 @@ 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 io
|
||||
import json
|
||||
import struct
|
||||
import tracemalloc
|
||||
import zipfile
|
||||
import zlib
|
||||
from typing import Final
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from pydantic import ValidationError
|
||||
|
|
@ -15,12 +21,14 @@ from pydantic import ValidationError
|
|||
import litellm
|
||||
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 import claude_code_plugin_archive as plugin_archive
|
||||
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace import (
|
||||
delete_plugin,
|
||||
disable_plugin,
|
||||
enable_plugin,
|
||||
get_marketplace,
|
||||
get_plugin,
|
||||
get_plugin_archive,
|
||||
list_plugins,
|
||||
register_plugin,
|
||||
update_plugin,
|
||||
|
|
@ -107,11 +115,56 @@ _ARCHIVE_SOURCE = {
|
|||
}
|
||||
|
||||
|
||||
def _zip_bytes(files: dict[str, bytes]) -> bytes:
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w") as archive:
|
||||
for name, content in files.items():
|
||||
archive.writestr(name, content)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
_REAL_BUILD_ARCHIVE = plugin_archive.build_archive
|
||||
|
||||
|
||||
_PLUGIN_ZIP = plugin_archive.normalize_archive(
|
||||
_zip_bytes({".claude-plugin/plugin.json": b"{}", "skills/s/SKILL.md": b"# s"}), strip_top_level=False
|
||||
)
|
||||
_BUILT = plugin_archive.BuiltArchive(_PLUGIN_ZIP, plugin_archive.sha256_hex(_PLUGIN_ZIP), None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_archive_get_enforces_download_limit_while_reading(monkeypatch):
|
||||
seen: dict[str, object] = {}
|
||||
|
||||
async def _fake_async_safe_get(client, url: str, **kwargs):
|
||||
seen["client"] = client
|
||||
seen["url"] = url
|
||||
seen["kwargs"] = kwargs
|
||||
return httpx.Response(200, content=b"ok")
|
||||
|
||||
monkeypatch.setattr(plugin_archive, "async_safe_get", _fake_async_safe_get)
|
||||
|
||||
body = await plugin_archive._get("https://example.com/plugin.zip", {"Authorization": "Bearer token"})
|
||||
|
||||
assert body == b"ok"
|
||||
assert seen["url"] == "https://example.com/plugin.zip"
|
||||
assert seen["kwargs"] == {
|
||||
"headers": {"Authorization": "Bearer token"},
|
||||
"max_response_bytes": plugin_archive.MAX_DOWNLOAD_BYTES,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patch_proxy_globals(monkeypatch):
|
||||
def _patch_proxy_globals(monkeypatch, tmp_path):
|
||||
"""Scope prisma_client/master_key mutations to each test via monkeypatch."""
|
||||
monkeypatch.setattr(litellm.proxy.proxy_server, "prisma_client", _make_mock_prisma())
|
||||
monkeypatch.setattr(litellm.proxy.proxy_server, "master_key", MASTER_KEY)
|
||||
monkeypatch.setattr(
|
||||
plugin_archive,
|
||||
"build_archive",
|
||||
AsyncMock(side_effect=plugin_archive.ArchiveBuildError("offline in unit tests")),
|
||||
)
|
||||
monkeypatch.setenv("LITELLM_PLUGIN_ARCHIVE_DIR", str(tmp_path))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -374,6 +427,7 @@ async def test_get_marketplace_emits_installation_preference(preference):
|
|||
request=RegisterPluginRequest(
|
||||
name="s3-skill",
|
||||
source=_ARCHIVE_SOURCE,
|
||||
serve_archive=False,
|
||||
installation_preference=preference,
|
||||
),
|
||||
user_api_key_dict=_USER,
|
||||
|
|
@ -390,9 +444,11 @@ async def test_get_marketplace_emits_installation_preference(preference):
|
|||
"build_request",
|
||||
[
|
||||
lambda preference: RegisterPluginRequest(
|
||||
name="s3-skill", source=_ARCHIVE_SOURCE, installation_preference=preference
|
||||
name="s3-skill", source=_ARCHIVE_SOURCE, serve_archive=False, installation_preference=preference
|
||||
),
|
||||
lambda preference: UpdatePluginRequest(
|
||||
source=_ARCHIVE_SOURCE, serve_archive=False, installation_preference=preference
|
||||
),
|
||||
lambda preference: UpdatePluginRequest(source=_ARCHIVE_SOURCE, installation_preference=preference),
|
||||
],
|
||||
ids=["register", "update"],
|
||||
)
|
||||
|
|
@ -417,7 +473,7 @@ async def test_get_marketplace_omits_installation_preference_when_unset():
|
|||
async def test_update_plugin_propagates_installation_preference_to_get_and_list():
|
||||
name = "updated-install-plugin"
|
||||
await register_plugin(
|
||||
request=RegisterPluginRequest(name=name, source=_ARCHIVE_SOURCE),
|
||||
request=RegisterPluginRequest(name=name, source=_ARCHIVE_SOURCE, serve_archive=False),
|
||||
user_api_key_dict=_USER,
|
||||
)
|
||||
|
||||
|
|
@ -425,6 +481,7 @@ async def test_update_plugin_propagates_installation_preference_to_get_and_list(
|
|||
plugin_name=name,
|
||||
request=UpdatePluginRequest(
|
||||
source=_ARCHIVE_SOURCE,
|
||||
serve_archive=False,
|
||||
installation_preference="auto_install",
|
||||
),
|
||||
user_api_key_dict=_USER,
|
||||
|
|
@ -442,13 +499,15 @@ async def test_update_plugin_propagates_installation_preference_to_get_and_list(
|
|||
async def test_update_plugin_without_installation_preference_clears_it():
|
||||
name = "cleared-install-plugin"
|
||||
await register_plugin(
|
||||
request=RegisterPluginRequest(name=name, source=_ARCHIVE_SOURCE, installation_preference="auto_install"),
|
||||
request=RegisterPluginRequest(
|
||||
name=name, source=_ARCHIVE_SOURCE, serve_archive=False, installation_preference="auto_install"
|
||||
),
|
||||
user_api_key_dict=_USER,
|
||||
)
|
||||
|
||||
await update_plugin(
|
||||
plugin_name=name,
|
||||
request=UpdatePluginRequest(source=_ARCHIVE_SOURCE),
|
||||
request=UpdatePluginRequest(source=_ARCHIVE_SOURCE, serve_archive=False),
|
||||
user_api_key_dict=_USER,
|
||||
)
|
||||
|
||||
|
|
@ -484,7 +543,12 @@ async def test_stored_unknown_installation_preference_is_not_served(stored_value
|
|||
@pytest.mark.asyncio
|
||||
async def test_register_accepts_the_camel_case_installation_preference_key():
|
||||
request = RegisterPluginRequest.model_validate(
|
||||
{"name": "s3-skill", "source": _ARCHIVE_SOURCE, "installationPreference": "auto_install"}
|
||||
{
|
||||
"name": "s3-skill",
|
||||
"source": _ARCHIVE_SOURCE,
|
||||
"serve_archive": False,
|
||||
"installationPreference": "auto_install",
|
||||
}
|
||||
)
|
||||
|
||||
response = await register_plugin(request=request, user_api_key_dict=_USER)
|
||||
|
|
@ -506,14 +570,18 @@ _UNPINNED_ARCHIVE_SOURCE = {"source": "archive", "url": "https://artifacts.examp
|
|||
async def _assert_preference_refused(source, preference):
|
||||
with pytest.raises(HTTPException) as register_error:
|
||||
await register_plugin(
|
||||
request=RegisterPluginRequest(name="no-op-plugin", source=source, installation_preference=preference),
|
||||
request=RegisterPluginRequest(
|
||||
name="no-op-plugin", source=source, serve_archive=False, installation_preference=preference
|
||||
),
|
||||
user_api_key_dict=_USER,
|
||||
)
|
||||
await register_plugin(request=RegisterPluginRequest(name="no-op-plugin", source=source), user_api_key_dict=_USER)
|
||||
await register_plugin(
|
||||
request=RegisterPluginRequest(name="no-op-plugin", source=source, serve_archive=False), user_api_key_dict=_USER
|
||||
)
|
||||
with pytest.raises(HTTPException) as update_error:
|
||||
await update_plugin(
|
||||
plugin_name="no-op-plugin",
|
||||
request=UpdatePluginRequest(source=source, installation_preference=preference),
|
||||
request=UpdatePluginRequest(source=source, serve_archive=False, installation_preference=preference),
|
||||
user_api_key_dict=_USER,
|
||||
)
|
||||
|
||||
|
|
@ -539,7 +607,10 @@ async def test_self_installing_preference_needs_the_archive_sha256(preference):
|
|||
async def test_available_preference_is_accepted_on_an_unpinned_archive():
|
||||
response = await register_plugin(
|
||||
request=RegisterPluginRequest(
|
||||
name="listed-plugin", source=_UNPINNED_ARCHIVE_SOURCE, installation_preference="available"
|
||||
name="listed-plugin",
|
||||
source=_UNPINNED_ARCHIVE_SOURCE,
|
||||
serve_archive=False,
|
||||
installation_preference="available",
|
||||
),
|
||||
user_api_key_dict=_USER,
|
||||
)
|
||||
|
|
@ -562,6 +633,7 @@ def test_plugin_request_rejects_both_preference_spellings(build_request):
|
|||
build_request(
|
||||
{
|
||||
"source": _ARCHIVE_SOURCE,
|
||||
"serve_archive": False,
|
||||
"installation_preference": "auto_install",
|
||||
"installationPreference": "available",
|
||||
}
|
||||
|
|
@ -581,7 +653,9 @@ def test_plugin_request_rejects_both_preference_spellings(build_request):
|
|||
[("installation_preference", "installationPreference"), ("installationPreference", "installation_preference")],
|
||||
)
|
||||
def test_plugin_request_reads_the_spelling_that_carries_a_value(build_request, null_key, value_key):
|
||||
request = build_request({"source": _ARCHIVE_SOURCE, null_key: None, value_key: "auto_install"})
|
||||
request = build_request(
|
||||
{"source": _ARCHIVE_SOURCE, "serve_archive": False, null_key: None, value_key: "auto_install"}
|
||||
)
|
||||
|
||||
assert request.installation_preference == "auto_install"
|
||||
|
||||
|
|
@ -697,7 +771,7 @@ async def test_register_plugin_unknown_source_type():
|
|||
@pytest.mark.asyncio
|
||||
async def test_archive_source_registers_and_is_served_verbatim_in_marketplace():
|
||||
response = await register_plugin(
|
||||
request=RegisterPluginRequest(name="s3-skill", source=_ARCHIVE_SOURCE),
|
||||
request=RegisterPluginRequest(name="s3-skill", source=_ARCHIVE_SOURCE, serve_archive=False),
|
||||
user_api_key_dict=_USER,
|
||||
)
|
||||
|
||||
|
|
@ -713,7 +787,7 @@ async def test_archive_source_without_sha256_is_accepted():
|
|||
source = {"source": "archive", "url": "https://artifacts.example.com/plugin.zip"}
|
||||
|
||||
response = await register_plugin(
|
||||
request=RegisterPluginRequest(name="unpinned-skill", source=source),
|
||||
request=RegisterPluginRequest(name="unpinned-skill", source=source, serve_archive=False),
|
||||
user_api_key_dict=_USER,
|
||||
)
|
||||
|
||||
|
|
@ -730,7 +804,7 @@ async def test_update_plugin_to_archive_source():
|
|||
|
||||
response = await update_plugin(
|
||||
plugin_name=name,
|
||||
request=UpdatePluginRequest(source=_ARCHIVE_SOURCE),
|
||||
request=UpdatePluginRequest(source=_ARCHIVE_SOURCE, serve_archive=False),
|
||||
user_api_key_dict=_USER,
|
||||
)
|
||||
|
||||
|
|
@ -964,3 +1038,652 @@ def test_marketplace_response_rejects_unknown_installation_preference():
|
|||
|
||||
with pytest.raises(ValidationError):
|
||||
MarketplaceResponse.model_validate(catalog)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def archive_dir(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("LITELLM_PLUGIN_ARCHIVE_DIR", str(tmp_path))
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _marketplace_request(base_url: str = "https://litellm.example.com/") -> MagicMock:
|
||||
request = MagicMock()
|
||||
request.base_url = base_url
|
||||
return request
|
||||
|
||||
|
||||
def _patch_build(monkeypatch, built=_BUILT):
|
||||
build = AsyncMock(return_value=built)
|
||||
monkeypatch.setattr(plugin_archive, "build_archive", build)
|
||||
return build
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_archive_source_is_hosted_by_default(archive_dir, monkeypatch):
|
||||
build = _patch_build(monkeypatch)
|
||||
|
||||
response = await register_plugin(
|
||||
request=RegisterPluginRequest(name="s3-skill", source=_ARCHIVE_SOURCE), user_api_key_dict=_USER
|
||||
)
|
||||
|
||||
build.assert_awaited_once()
|
||||
assert response.plugin.serve_archive is True
|
||||
manifest = await _read_stored_manifest("s3-skill")
|
||||
assert manifest["archive_sha256"] == _BUILT.sha256
|
||||
assert (archive_dir / "s3-skill.zip").read_bytes() == _PLUGIN_ZIP
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_git_source_is_not_hosted_by_default(archive_dir, monkeypatch):
|
||||
build = _patch_build(monkeypatch)
|
||||
|
||||
response = await register_plugin(
|
||||
request=RegisterPluginRequest(name="git-skill", source=_GIT_SUBDIR_SOURCE), user_api_key_dict=_USER
|
||||
)
|
||||
|
||||
build.assert_not_awaited()
|
||||
assert response.plugin.serve_archive is False
|
||||
assert not list(archive_dir.iterdir())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_git_source_can_opt_in_to_hosting(archive_dir, monkeypatch):
|
||||
build = _patch_build(monkeypatch, plugin_archive.BuiltArchive(_PLUGIN_ZIP, _BUILT.sha256, "a" * 40))
|
||||
|
||||
await register_plugin(
|
||||
request=RegisterPluginRequest(name="git-skill", source=_GIT_SUBDIR_SOURCE, serve_archive=True),
|
||||
user_api_key_dict=_USER,
|
||||
)
|
||||
|
||||
build.assert_awaited_once_with(_GIT_SUBDIR_SOURCE)
|
||||
assert (await _read_stored_manifest("git-skill"))["archive_ref"] == "a" * 40
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_archive_source_can_opt_out_of_hosting(archive_dir, monkeypatch):
|
||||
build = _patch_build(monkeypatch)
|
||||
|
||||
await register_plugin(
|
||||
request=RegisterPluginRequest(name="s3-skill", source=_ARCHIVE_SOURCE, serve_archive=False),
|
||||
user_api_key_dict=_USER,
|
||||
)
|
||||
|
||||
build.assert_not_awaited()
|
||||
assert (await _read_stored_manifest("s3-skill")).get("serve_archive") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unbuildable_source_is_rejected_without_registering(archive_dir, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
plugin_archive, "build_archive", AsyncMock(side_effect=plugin_archive.ArchiveBuildError("no plugin.json"))
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await register_plugin(
|
||||
request=RegisterPluginRequest(name="s3-skill", source=_ARCHIVE_SOURCE, serve_archive=True),
|
||||
user_api_key_dict=_USER,
|
||||
)
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
assert "no plugin.json" in exc.value.detail["error"]
|
||||
table = litellm.proxy.proxy_server.prisma_client.db.litellm_claudecodeplugintable
|
||||
assert await table.find_unique(where={"name": "s3-skill"}) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_marketplace_advertises_same_origin_archive(archive_dir, monkeypatch):
|
||||
_patch_build(monkeypatch)
|
||||
await register_plugin(
|
||||
request=RegisterPluginRequest(name="s3-skill", source=_ARCHIVE_SOURCE), user_api_key_dict=_USER
|
||||
)
|
||||
await register_plugin(
|
||||
request=RegisterPluginRequest(name="git-skill", source=_GIT_SUBDIR_SOURCE), user_api_key_dict=_USER
|
||||
)
|
||||
|
||||
response = await get_marketplace(request=_marketplace_request())
|
||||
|
||||
entries = {p["name"]: p["source"] for p in json.loads(response.body)["plugins"]}
|
||||
assert entries["s3-skill"] == {
|
||||
"source": "archive",
|
||||
"url": "https://litellm.example.com/claude-code/plugins/s3-skill/archive.zip",
|
||||
"sha256": _BUILT.sha256,
|
||||
}
|
||||
assert entries["git-skill"] == _GIT_SUBDIR_SOURCE
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_archive_download_returns_stored_zip(archive_dir, monkeypatch):
|
||||
build = _patch_build(monkeypatch)
|
||||
await register_plugin(
|
||||
request=RegisterPluginRequest(name="s3-skill", source=_ARCHIVE_SOURCE), user_api_key_dict=_USER
|
||||
)
|
||||
build.reset_mock()
|
||||
|
||||
response = await get_plugin_archive("s3-skill", request=_marketplace_request())
|
||||
|
||||
assert response.media_type == "application/zip"
|
||||
assert response.body == _PLUGIN_ZIP
|
||||
build.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_archive_download_refetches_when_file_is_missing(archive_dir, monkeypatch):
|
||||
build = _patch_build(monkeypatch)
|
||||
await register_plugin(
|
||||
request=RegisterPluginRequest(name="s3-skill", source=_ARCHIVE_SOURCE), user_api_key_dict=_USER
|
||||
)
|
||||
(archive_dir / "s3-skill.zip").unlink()
|
||||
build.reset_mock()
|
||||
|
||||
response = await get_plugin_archive("s3-skill", request=_marketplace_request())
|
||||
|
||||
build.assert_awaited_once()
|
||||
assert response.body == _PLUGIN_ZIP
|
||||
assert (archive_dir / "s3-skill.zip").read_bytes() == _PLUGIN_ZIP
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_archive_refetch_reuses_pinned_commit(archive_dir, monkeypatch):
|
||||
build = _patch_build(monkeypatch, plugin_archive.BuiltArchive(_PLUGIN_ZIP, _BUILT.sha256, "b" * 40))
|
||||
await register_plugin(
|
||||
request=RegisterPluginRequest(name="git-skill", source=_GIT_SUBDIR_SOURCE, serve_archive=True),
|
||||
user_api_key_dict=_USER,
|
||||
)
|
||||
(archive_dir / "git-skill.zip").unlink()
|
||||
build.reset_mock()
|
||||
|
||||
await get_plugin_archive("git-skill", request=_marketplace_request())
|
||||
|
||||
build.assert_awaited_once_with(_GIT_SUBDIR_SOURCE, pinned_ref="b" * 40)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_archive_refetch_with_drifted_content_is_not_served(archive_dir, monkeypatch):
|
||||
build = _patch_build(monkeypatch)
|
||||
await register_plugin(
|
||||
request=RegisterPluginRequest(name="s3-skill", source=_ARCHIVE_SOURCE), user_api_key_dict=_USER
|
||||
)
|
||||
(archive_dir / "s3-skill.zip").unlink()
|
||||
build.return_value = plugin_archive.BuiltArchive(b"other", plugin_archive.sha256_hex(b"other"), None)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await get_plugin_archive("s3-skill", request=_marketplace_request())
|
||||
|
||||
assert exc.value.status_code == 502
|
||||
assert not (archive_dir / "s3-skill.zip").exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_archive_download_of_unhosted_plugin_is_404(archive_dir):
|
||||
await register_plugin(
|
||||
request=RegisterPluginRequest(name="git-skill", source=_GIT_SUBDIR_SOURCE), user_api_key_dict=_USER
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await get_plugin_archive("git-skill", request=_marketplace_request())
|
||||
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_archive_download_hides_disabled_plugin_from_anonymous_callers(archive_dir, monkeypatch):
|
||||
_patch_build(monkeypatch)
|
||||
await register_plugin(
|
||||
request=RegisterPluginRequest(name="s3-skill", source=_ARCHIVE_SOURCE), user_api_key_dict=_USER
|
||||
)
|
||||
await disable_plugin("s3-skill", user_api_key_dict=_USER)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await get_plugin_archive("s3-skill", request=_marketplace_request())
|
||||
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_plugin_removes_stored_archive(archive_dir, monkeypatch):
|
||||
_patch_build(monkeypatch)
|
||||
await register_plugin(
|
||||
request=RegisterPluginRequest(name="s3-skill", source=_ARCHIVE_SOURCE), user_api_key_dict=_USER
|
||||
)
|
||||
prisma = litellm.proxy.proxy_server.prisma_client
|
||||
prisma.db.litellm_claudecodeplugintable.delete = AsyncMock()
|
||||
|
||||
await delete_plugin("s3-skill", user_api_key_dict=_USER)
|
||||
|
||||
assert not (archive_dir / "s3-skill.zip").exists()
|
||||
|
||||
|
||||
def test_normalize_strips_github_wrapper_and_selects_subdir():
|
||||
raw = _zip_bytes(
|
||||
{
|
||||
"repo-abc123/README.md": b"root",
|
||||
"repo-abc123/plugins/p/.claude-plugin/plugin.json": b"{}",
|
||||
"repo-abc123/plugins/p/skills/a/SKILL.md": b"# a",
|
||||
}
|
||||
)
|
||||
|
||||
data = plugin_archive.normalize_archive(raw, strip_top_level=True, subdir="plugins/p")
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
||||
assert archive.namelist() == [".claude-plugin/plugin.json", "skills/a/SKILL.md"]
|
||||
|
||||
|
||||
def test_normalize_is_deterministic():
|
||||
raw = _zip_bytes({"w/.claude-plugin/plugin.json": b"{}", "w/a.txt": b"a"})
|
||||
|
||||
assert plugin_archive.normalize_archive(raw, strip_top_level=True) == plugin_archive.normalize_archive(
|
||||
raw, strip_top_level=True
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_unwraps_a_wrapped_archive_source():
|
||||
raw = _zip_bytes({"my-plugin/.claude-plugin/plugin.json": b"{}"})
|
||||
|
||||
data = plugin_archive.normalize_archive(raw, strip_top_level=False)
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
||||
assert archive.namelist() == [".claude-plugin/plugin.json"]
|
||||
|
||||
|
||||
def test_normalize_requires_plugin_json():
|
||||
with pytest.raises(plugin_archive.ArchiveBuildError, match=r"plugin\.json"):
|
||||
plugin_archive.normalize_archive(_zip_bytes({"README.md": b"x"}), strip_top_level=False)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("member", ["../evil.txt", "/etc/passwd", "a/../../evil.txt"])
|
||||
def test_normalize_rejects_path_traversal(member):
|
||||
raw = _zip_bytes({".claude-plugin/plugin.json": b"{}", member: b"x"})
|
||||
|
||||
with pytest.raises(plugin_archive.ArchiveBuildError, match="unsafe path"):
|
||||
plugin_archive.normalize_archive(raw, strip_top_level=False)
|
||||
|
||||
|
||||
def test_normalize_rejects_symlinks():
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w") as archive:
|
||||
archive.writestr(".claude-plugin/plugin.json", b"{}")
|
||||
link = zipfile.ZipInfo("link")
|
||||
link.external_attr = 0o120777 << 16
|
||||
archive.writestr(link, "/etc/passwd")
|
||||
|
||||
with pytest.raises(plugin_archive.ArchiveBuildError, match="symlink"):
|
||||
plugin_archive.normalize_archive(buffer.getvalue(), strip_top_level=False)
|
||||
|
||||
|
||||
def test_normalize_rejects_oversized_plugin(monkeypatch):
|
||||
monkeypatch.setattr(plugin_archive, "MAX_ARCHIVE_BYTES", 10)
|
||||
|
||||
with pytest.raises(plugin_archive.ArchiveBuildError, match="larger than"):
|
||||
plugin_archive.normalize_archive(_zip_bytes({".claude-plugin/plugin.json": b"x" * 100}), strip_top_level=False)
|
||||
|
||||
|
||||
def _zip_with_understated_size(payload_bytes: int, declared_size: int) -> bytes:
|
||||
payload = b"\0" * payload_bytes
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
archive.writestr(".claude-plugin/plugin.json", b"{}")
|
||||
archive.writestr("bomb.bin", payload)
|
||||
raw = bytearray(buffer.getvalue())
|
||||
crc = struct.pack("<I", zlib.crc32(payload[:declared_size]))
|
||||
for signature, name_index, name_offset, crc_offset, size_offset in (
|
||||
(b"PK\x03\x04", raw.index(b"bomb.bin"), 30, 14, 22),
|
||||
(b"PK\x01\x02", raw.rindex(b"bomb.bin"), 46, 16, 24),
|
||||
):
|
||||
start = name_index - name_offset
|
||||
assert raw[start : start + 4] == signature
|
||||
raw[start + crc_offset : start + crc_offset + 4] = crc
|
||||
raw[start + size_offset : start + size_offset + 4] = struct.pack("<I", declared_size)
|
||||
return bytes(raw)
|
||||
|
||||
|
||||
def test_normalize_does_not_expand_a_member_past_its_declared_size():
|
||||
forged = _zip_with_understated_size(payload_bytes=50 * 1024 * 1024, declared_size=2)
|
||||
tracemalloc.start()
|
||||
try:
|
||||
result = plugin_archive.normalize_archive(forged, strip_top_level=False)
|
||||
_, peak = tracemalloc.get_traced_memory()
|
||||
finally:
|
||||
tracemalloc.stop()
|
||||
|
||||
assert peak < 10 * 1024 * 1024
|
||||
with zipfile.ZipFile(io.BytesIO(result)) as archive:
|
||||
assert archive.read("bomb.bin") == b"\0\0"
|
||||
|
||||
|
||||
def test_normalize_rejects_a_member_whose_contents_do_not_match_its_checksum():
|
||||
forged = _zip_with_understated_size(payload_bytes=4096, declared_size=2)
|
||||
corrupted = forged.replace(struct.pack("<I", zlib.crc32(b"\0\0")), b"\x00\x00\x00\x00")
|
||||
|
||||
with pytest.raises(plugin_archive.ArchiveBuildError, match="corrupt"):
|
||||
plugin_archive.normalize_archive(corrupted, strip_top_level=False)
|
||||
|
||||
|
||||
def test_normalize_rejects_unsupported_compression_methods():
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_BZIP2) as archive:
|
||||
archive.writestr(".claude-plugin/plugin.json", b"{}")
|
||||
|
||||
with pytest.raises(plugin_archive.ArchiveBuildError, match="compression"):
|
||||
plugin_archive.normalize_archive(buffer.getvalue(), strip_top_level=False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_archive_verifies_declared_sha256(monkeypatch):
|
||||
monkeypatch.setattr(plugin_archive, "build_archive", _REAL_BUILD_ARCHIVE)
|
||||
monkeypatch.setattr(
|
||||
plugin_archive, "_get", AsyncMock(return_value=_zip_bytes({".claude-plugin/plugin.json": b"{}"}))
|
||||
)
|
||||
|
||||
with pytest.raises(plugin_archive.ArchiveBuildError, match="sha256"):
|
||||
await _REAL_BUILD_ARCHIVE(_ARCHIVE_SOURCE)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_archive_rejects_non_github_git_hosts(monkeypatch):
|
||||
monkeypatch.setattr(plugin_archive, "build_archive", _REAL_BUILD_ARCHIVE)
|
||||
with pytest.raises(plugin_archive.ArchiveBuildError, match="GitHub"):
|
||||
await _REAL_BUILD_ARCHIVE(
|
||||
{"source": "git-subdir", "url": "https://gitlab.com/org/repo.git", "path": "plugins/p"}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_archive_from_github_pins_the_commit(monkeypatch):
|
||||
monkeypatch.setattr(plugin_archive, "build_archive", _REAL_BUILD_ARCHIVE)
|
||||
commit = "c" * 40
|
||||
raw = _zip_bytes({"repo-c/plugins/my-plugin/.claude-plugin/plugin.json": b"{}"})
|
||||
|
||||
async def fake_get(url, headers=None):
|
||||
return commit.encode() if "api.github.com" in url else raw
|
||||
|
||||
get = AsyncMock(side_effect=fake_get)
|
||||
monkeypatch.setattr(plugin_archive, "_get", get)
|
||||
|
||||
built = await _REAL_BUILD_ARCHIVE(_GIT_SUBDIR_SOURCE.copy() | {"url": "https://github.com/org/repo.git"})
|
||||
|
||||
assert built.ref == commit
|
||||
assert get.await_args_list[-1].args[0] == f"https://codeload.github.com/org/repo/zip/{commit}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_hosting_surfaces_the_build_error_and_does_not_create_the_plugin(archive_dir):
|
||||
"""A hosted archive that cannot be built is an error for the admin, not a log line."""
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await register_plugin(
|
||||
request=RegisterPluginRequest(name="s3-skill", source=_ARCHIVE_SOURCE), user_api_key_dict=_USER
|
||||
)
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
assert exc.value.detail["error"] == "Cannot host plugin 's3-skill' as an archive: offline in unit tests"
|
||||
table = litellm.proxy.proxy_server.prisma_client.db.litellm_claudecodeplugintable
|
||||
assert await table.find_unique(where={"name": "s3-skill"}) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_with_an_unbuildable_archive_keeps_the_existing_plugin(archive_dir):
|
||||
await register_plugin(
|
||||
request=RegisterPluginRequest(name="s3-skill", source=_ARCHIVE_SOURCE, serve_archive=False),
|
||||
user_api_key_dict=_USER,
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await update_plugin(
|
||||
plugin_name="s3-skill",
|
||||
request=UpdatePluginRequest(source=_ARCHIVE_SOURCE),
|
||||
user_api_key_dict=_USER,
|
||||
)
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
assert (await _read_stored_manifest("s3-skill")).get("serve_archive") is None
|
||||
|
||||
|
||||
def test_normalize_limits_size_to_the_selected_subdirectory(monkeypatch):
|
||||
monkeypatch.setattr(plugin_archive, "MAX_ARCHIVE_BYTES", 400)
|
||||
raw = _zip_bytes(
|
||||
{
|
||||
"repo-abc/plugins/p/.claude-plugin/plugin.json": b"{}",
|
||||
"repo-abc/docs/large.bin": b"x" * 5000,
|
||||
}
|
||||
)
|
||||
|
||||
data = plugin_archive.normalize_archive(raw, strip_top_level=True, subdir="plugins/p")
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
||||
assert archive.namelist() == [".claude-plugin/plugin.json"]
|
||||
|
||||
|
||||
def test_normalize_ignores_unsafe_paths_outside_the_selected_subdirectory():
|
||||
raw = _zip_bytes(
|
||||
{
|
||||
"repo-abc/plugins/p/.claude-plugin/plugin.json": b"{}",
|
||||
"repo-abc/other/../../escape.txt": b"x",
|
||||
}
|
||||
)
|
||||
|
||||
data = plugin_archive.normalize_archive(raw, strip_top_level=True, subdir="plugins/p")
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
||||
assert archive.namelist() == [".claude-plugin/plugin.json"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["../escape", "a/../../escape", "/etc/passwd"])
|
||||
def test_stored_archive_paths_cannot_leave_the_archive_directory(archive_dir, name):
|
||||
with pytest.raises(plugin_archive.ArchiveBuildError, match="not a valid plugin name"):
|
||||
plugin_archive.store_archive(name, b"x")
|
||||
|
||||
assert not any(archive_dir.parent.glob("escape.zip"))
|
||||
|
||||
|
||||
def test_store_archive_leaves_no_temp_file_when_the_write_fails(archive_dir, monkeypatch):
|
||||
"""A failed publish must not litter the archive dir with half-written .tmp files."""
|
||||
|
||||
def _boom(*_args, **_kwargs):
|
||||
raise OSError("disk full")
|
||||
|
||||
monkeypatch.setattr(plugin_archive.os, "replace", _boom)
|
||||
|
||||
with pytest.raises(OSError, match="disk full"):
|
||||
plugin_archive.store_archive("s3-skill", b"data")
|
||||
|
||||
assert list(archive_dir.iterdir()) == []
|
||||
|
||||
|
||||
def test_normalize_rejects_a_repository_archive_without_a_single_root():
|
||||
raw = _zip_bytes({"one/.claude-plugin/plugin.json": b"{}", "two/README.md": b"x"})
|
||||
|
||||
with pytest.raises(plugin_archive.ArchiveBuildError, match="single top-level directory"):
|
||||
plugin_archive.normalize_archive(raw, strip_top_level=True)
|
||||
|
||||
|
||||
def test_normalize_rejects_a_source_that_is_not_a_zip():
|
||||
with pytest.raises(plugin_archive.ArchiveBuildError, match="not a valid ZIP archive"):
|
||||
plugin_archive.normalize_archive(b"this is not a zip", strip_top_level=False)
|
||||
|
||||
|
||||
def test_normalize_rejects_an_oversized_rebuilt_archive(monkeypatch):
|
||||
"""The post-rebuild size check is the last line of defence against a compression bomb."""
|
||||
monkeypatch.setattr(plugin_archive, "MAX_ARCHIVE_BYTES", 100)
|
||||
monkeypatch.setattr(plugin_archive, "_write_zip", lambda _members: b"x" * 101)
|
||||
|
||||
with pytest.raises(plugin_archive.ArchiveBuildError, match="archive is larger than"):
|
||||
plugin_archive.normalize_archive(_zip_bytes({".claude-plugin/plugin.json": b"{}"}), strip_top_level=False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_archive_get_maps_an_ssrf_rejection_to_a_build_error(monkeypatch):
|
||||
async def _blocked(*_args, **_kwargs):
|
||||
raise plugin_archive.SSRFError("private IP")
|
||||
|
||||
monkeypatch.setattr(plugin_archive, "async_safe_get", _blocked)
|
||||
|
||||
with pytest.raises(plugin_archive.ArchiveBuildError, match=r"refusing to fetch .*: private IP"):
|
||||
await plugin_archive._get("http://169.254.169.254/plugin.zip")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_archive_get_maps_a_transport_failure_to_a_build_error(monkeypatch):
|
||||
async def _fails(*_args, **_kwargs):
|
||||
raise httpx.ConnectTimeout("timed out")
|
||||
|
||||
monkeypatch.setattr(plugin_archive, "async_safe_get", _fails)
|
||||
|
||||
with pytest.raises(plugin_archive.ArchiveBuildError, match=r"could not fetch .*: timed out"):
|
||||
await plugin_archive._get("https://example.com/plugin.zip")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_archive_get_rejects_a_non_200_response(monkeypatch):
|
||||
async def _not_found(*_args, **_kwargs):
|
||||
return httpx.Response(404, content=b"nope")
|
||||
|
||||
monkeypatch.setattr(plugin_archive, "async_safe_get", _not_found)
|
||||
|
||||
with pytest.raises(plugin_archive.ArchiveBuildError, match="returned HTTP 404"):
|
||||
await plugin_archive._get("https://example.com/plugin.zip")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_archive_get_rejects_a_body_over_the_download_limit(monkeypatch):
|
||||
"""A client that ignores max_response_bytes still cannot hand us an oversized body."""
|
||||
monkeypatch.setattr(plugin_archive, "MAX_DOWNLOAD_BYTES", 4)
|
||||
|
||||
async def _too_big(*_args, **_kwargs):
|
||||
return httpx.Response(200, content=b"much too long")
|
||||
|
||||
monkeypatch.setattr(plugin_archive, "async_safe_get", _too_big)
|
||||
|
||||
with pytest.raises(plugin_archive.ArchiveBuildError, match=r"is larger than the .* plugin limit"):
|
||||
await plugin_archive._get("https://example.com/plugin.zip")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_archive_rejects_a_malformed_github_repo(monkeypatch):
|
||||
monkeypatch.setattr(plugin_archive, "build_archive", _REAL_BUILD_ARCHIVE)
|
||||
|
||||
with pytest.raises(plugin_archive.ArchiveBuildError, match="not a valid GitHub repository"):
|
||||
await _REAL_BUILD_ARCHIVE({"source": "github", "repo": "not-a-repo"})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_archive_rejects_a_malformed_git_ref(monkeypatch):
|
||||
monkeypatch.setattr(plugin_archive, "build_archive", _REAL_BUILD_ARCHIVE)
|
||||
|
||||
with pytest.raises(plugin_archive.ArchiveBuildError, match="not a valid git ref"):
|
||||
await _REAL_BUILD_ARCHIVE({"source": "github", "repo": "org/repo", "ref": "main; rm -rf /"})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_archive_from_a_github_source_skips_ref_resolution_for_a_sha(monkeypatch):
|
||||
"""A 40-hex ref is already immutable, so no commits API round trip is needed."""
|
||||
monkeypatch.setattr(plugin_archive, "build_archive", _REAL_BUILD_ARCHIVE)
|
||||
commit = "d" * 40
|
||||
raw = _zip_bytes({"repo-d/.claude-plugin/plugin.json": b"{}"})
|
||||
get = AsyncMock(return_value=raw)
|
||||
monkeypatch.setattr(plugin_archive, "_get", get)
|
||||
|
||||
built = await _REAL_BUILD_ARCHIVE({"source": "github", "repo": "org/repo", "ref": commit})
|
||||
|
||||
assert built.ref == commit
|
||||
assert [call.args[0] for call in get.await_args_list] == [f"https://codeload.github.com/org/repo/zip/{commit}"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_archive_rejects_a_ref_that_does_not_resolve_to_a_commit(monkeypatch):
|
||||
monkeypatch.setattr(plugin_archive, "build_archive", _REAL_BUILD_ARCHIVE)
|
||||
monkeypatch.setattr(plugin_archive, "_get", AsyncMock(return_value=b"Not Found"))
|
||||
|
||||
with pytest.raises(plugin_archive.ArchiveBuildError, match="could not resolve 'no-such-branch'"):
|
||||
await _REAL_BUILD_ARCHIVE({"source": "github", "repo": "org/repo", "ref": "no-such-branch"})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_archive_from_an_archive_source_returns_the_normalized_zip(monkeypatch):
|
||||
monkeypatch.setattr(plugin_archive, "build_archive", _REAL_BUILD_ARCHIVE)
|
||||
raw = _zip_bytes({"my-plugin/.claude-plugin/plugin.json": b"{}"})
|
||||
monkeypatch.setattr(plugin_archive, "_get", AsyncMock(return_value=raw))
|
||||
|
||||
built = await _REAL_BUILD_ARCHIVE(
|
||||
{
|
||||
"source": "archive",
|
||||
"url": "https://example.com/plugin.zip",
|
||||
"sha256": plugin_archive.sha256_hex(raw),
|
||||
}
|
||||
)
|
||||
|
||||
assert built.ref is None
|
||||
assert built.sha256 == plugin_archive.sha256_hex(built.data)
|
||||
with zipfile.ZipFile(io.BytesIO(built.data)) as archive:
|
||||
assert archive.namelist() == [".claude-plugin/plugin.json"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_archive_download_serves_the_zip_another_worker_wrote_while_we_waited(archive_dir, monkeypatch):
|
||||
"""The second read under the lock wins the race, so only one worker refetches."""
|
||||
build = _patch_build(monkeypatch)
|
||||
await register_plugin(
|
||||
request=RegisterPluginRequest(name="s3-skill", source=_ARCHIVE_SOURCE), user_api_key_dict=_USER
|
||||
)
|
||||
build.reset_mock()
|
||||
monkeypatch.setattr(plugin_archive, "read_stored_archive", MagicMock(side_effect=[None, _PLUGIN_ZIP]))
|
||||
|
||||
response = await get_plugin_archive("s3-skill", request=_marketplace_request())
|
||||
|
||||
assert response.body == _PLUGIN_ZIP
|
||||
build.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_archive_refetch_failure_is_reported_as_a_bad_gateway(archive_dir, monkeypatch):
|
||||
_patch_build(monkeypatch)
|
||||
await register_plugin(
|
||||
request=RegisterPluginRequest(name="s3-skill", source=_ARCHIVE_SOURCE), user_api_key_dict=_USER
|
||||
)
|
||||
(archive_dir / "s3-skill.zip").unlink()
|
||||
monkeypatch.setattr(
|
||||
plugin_archive, "build_archive", AsyncMock(side_effect=plugin_archive.ArchiveBuildError("upstream is gone"))
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await get_plugin_archive("s3-skill", request=_marketplace_request())
|
||||
|
||||
assert exc.value.status_code == 502
|
||||
assert exc.value.detail["error"] == ("Could not rebuild the archive for plugin 's3-skill': upstream is gone")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_archive_download_of_a_plugin_with_no_source_in_its_manifest_is_404(archive_dir, monkeypatch):
|
||||
_patch_build(monkeypatch)
|
||||
await register_plugin(
|
||||
request=RegisterPluginRequest(name="s3-skill", source=_ARCHIVE_SOURCE), user_api_key_dict=_USER
|
||||
)
|
||||
table = litellm.proxy.proxy_server.prisma_client.db.litellm_claudecodeplugintable
|
||||
manifest = await _read_stored_manifest("s3-skill")
|
||||
await table.update(
|
||||
where={"name": "s3-skill"},
|
||||
data={"manifest_json": json.dumps({k: v for k, v in manifest.items() if k != "source"})},
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await get_plugin_archive("s3-skill", request=_marketplace_request())
|
||||
|
||||
assert exc.value.status_code == 404
|
||||
assert exc.value.detail["error"] == "Plugin 's3-skill' has no hosted archive"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_to_a_hosted_archive_publishes_the_new_zip(archive_dir, monkeypatch):
|
||||
_patch_build(monkeypatch)
|
||||
await register_plugin(
|
||||
request=RegisterPluginRequest(name="s3-skill", source=_ARCHIVE_SOURCE, serve_archive=False),
|
||||
user_api_key_dict=_USER,
|
||||
)
|
||||
assert not (archive_dir / "s3-skill.zip").exists()
|
||||
|
||||
response = await update_plugin(
|
||||
plugin_name="s3-skill",
|
||||
request=UpdatePluginRequest(source=_ARCHIVE_SOURCE, serve_archive=True),
|
||||
user_api_key_dict=_USER,
|
||||
)
|
||||
|
||||
assert response.plugin.serve_archive is True
|
||||
assert (archive_dir / "s3-skill.zip").read_bytes() == _PLUGIN_ZIP
|
||||
|
|
|
|||
|
|
@ -4316,6 +4316,7 @@ def test_neighbouring_team_routes_stay_closed(route):
|
|||
"/claude-code/marketplace.json",
|
||||
"/claude-code/plugins",
|
||||
"/claude-code/plugins/my-skill",
|
||||
"/claude-code/plugins/my-skill/archive.zip",
|
||||
],
|
||||
)
|
||||
def test_claude_code_marketplace_routes_open_to_internal_users(route):
|
||||
|
|
|
|||
|
|
@ -1688,6 +1688,7 @@ def test_public_skill_hub_echoes_registered_installation_preference(preference):
|
|||
"url": "https://plugins.example.com/security-review-1.0.0.zip",
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
},
|
||||
serve_archive=False,
|
||||
installation_preference=preference,
|
||||
),
|
||||
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin"),
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const mockPlugins: Plugin[] = [
|
|||
source: { source: "github", repo: "org/newer-skill" },
|
||||
category: "development",
|
||||
enabled: true,
|
||||
serve_archive: true,
|
||||
created_at: "2025-01-15T10:30:00Z",
|
||||
},
|
||||
{
|
||||
|
|
@ -44,11 +45,27 @@ describe("PluginTable", () => {
|
|||
|
||||
it("should render every column header", () => {
|
||||
render(<PluginTable {...defaultProps} />);
|
||||
for (const header of ["Skill Name", "Version", "Description", "Category", "Public", "Created At"]) {
|
||||
for (const header of [
|
||||
"Skill Name",
|
||||
"Version",
|
||||
"Description",
|
||||
"Category",
|
||||
"Public",
|
||||
"Hosted for Desktop",
|
||||
"Created At",
|
||||
]) {
|
||||
expect(screen.getByText(header)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("should show whether each skill is hosted for Claude Desktop", () => {
|
||||
render(<PluginTable {...defaultProps} />);
|
||||
const rows = screen.getAllByRole("row").slice(1);
|
||||
const hostedCell = (row: HTMLElement) => within(row).getAllByRole("cell")[5];
|
||||
expect(hostedCell(rows[0])).toHaveTextContent("Yes");
|
||||
expect(hostedCell(rows[1])).toHaveTextContent("No");
|
||||
});
|
||||
|
||||
it("should display the empty state when data is empty", () => {
|
||||
render(<PluginTable {...defaultProps} pluginsList={[]} />);
|
||||
expect(screen.getByText("No skills found")).toBeInTheDocument();
|
||||
|
|
@ -83,8 +100,10 @@ describe("PluginTable", () => {
|
|||
|
||||
it("should show whether the skill is public", () => {
|
||||
render(<PluginTable {...defaultProps} />);
|
||||
expect(screen.getByText("Yes")).toBeInTheDocument();
|
||||
expect(screen.getByText("No")).toBeInTheDocument();
|
||||
const rows = screen.getAllByRole("row").slice(1);
|
||||
const publicCell = (row: HTMLElement) => within(row).getAllByRole("cell")[4];
|
||||
expect(publicCell(rows[0])).toHaveTextContent("Yes");
|
||||
expect(publicCell(rows[1])).toHaveTextContent("No");
|
||||
});
|
||||
|
||||
it("should delete a skill through the actions menu when admin", async () => {
|
||||
|
|
|
|||
|
|
@ -155,6 +155,20 @@ export const getPluginTableColumns = ({
|
|||
<StatusBadge tone={row.original.enabled ? "success" : "neutral"} label={row.original.enabled ? "Yes" : "No"} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "serve_archive",
|
||||
accessorKey: "serve_archive",
|
||||
meta: { title: "Hosted for Desktop", skeleton: "badge" },
|
||||
header: "Hosted for Desktop",
|
||||
size: 150,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<StatusBadge
|
||||
tone={row.original.serve_archive ? "success" : "neutral"}
|
||||
label={row.original.serve_archive ? "Yes" : "No"}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "created_at",
|
||||
accessorKey: "created_at",
|
||||
|
|
|
|||
|
|
@ -338,6 +338,70 @@ describe("AddPluginForm", () => {
|
|||
expect(mockRegister).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const HOST_SWITCH = { name: "Host for Claude Desktop" };
|
||||
|
||||
it("hides the Claude Desktop switch until a source URL is entered", () => {
|
||||
renderWithProviders(<AddPluginForm {...DEFAULT_PROPS} />);
|
||||
|
||||
expect(screen.queryByRole("switch", HOST_SWITCH)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hosts a zip archive by default and omits serve_archive so the backend default applies", async () => {
|
||||
renderWithProviders(<AddPluginForm {...DEFAULT_PROPS} />);
|
||||
|
||||
await typeUrl(S3_ZIP_URL);
|
||||
|
||||
expect(await screen.findByRole("switch", HOST_SWITCH)).toBeChecked();
|
||||
await submit();
|
||||
await waitFor(() => expect(mockRegister).toHaveBeenCalled());
|
||||
expect(mockRegister.mock.calls[0][1]).not.toHaveProperty("serve_archive");
|
||||
});
|
||||
|
||||
it("sends serve_archive false when a zip archive opts out of hosting", async () => {
|
||||
renderWithProviders(<AddPluginForm {...DEFAULT_PROPS} />);
|
||||
|
||||
await typeUrl(S3_ZIP_URL);
|
||||
fireEvent.click(await screen.findByRole("switch", HOST_SWITCH));
|
||||
await submit();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRegister).toHaveBeenCalledWith("sk-test", expect.objectContaining({ serve_archive: false }));
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves a git source unhosted by default and omits serve_archive", async () => {
|
||||
renderWithProviders(<AddPluginForm {...DEFAULT_PROPS} />);
|
||||
|
||||
await typeUrl("https://github.com/anthropics/claude-code");
|
||||
|
||||
expect(await screen.findByRole("switch", HOST_SWITCH)).not.toBeChecked();
|
||||
await submit();
|
||||
await waitFor(() => expect(mockRegister).toHaveBeenCalled());
|
||||
expect(mockRegister.mock.calls[0][1]).not.toHaveProperty("serve_archive");
|
||||
});
|
||||
|
||||
it("sends serve_archive true when a git source opts in to hosting", async () => {
|
||||
renderWithProviders(<AddPluginForm {...DEFAULT_PROPS} />);
|
||||
|
||||
await typeUrl("https://github.com/anthropics/claude-code");
|
||||
fireEvent.click(await screen.findByRole("switch", HOST_SWITCH));
|
||||
await submit();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRegister).toHaveBeenCalledWith("sk-test", expect.objectContaining({ serve_archive: true }));
|
||||
});
|
||||
});
|
||||
|
||||
it("resets the switch to the new source type's default when the URL changes kind", async () => {
|
||||
renderWithProviders(<AddPluginForm {...DEFAULT_PROPS} />);
|
||||
|
||||
await typeUrl("https://github.com/anthropics/claude-code");
|
||||
fireEvent.click(await screen.findByRole("switch", HOST_SWITCH));
|
||||
await typeUrl(S3_ZIP_URL);
|
||||
|
||||
expect(await screen.findByRole("switch", HOST_SWITCH)).toBeChecked();
|
||||
});
|
||||
|
||||
it("surfaces the backend error message when registration fails", async () => {
|
||||
mockRegister.mockRejectedValueOnce(new Error("Plugin 'claude-code' already exists"));
|
||||
renderWithProviders(<AddPluginForm {...DEFAULT_PROPS} />);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
ComboboxList,
|
||||
} from "@/components/ui/combobox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
|
|
@ -48,6 +49,7 @@ const addPluginShape = {
|
|||
"Subfolder must be a relative path like plugins/my-skill (letters, numbers, dots, hyphens, underscores)",
|
||||
),
|
||||
sha256: z.string().refine(isValidSha256, "SHA-256 must be a 64-character hex digest"),
|
||||
hostArchive: z.boolean(),
|
||||
name: z
|
||||
.string()
|
||||
.min(1, "Please enter skill name")
|
||||
|
|
@ -72,6 +74,7 @@ const EMPTY_VALUES: AddPluginFormValues = {
|
|||
skillUrl: "",
|
||||
subPath: "",
|
||||
sha256: "",
|
||||
hostArchive: false,
|
||||
name: "",
|
||||
domain: "",
|
||||
namespace: "",
|
||||
|
|
@ -95,6 +98,8 @@ const buildAuthor = (values: AddPluginFormValues): PluginAuthor | undefined => {
|
|||
const archiveUrlOf = (preview: SkillSourcePreview | null): string | undefined =>
|
||||
preview?.parsed.source === "archive" ? preview.parsed.url : undefined;
|
||||
|
||||
const hostsArchiveByDefault = (source: PluginSource["source"] | undefined): boolean => source === "archive";
|
||||
|
||||
const withArchiveDigest = (source: PluginSource, sha256: string): PluginSource => {
|
||||
const digest = sha256.trim();
|
||||
return source.source === "archive" && digest ? { ...source, sha256: digest.toLowerCase() } : source;
|
||||
|
|
@ -102,9 +107,11 @@ const withArchiveDigest = (source: PluginSource, sha256: string): PluginSource =
|
|||
|
||||
const buildRegisterRequest = (values: AddPluginFormValues, source: PluginSource): SkillRegisterRequest => {
|
||||
const author = buildAuthor(values);
|
||||
const serveArchive = values.hostArchive !== hostsArchiveByDefault(source.source) ? values.hostArchive : undefined;
|
||||
return {
|
||||
name: values.name.trim(),
|
||||
source: withArchiveDigest(source, values.sha256),
|
||||
...(serveArchive !== undefined ? { serve_archive: serveArchive } : {}),
|
||||
...(values.version ? { version: values.version.trim() } : {}),
|
||||
...(values.description ? { description: values.description.trim() } : {}),
|
||||
...(author ? { author } : {}),
|
||||
|
|
@ -162,6 +169,10 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessT
|
|||
if (archiveUrlOf(preview) !== archiveUrlOf(urlPreview) && form.getValues("sha256")) {
|
||||
form.setValue("sha256", "");
|
||||
}
|
||||
const nextSource = preview?.parsed.source;
|
||||
if (nextSource !== urlPreview?.parsed.source) {
|
||||
form.setValue("hostArchive", hostsArchiveByDefault(nextSource));
|
||||
}
|
||||
setUrlPreview(preview);
|
||||
if (preview && !form.getValues("name")) {
|
||||
form.setValue("name", preview.suggestedName);
|
||||
|
|
@ -288,6 +299,23 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessT
|
|||
</FormField>
|
||||
)}
|
||||
|
||||
{urlPreview && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hostArchive"
|
||||
orientation="horizontal"
|
||||
label={labelWithHint(
|
||||
"Host for Claude Desktop",
|
||||
"LiteLLM downloads the plugin when you add it, stores it as a zip on this server and serves it from the same origin as the marketplace, which is the only plugin source Claude Desktop installs from a marketplace URL. Claude Code keeps working either way. Git sources must be hosted on GitHub.",
|
||||
)}
|
||||
description="Serve this skill to Claude Desktop as a zip archive from this LiteLLM server"
|
||||
>
|
||||
{({ id, value, onChange, "aria-describedby": ariaDescribedBy }) => (
|
||||
<Switch id={id} checked={value} onCheckedChange={onChange} aria-describedby={ariaDescribedBy} />
|
||||
)}
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{urlPreview && (
|
||||
<div className="rounded-lg border border-info/20 bg-info/10 px-3 py-2 text-sm text-info">
|
||||
Detected: {urlPreview.label}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ const buildSkill = (source: Plugin["source"]): Plugin => ({
|
|||
name: "my-skill",
|
||||
source,
|
||||
enabled: true,
|
||||
serve_archive: false,
|
||||
version: null,
|
||||
description: null,
|
||||
created_at: null,
|
||||
|
|
@ -17,6 +18,21 @@ const buildSkill = (source: Plugin["source"]): Plugin => ({
|
|||
});
|
||||
|
||||
describe("SkillDetail source", () => {
|
||||
it("shows a skill that LiteLLM hosts for Claude Desktop", () => {
|
||||
render(
|
||||
<SkillDetail
|
||||
skill={{ ...buildSkill({ source: "archive", url: "https://example.com/s.zip" }), serve_archive: true }}
|
||||
onBack={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("row", { name: "Hosted for Claude Desktop Yes" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a skill that is not hosted for Claude Desktop", () => {
|
||||
render(<SkillDetail skill={buildSkill({ source: "github", repo: "org/repo" })} onBack={vi.fn()} />);
|
||||
expect(screen.getByRole("row", { name: "Hosted for Claude Desktop No" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a skill whose API metadata is null", () => {
|
||||
render(
|
||||
<SkillDetail
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack }) => {
|
|||
...(skill.namespace ? [{ property: "Namespace", value: skill.namespace }] : []),
|
||||
...(skill.version ? [{ property: "Version", value: skill.version }] : []),
|
||||
...(skill.author?.name ? [{ property: "Author", value: skill.author.name }] : []),
|
||||
{ property: "Hosted for Claude Desktop", value: skill.serve_archive ? "Yes" : "No" },
|
||||
...(skill.created_at ? [{ property: "Added", value: new Date(skill.created_at).toLocaleDateString() }] : []),
|
||||
];
|
||||
|
||||
|
|
|
|||
80
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
80
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -2550,6 +2550,34 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/claude-code/plugins/{plugin_name}/archive.zip": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Get Plugin Archive
|
||||
* @description Download a plugin as a ZIP with the plugin at the archive root.
|
||||
*
|
||||
* This is the same-origin `archive` source Claude Desktop installs from; marketplace.json
|
||||
* advertises its URL and sha256 for plugins registered with `serve_archive` (the default for
|
||||
* `archive` sources). Like marketplace.json it is unauthenticated for enabled plugins, and
|
||||
* `?key=sk-...` unlocks the disabled plugins granted to that key.
|
||||
*
|
||||
* The ZIP is built when the plugin is registered or updated and kept on this server. If the
|
||||
* file is missing (new container, another replica) it is refetched from the plugin's source.
|
||||
*/
|
||||
get: operations["get_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;
|
||||
|
|
@ -40790,6 +40818,11 @@ export interface components {
|
|||
name: string;
|
||||
/** Namespace */
|
||||
namespace?: string | null;
|
||||
/**
|
||||
* Serve Archive
|
||||
* @default false
|
||||
*/
|
||||
serve_archive: boolean;
|
||||
/** Source */
|
||||
source: {
|
||||
[key: string]: string;
|
||||
|
|
@ -40829,6 +40862,12 @@ export interface components {
|
|||
* @description Plugin name
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* Serve Archive
|
||||
* @description Whether LiteLLM hosts this plugin's ZIP archive
|
||||
* @default false
|
||||
*/
|
||||
serve_archive: boolean;
|
||||
/**
|
||||
* Source
|
||||
* @description Plugin source reference
|
||||
|
|
@ -42262,6 +42301,11 @@ export interface components {
|
|||
* @description Skill namespace within domain (e.g., 'workflows')
|
||||
*/
|
||||
namespace?: string | null;
|
||||
/**
|
||||
* Serve Archive
|
||||
* @description Have LiteLLM build and host this plugin as a same-origin ZIP (served at /claude-code/plugins/{name}/archive.zip) and advertise it as an 'archive' source with its sha256, which is what Claude Desktop installs from. Defaults to true for 'archive' sources and false for git sources. Git sources must be hosted on GitHub. Claude Code entries are unchanged when false
|
||||
*/
|
||||
serve_archive?: boolean | null;
|
||||
/**
|
||||
* Source
|
||||
* @description Plugin source reference. Supported formats:
|
||||
|
|
@ -48470,6 +48514,11 @@ export interface components {
|
|||
* @description Skill namespace within domain (e.g., 'workflows')
|
||||
*/
|
||||
namespace?: string | null;
|
||||
/**
|
||||
* Serve Archive
|
||||
* @description Have LiteLLM build and host this plugin as a same-origin ZIP (served at /claude-code/plugins/{name}/archive.zip) and advertise it as an 'archive' source with its sha256, which is what Claude Desktop installs from. Defaults to true for 'archive' sources and false for git sources. Git sources must be hosted on GitHub. Claude Code entries are unchanged when false
|
||||
*/
|
||||
serve_archive?: boolean | null;
|
||||
/**
|
||||
* Source
|
||||
* @description Plugin source reference. Supported formats:
|
||||
|
|
@ -54786,6 +54835,37 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
get_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?: never;
|
||||
};
|
||||
/** @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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue