diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml index 61982be90a9..d515ead7c9d 100644 --- a/.github/codeql/codeql-config.yml +++ b/.github/codeql/codeql-config.yml @@ -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 diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 4572e59944b..138b90b391f 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -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", diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 54acf59ef80..eb52ec657b3 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -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. diff --git a/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace.py b/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace.py index 8e348423837..18e47b8f9d6 100644 --- a/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace.py +++ b/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace.py @@ -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"} diff --git a/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_plugin_archive.py b/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_plugin_archive.py new file mode 100644 index 00000000000..b390a255adb --- /dev/null +++ b/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_plugin_archive.py @@ -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) diff --git a/litellm/types/llms/custom_http.py b/litellm/types/llms/custom_http.py index 47f80c52845..5cb652adb95 100644 --- a/litellm/types/llms/custom_http.py +++ b/litellm/types/llms/custom_http.py @@ -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 diff --git a/litellm/types/proxy/claude_code_endpoints.py b/litellm/types/proxy/claude_code_endpoints.py index 19e40aa5c5d..417c2d6f37b 100644 --- a/litellm/types/proxy/claude_code_endpoints.py +++ b/litellm/types/proxy/claude_code_endpoints.py @@ -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 diff --git a/tests/unit/proxy/anthropic_endpoints/test_claude_code_marketplace.py b/tests/unit/proxy/anthropic_endpoints/test_claude_code_marketplace.py index 6f426a4a3a7..67a8220a2b6 100644 --- a/tests/unit/proxy/anthropic_endpoints/test_claude_code_marketplace.py +++ b/tests/unit/proxy/anthropic_endpoints/test_claude_code_marketplace.py @@ -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(" { it("should render every column header", () => { render(); - 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(); + 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(); expect(screen.getByText("No skills found")).toBeInTheDocument(); @@ -83,8 +100,10 @@ describe("PluginTable", () => { it("should show whether the skill is public", () => { render(); - 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 () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTableColumns.tsx index 649944d1e6e..705b6a9127e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTableColumns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTableColumns.tsx @@ -155,6 +155,20 @@ export const getPluginTableColumns = ({ ), }, + { + id: "serve_archive", + accessorKey: "serve_archive", + meta: { title: "Hosted for Desktop", skeleton: "badge" }, + header: "Hosted for Desktop", + size: 150, + enableSorting: false, + cell: ({ row }) => ( + + ), + }, { id: "created_at", accessorKey: "created_at", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.test.tsx index 6507855d0c7..cc04610eb56 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.tsx index 63b8b1be7de..c23be3984c1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.tsx @@ -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 = ({ 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 = ({ visible, onClose, accessT )} + {urlPreview && ( + + {({ id, value, onChange, "aria-describedby": ariaDescribedBy }) => ( + + )} + + )} + {urlPreview && (
Detected: {urlPreview.label} diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.test.tsx b/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.test.tsx index 92e18514f66..aa3c269e58a 100644 --- a/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.test.tsx +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.test.tsx @@ -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( + , + ); + expect(screen.getByRole("row", { name: "Hosted for Claude Desktop Yes" })).toBeInTheDocument(); + }); + + it("shows a skill that is not hosted for Claude Desktop", () => { + render(); + expect(screen.getByRole("row", { name: "Hosted for Claude Desktop No" })).toBeInTheDocument(); + }); + it("renders a skill whose API metadata is null", () => { render( = ({ 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() }] : []), ]; diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index bd08b743c7d..01056ef092e 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -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;