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:
Matthijs Vos 2026-10-21 16:13:03 +02:00
parent b6f91a221b
commit ace61120c8
17 changed files with 1501 additions and 29 deletions

View file

@ -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

View file

@ -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",

View file

@ -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.

View file

@ -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"}

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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):

View file

@ -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"),

View file

@ -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 () => {

View file

@ -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",

View file

@ -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} />);

View file

@ -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}

View file

@ -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

View file

@ -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() }] : []),
];

View file

@ -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;