feat(skills): upload local skill ZIPs from the dashboard (#50008)

This commit is contained in:
tin-berri 2026-11-04 00:56:40 -08:00 • committed by GitHub
parent 664241188f
commit 48bd9b01f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 1839 additions and 189 deletions

View file

@ -0,0 +1,9 @@
SET LOCAL lock_timeout = '10s';
CREATE TABLE IF NOT EXISTS "LiteLLM_ClaudeCodePluginUpload" (
"plugin_name" TEXT NOT NULL,
"content" BYTEA NOT NULL,
CONSTRAINT "LiteLLM_ClaudeCodePluginUpload_pkey" PRIMARY KEY ("plugin_name"),
CONSTRAINT "LiteLLM_ClaudeCodePluginUpload_plugin_name_fkey" FOREIGN KEY ("plugin_name")
REFERENCES "LiteLLM_ClaudeCodePluginTable"("name") ON DELETE CASCADE ON UPDATE CASCADE
);

View file

@ -1615,9 +1615,18 @@ model LiteLLM_ClaudeCodePluginTable {
updated_at DateTime? @default(now()) @updatedAt
created_by String?
uploaded_archive LiteLLM_ClaudeCodePluginUpload?
@@map("LiteLLM_ClaudeCodePluginTable")
}
model LiteLLM_ClaudeCodePluginUpload {
plugin_name String @id
content Bytes
plugin LiteLLM_ClaudeCodePluginTable @relation(fields: [plugin_name], references: [name], onDelete: Cascade, onUpdate: Cascade)
}
// User/team-scoped memory store with a GLOBAL unique key.
// `value` is a string (typically markdown/text meant for LLM context);
// `metadata` is an optional JSON envelope for structured tags without schema changes.

View file

@ -341,6 +341,7 @@ _PRISMA_MODELS: Final[frozenset[str]] = frozenset(
"LiteLLM_AccountingDelivery",
"LiteLLM_AccountingEvent",
"LiteLLM_ClaudeCodePluginTable",
"LiteLLM_ClaudeCodePluginUpload",
"LiteLLM_MemoryTable",
"LiteLLM_AdaptiveRouterState",
"LiteLLM_AdaptiveRouterSession",

View file

@ -0,0 +1,29 @@
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Protocol
@dataclass(frozen=True, slots=True)
class PluginUpload:
name: str
version: str | None
description: str | None
manifest: Mapping[str, object]
content: bytes
created_by: str | None
@dataclass(frozen=True, slots=True)
class PluginArchiveMetadata:
name: str
enabled: bool
source: Mapping[str, str]
manifest_json: str | None
class PluginUploadStore(Protocol):
async def create(self, upload: PluginUpload) -> str | None: ...
async def get_metadata(self, name: str) -> PluginArchiveMetadata | None: ...
async def get_content(self, metadata: PluginArchiveMetadata) -> bytes | None: ...

View file

@ -7454,6 +7454,31 @@
"claude_code_marketplace": {
"components": {
"schemas": {
"Body_upload_plugin_claude_code_plugins_upload_post": {
"properties": {
"file": {
"contentMediaType": "application/octet-stream",
"description": "Skill ZIP containing SKILL.md and supporting files, up to 10 MiB",
"title": "File",
"type": "string"
},
"metadata": {
"contentMediaType": "application/json",
"contentSchema": {
"$ref": "#/components/schemas/UploadPluginRequest"
},
"description": "JSON skill metadata without a source URL",
"title": "Metadata",
"type": "string"
}
},
"required": [
"file",
"metadata"
],
"title": "Body_upload_plugin_claude_code_plugins_upload_post",
"type": "object"
},
"HTTPValidationError": {
"properties": {
"detail": {
@ -8057,6 +8082,137 @@
"title": "UpdatePluginRequest",
"type": "object"
},
"UploadPluginRequest": {
"properties": {
"author": {
"anyOf": [
{
"$ref": "#/components/schemas/PluginAuthor"
},
{
"type": "null"
}
],
"description": "Plugin author"
},
"category": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Plugin category",
"title": "Category"
},
"description": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Plugin description",
"title": "Description"
},
"domain": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Skill domain (e.g., 'Productivity')",
"title": "Domain"
},
"homepage": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Plugin homepage URL",
"title": "Homepage"
},
"installation_preference": {
"anyOf": [
{
"enum": [
"available",
"auto_install",
"required"
],
"type": "string"
},
{
"type": "null"
}
],
"description": "Emitted as installationPreference on this plugin's marketplace.json entry. Needs an archive source, the only plugins Claude Desktop lists from a marketplace.json URL, and 'auto_install' or 'required' also need its sha256. Claude Desktop honors the mark only when the archive and marketplace.json share its inference gateway origin",
"title": "Installation Preference"
},
"keywords": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"description": "Search keywords",
"title": "Keywords"
},
"name": {
"description": "Skill name (kebab-case)",
"pattern": "^[a-z0-9-]+$",
"title": "Name",
"type": "string"
},
"namespace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Skill namespace within domain (e.g., 'workflows')",
"title": "Namespace"
},
"version": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": "1.0.0",
"description": "Semantic version",
"title": "Version"
}
},
"required": [
"name"
],
"title": "UploadPluginRequest",
"type": "object"
},
"ValidationError": {
"properties": {
"ctx": {
@ -8242,6 +8398,53 @@
]
}
},
"/claude-code/plugins/upload": {
"post": {
"description": "Create a skill and its uploaded ZIP atomically. Duplicate names return 409.",
"operationId": "upload_plugin_claude_code_plugins_upload_post",
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/Body_upload_plugin_claude_code_plugins_upload_post"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RegisterPluginResponse"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"security": [
{
"APIKeyHeader": []
}
],
"summary": "Upload Plugin",
"tags": [
"claude_code_marketplace"
]
}
},
"/claude-code/plugins/{plugin_name}": {
"delete": {
"description": "Delete a plugin from the marketplace.\n\nRequires a proxy admin API key.\n\nParameters:\n - plugin_name: The name of the plugin to delete",
@ -8388,6 +8591,59 @@
]
}
},
"/claude-code/plugins/{plugin_name}/archive/{digest}.zip": {
"get": {
"description": "Download an uploaded ZIP under the same visibility policy as its skill.",
"operationId": "download_plugin_archive_claude_code_plugins__plugin_name__archive__digest__zip_get",
"parameters": [
{
"in": "path",
"name": "plugin_name",
"required": true,
"schema": {
"title": "Plugin Name",
"type": "string"
}
},
{
"in": "path",
"name": "digest",
"required": true,
"schema": {
"title": "Digest",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/zip": {
"schema": {
"format": "binary",
"type": "string"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Download Plugin Archive",
"tags": [
"claude_code_marketplace"
]
}
},
"/claude-code/plugins/{plugin_name}/disable": {
"post": {
"description": "Disable a plugin without deleting it.\n\nRequires a proxy admin API key.\n\nParameters:\n - plugin_name: The name of the plugin to disable",

View file

@ -467,6 +467,8 @@ class LiteLLMRoutes(enum.Enum):
"/v1/skills/{skill_id}",
"/claude-code/marketplace.json",
"/claude-code/plugins",
"/claude-code/plugins/upload",
"/claude-code/plugins/{plugin_name}/archive/{digest}.zip",
"/claude-code/plugins/{plugin_name}",
]

View file

@ -18,6 +18,8 @@ Endpoints:
/claude-code/plugins/{name} - DELETE - Delete a plugin (proxy admin only)
"""
import asyncio
import hashlib
import json
import re
from collections.abc import Mapping, Sequence
@ -26,21 +28,30 @@ from types import MappingProxyType
from typing import Annotated, Final, Protocol, TypedDict, get_args
from urllib.parse import urlsplit
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse
from pydantic import TypeAdapter
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import JSONResponse, Response
from pydantic import Json, TypeAdapter
from typing_extensions import ReadOnly
from litellm._logging import verbose_proxy_logger
from litellm.models.plugin_upload import PluginUpload
from litellm.proxy._types import CommonProxyErrors, ProxyErrorTypes, ProxyException, UserAPIKeyAuth
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_skill_access import (
SkillVisibility,
resolve_skill_visibility,
)
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.skill_upload_archive import (
MAX_SKILL_UPLOAD_BYTES,
build_uploaded_skill_archive,
)
from litellm.proxy.auth.auth_exception_handler import DB_UNAVAILABLE_FALLBACK_USER_ID
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_utils.claude_code_marketplace_auth import marketplace_auth_required
from litellm.proxy.common_utils.claude_code_marketplace_auth import (
marketplace_auth_required,
marketplace_key_header_present,
)
from litellm.proxy.common_utils.resource_ownership import is_proxy_admin
from litellm.proxy.deps.repositories import PluginUploadRepositoryDep
from litellm.repositories.table_repositories import ClaudeCodePluginRepository
from litellm.types.proxy.auth.special_headers import SpecialHeaders
from litellm.types.proxy.claude_code_endpoints import (
@ -52,6 +63,7 @@ from litellm.types.proxy.claude_code_endpoints import (
RegisterPluginRequest,
RegisterPluginResponse,
UpdatePluginRequest,
UploadPluginRequest,
)
router: Final = APIRouter()
@ -148,6 +160,117 @@ async def _marketplace_caller(request: Request, key: str | None) -> UserAPIKeyAu
return None
@router.post(
"/claude-code/plugins/upload",
tags=["Claude Code Marketplace"],
response_model=RegisterPluginResponse,
)
async def upload_plugin(
request: Request,
uploads: PluginUploadRepositoryDep,
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
file: Annotated[UploadFile, File(description="Skill ZIP containing SKILL.md and supporting files, up to 10 MiB")],
metadata: Annotated[Json[UploadPluginRequest], Form(description="JSON skill metadata without a source URL")],
) -> RegisterPluginResponse:
"""Create a skill and its uploaded ZIP atomically. Duplicate names return 409."""
from litellm.proxy.utils import get_custom_url
_require_proxy_admin(user_api_key_dict)
if uploads is None:
raise _error_response(500, CommonProxyErrors.db_not_connected_error.value)
content: Final = await file.read(MAX_SKILL_UPLOAD_BYTES + 1)
if len(content) > MAX_SKILL_UPLOAD_BYTES:
raise _error_response(413, "Skill ZIP must be 10 MiB or smaller")
archive: Final = await asyncio.to_thread(build_uploaded_skill_archive, content, metadata.name)
if archive is None:
raise _error_response(
400, "Upload a valid ZIP with SKILL.md at the root or inside one skill folder, and safe supporting files"
)
digest: Final = hashlib.sha256(archive).hexdigest()
source: Final = {
"source": "archive",
"url": get_custom_url(
request_base_url=str(request.base_url),
route=f"claude-code/plugins/{metadata.name}/archive/{digest}.zip",
),
"sha256": digest,
}
spec: Final = RegisterPluginRequest.model_validate({**metadata.model_dump(), "source": source})
_validate_installation_preference(spec)
plugin_id: Final = await uploads.create(
PluginUpload(
name=spec.name,
version=spec.version,
description=spec.description,
manifest=_build_plugin_manifest(spec.name, spec),
content=archive,
created_by=user_api_key_dict.user_id,
)
)
if plugin_id is None:
raise _name_conflict_error(spec.name)
return RegisterPluginResponse(
status="success",
action="created",
plugin=PluginResponse(
id=plugin_id,
name=spec.name,
version=spec.version,
description=spec.description,
source=source,
installation_preference=spec.installation_preference,
enabled=True,
),
)
@router.get(
"/claude-code/plugins/{plugin_name}/archive/{digest}.zip",
tags=["Claude Code Marketplace"],
response_class=Response,
responses={200: {"content": {"application/zip": {"schema": {"type": "string", "format": "binary"}}}}},
)
async def download_plugin_archive(
request: Request,
plugin_name: str,
digest: str,
uploads: PluginUploadRepositoryDep,
) -> Response:
"""Download an uploaded ZIP under the same visibility policy as its skill."""
from litellm.proxy.utils import get_custom_url
caller: Final = (
await _header_key_caller(request)
if marketplace_key_header_present(request)
else await _marketplace_caller(request, None)
)
visibility: Final = await resolve_skill_visibility(caller)
if uploads is None:
raise _error_response(500, CommonProxyErrors.db_not_connected_error.value)
archive: Final = await uploads.get_metadata(plugin_name)
expected_url: Final = get_custom_url(
request_base_url=str(request.base_url), route=f"claude-code/plugins/{plugin_name}/archive/{digest}.zip"
)
if (
archive is None
or not visibility.allows(archive)
or archive.source != {"source": "archive", "url": expected_url, "sha256": digest}
):
raise _error_response(404, "Skill archive not found")
content: Final = await uploads.get_content(archive)
if content is None:
raise _error_response(404, "Skill archive not found")
return Response(
content=content,
media_type="application/zip",
headers={
"Content-Disposition": f'attachment; filename="{archive.name}.zip"',
"Cache-Control": "private, no-store",
"X-Content-Type-Options": "nosniff",
},
)
@router.get(
"/claude-code/marketplace.json",
tags=["Claude Code Marketplace"],
@ -680,18 +803,19 @@ async def update_plugin(
prisma_client: Final = await get_prisma_client()
_validate_plugin_source(request.source)
_validate_installation_preference(request)
existing: Final[_PluginRecord | None] = await ClaudeCodePluginRepository(prisma_client).table.find_unique(
where={"name": plugin_name}
)
repository: Final = ClaudeCodePluginRepository(prisma_client, use_writer=True)
existing: Final[_PluginRecord | None] = await repository.table.find_unique(where={"name": plugin_name})
if not existing:
raise _error_response(404, f"Plugin '{plugin_name}' not found")
current_manifest: Final = MANIFEST_FIELDS.validate_json(existing.manifest_json or "{}")
if request.source != current_manifest.get("source"):
_validate_plugin_source(request.source)
_validate_installation_preference(request)
manifest: Final[Mapping[str, object]] = _build_plugin_manifest(plugin_name, request)
plugin: Final[_PluginRecord | None] = await ClaudeCodePluginRepository(prisma_client).table.update(
plugin: Final[_PluginRecord | None] = await repository.table.update(
where={"name": plugin_name},
data={
"version": request.version,

View file

@ -19,8 +19,11 @@ if TYPE_CHECKING:
class _SkillRecord(Protocol):
name: str
enabled: bool
@property
def name(self) -> str: ...
@property
def enabled(self) -> bool: ...
def _skills_of(permission: LiteLLM_ObjectPermissionTable | None) -> frozenset[str]:

View file

@ -0,0 +1,30 @@
import json
import re
import struct
import zipfile
from typing import Final
from litellm.proxy.discovery_endpoints.agent_skills_archive import pack_skill_archive, read_skill_archive_members
MAX_SKILL_UPLOAD_BYTES: Final = 10 * 1024 * 1024
MAX_SKILL_ARCHIVE_COMPRESSION_RATIO: Final = 50
def build_uploaded_skill_archive(content: bytes, name: str) -> bytes | None:
if len(content) > MAX_SKILL_UPLOAD_BYTES or re.fullmatch(r"[a-z0-9-]+", name) is None:
return None
try:
skill: Final = read_skill_archive_members(content, for_upload=True)
if skill is None:
return None
members: Final = ((".claude-plugin/plugin.json", json.dumps({"name": name}).encode(), 0o644),) + tuple(
(f"skills/{name}/{path}", data, mode) for path, data, mode in skill
)
compressed: Final = pack_skill_archive(members)
return (
pack_skill_archive(members, zipfile.ZIP_STORED)
if sum(len(data) for _, data, _ in members) > len(compressed) * MAX_SKILL_ARCHIVE_COMPRESSION_RATIO
else compressed
)
except (struct.error, UnicodeEncodeError):
return None

View file

@ -1,10 +1,25 @@
from typing import Final
from fastapi import Request
from pydantic import TypeAdapter, ValidationError
from litellm.types.proxy.auth.special_headers import SpecialHeaders
_AUTH_REQUIRED_SETTING: Final[TypeAdapter[bool | None]] = TypeAdapter(bool | None)
def marketplace_key_header_present(request: Request) -> bool:
"""Recognize header credentials before an optional-auth archive chooses anonymity."""
from litellm.proxy.proxy_server import general_settings_view
configured_header: Final = general_settings_view().get("litellm_key_header_name")
return (
SpecialHeaders.openai_authorization.value in request.headers
or SpecialHeaders.custom_litellm_api_key.value in request.headers
or (isinstance(configured_header, str) and configured_header in request.headers)
)
def marketplace_auth_required() -> bool:
from litellm.proxy.proxy_server import general_settings_view

View file

@ -207,10 +207,10 @@ def _parse_form_metadata(raw_metadata: str) -> object:
"""The JSON body path rejects a lone surrogate escape (``"\\ud800"``) with a
400 because no provider or database can encode it. ``json.loads`` accepts
the escape, so the multipart ``metadata`` field gets the same check."""
parsed: Final[object] = json.loads(raw_metadata)
try:
parsed: Final[object] = json.loads(raw_metadata)
json.dumps(parsed, ensure_ascii=False).encode("utf-8")
except UnicodeEncodeError as e:
except (json.JSONDecodeError, UnicodeEncodeError) as e:
raise ProxyException(
message=f"Invalid form payload: metadata {e}",
type="invalid_request_error",
@ -220,6 +220,11 @@ def _parse_form_metadata(raw_metadata: str) -> object:
return parsed
def _reraise_request_size_error(error: Exception) -> None:
if isinstance(error, HTTPException) and error.status_code == status.HTTP_413_CONTENT_TOO_LARGE:
raise error
async def _read_form_data(request: Request) -> FormData:
try:
form_data: Final = await request.form()
@ -227,6 +232,7 @@ async def _read_form_data(request: Request) -> FormData:
except ClientDisconnect:
raise
except Exception as e:
_reraise_request_size_error(e)
# ``request.form()`` raises on malformed multipart (missing
# boundary, malformed chunk encoding, …). Surface as 400 so
# the auth-time pre-read does not silently cache ``{}`` while
@ -377,6 +383,7 @@ async def read_request_body(request: Request | None) -> dict:
raise disconnected from e
except Exception as e:
# Catch unexpected errors to avoid crashes
_reraise_request_size_error(e)
verbose_proxy_logger.exception("Unexpected error reading request body - %s", e)
return {}
@ -814,10 +821,6 @@ def populate_request_with_path_params(request_data: dict, request: Request) -> d
request_data["vector_store_ids"] = [value]
continue
request_data.setdefault(key, value)
verbose_proxy_logger.debug(
"populate_request_with_path_params: Found path_params, vector_store_ids=%s",
request_data.get("vector_store_ids"),
)
return request_data
# Fallback: parse the URL path directly to extract vector_store_id
@ -853,7 +856,3 @@ def _add_vector_store_id_from_path(request_data: dict, request: Request) -> None
existing_ids.append(vector_store_id)
else:
request_data["vector_store_ids"] = [vector_store_id]
verbose_proxy_logger.debug(
"populate_request_with_path_params: Updated request_data with vector_store_ids=%s",
request_data.get("vector_store_ids"),
)

View file

@ -4,10 +4,12 @@ from fastapi import Depends
from litellm.models.deployment_warning import DeploymentWarningIgnoreStore
from litellm.models.gateway_activity import GatewayActivityStore
from litellm.models.plugin_upload import PluginUploadStore
from litellm.models.user_banner import UserBannerStore
from litellm.proxy.deps.database import DatabaseDep
from litellm.repositories.deployment_warning_ignore_repository import DeploymentWarningIgnoreRepository
from litellm.repositories.gateway_activity_repository import GatewayActivityRepository
from litellm.repositories.plugin_upload_repository import PluginUploadRepository
from litellm.repositories.user_banner_repository import UserBannerRepository
@ -34,3 +36,10 @@ async def get_gateway_activity_repository(database: DatabaseDep) -> GatewayActiv
GatewayActivityRepositoryDep: TypeAlias = Annotated[
GatewayActivityStore | None, Depends(get_gateway_activity_repository)
]
async def get_plugin_upload_repository(database: DatabaseDep) -> PluginUploadStore | None:
return None if database is None else PluginUploadRepository(database.prisma)
PluginUploadRepositoryDep: TypeAlias = Annotated[PluginUploadStore | None, Depends(get_plugin_upload_repository)]

View file

@ -8,8 +8,11 @@ SHA-256 digest published in the index reproducible for identical uploads.
import hashlib
import io
import lzma
import re
import stat
import zipfile
import zlib
from collections.abc import Mapping
from dataclasses import dataclass
from types import MappingProxyType
@ -23,7 +26,7 @@ MAX_ARCHIVE_ENTRIES: Final = 1000
SKILL_MANIFEST_FILENAME: Final = "SKILL.md"
_ZIP_ENTRY_TIMESTAMP: Final = (1980, 1, 1, 0, 0, 0)
_ZIP_ENTRY_PERMISSIONS: Final = 0o644 << 16
_ZIP_ENTRY_MODE: Final = 0o644
_FRONTMATTER_PATTERN: Final = re.compile(r"^---\s*\n(.*?)\n---\s*(?:\n|$)", re.DOTALL)
_WINDOWS_DRIVE_PATTERN: Final = re.compile(r"^[A-Za-z]:")
_EMPTY_FRONTMATTER: Final[Mapping[str, object]] = MappingProxyType({})
@ -38,19 +41,26 @@ class SkillArchive:
declared_description: str | None
def build_skill_archive(stored_content: bytes) -> SkillArchive | None:
"""Return the installable archive for an upload, or None when it holds no root SKILL.md."""
def read_skill_archive_members(
stored_content: bytes, *, for_upload: bool = False
) -> tuple[tuple[str, bytes, int], ...] | None:
try:
with zipfile.ZipFile(io.BytesIO(stored_content)) as uploaded:
members: Final = _flattened_members(uploaded)
except (zipfile.BadZipFile, OSError, RuntimeError):
return _flattened_members(uploaded, for_upload=for_upload)
except (zipfile.BadZipFile, OSError, RuntimeError, EOFError, lzma.LZMAError, zlib.error):
return None
def build_skill_archive(stored_content: bytes) -> SkillArchive | None:
"""Return the installable archive for an upload, or None when it holds no root SKILL.md."""
members: Final = read_skill_archive_members(stored_content)
if members is None:
return None
frontmatter: Final = _manifest_frontmatter(next(data for name, data in members if name == SKILL_MANIFEST_FILENAME))
content: Final = _repack(members)
frontmatter: Final = _manifest_frontmatter(
next(data for name, data, _ in members if name == SKILL_MANIFEST_FILENAME)
)
content: Final = pack_skill_archive(members)
return SkillArchive(
content=content,
digest=f"sha256:{hashlib.sha256(content).hexdigest()}",
@ -59,24 +69,58 @@ def build_skill_archive(stored_content: bytes) -> SkillArchive | None:
)
def _flattened_members(uploaded: zipfile.ZipFile) -> tuple[tuple[str, bytes], ...] | None:
infos: Final = tuple(info for info in uploaded.infolist() if not info.is_dir())
def _flattened_members(uploaded: zipfile.ZipFile, *, for_upload: bool) -> tuple[tuple[str, bytes, int], ...] | None:
infos: Final = tuple(info for info in uploaded.infolist() if for_upload or not info.is_dir())
if not infos or len(infos) > MAX_ARCHIVE_ENTRIES:
return None
if sum(info.file_size for info in infos) > MAX_ARCHIVE_UNPACKED_BYTES:
return None
normalized: Final = tuple((info, _normalized_path(info.filename)) for info in infos)
normalized: Final = tuple(
(info, _normalized_path(info.orig_filename if for_upload else info.filename)) for info in infos
)
if any(path is None for _, path in normalized):
return None
if for_upload and (
len(frozenset(path for _, path in normalized)) != len(normalized)
or any(not _safe_upload_entry(info) for info in infos)
):
return None
prefix: Final = _common_root_prefix(tuple(path for _, path in normalized if path is not None))
flattened: Final = tuple((info, path[len(prefix) :]) for info, path in normalized if path is not None)
kept: Final = tuple(
(info, path)
for info, path in normalized
if path is not None and not info.is_dir() and (not for_upload or not _is_macos_metadata(path))
)
prefix: Final = _common_root_prefix(tuple(path for _, path in kept))
flattened: Final = tuple((info, path[len(prefix) :]) for info, path in kept)
names: Final = frozenset(name for _, name in flattened)
if SKILL_MANIFEST_FILENAME not in names or len(names) != len(flattened):
return None
return tuple((name, uploaded.read(info)) for info, name in sorted(flattened, key=lambda member: member[1]))
return tuple(
(name, uploaded.read(info), _entry_mode(info, preserve_executable_bits=for_upload))
for info, name in sorted(flattened, key=lambda member: member[1])
)
def _entry_mode(info: zipfile.ZipInfo, *, preserve_executable_bits: bool) -> int:
if not preserve_executable_bits or info.create_system != 3:
return _ZIP_ENTRY_MODE
mode: Final = info.external_attr >> 16
if stat.S_IFMT(mode) not in (0, stat.S_IFREG):
return _ZIP_ENTRY_MODE
return _ZIP_ENTRY_MODE | (mode & 0o111)
def _safe_upload_entry(info: zipfile.ZipInfo) -> bool:
return not info.flag_bits & 1 and (
info.create_system != 3 or stat.S_IFMT(info.external_attr >> 16) in (0, stat.S_IFREG, stat.S_IFDIR)
)
def _is_macos_metadata(path: str) -> bool:
return path.split("/", 1)[0] == "__MACOSX" or path.rsplit("/", 1)[-1] == ".DS_Store"
def _common_root_prefix(paths: tuple[str, ...]) -> str:
@ -117,16 +161,16 @@ def _frontmatter_text(frontmatter: Mapping[str, object], key: str) -> str | None
return value.strip() or None
def _zip_entry(name: str) -> zipfile.ZipInfo:
def _zip_entry(name: str, mode: int) -> zipfile.ZipInfo:
entry: Final = zipfile.ZipInfo(filename=name, date_time=_ZIP_ENTRY_TIMESTAMP)
entry.compress_type = zipfile.ZIP_DEFLATED
entry.external_attr = _ZIP_ENTRY_PERMISSIONS
entry.create_system = 3
entry.external_attr = mode << 16
return entry
def _repack(members: tuple[tuple[str, bytes], ...]) -> bytes:
def pack_skill_archive(members: tuple[tuple[str, bytes, int], ...], compression: int = zipfile.ZIP_DEFLATED) -> bytes:
buffer: Final = io.BytesIO()
with zipfile.ZipFile(buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as repacked:
for name, data in members:
repacked.writestr(_zip_entry(name), data)
with zipfile.ZipFile(buffer, mode="w") as repacked:
for name, data, mode in members:
repacked.writestr(_zip_entry(name, mode), data, compress_type=compression)
return buffer.getvalue()

View file

@ -20,7 +20,11 @@ from litellm.constants import (
)
from litellm.proxy.auth.auth_utils import get_request_route
from litellm.proxy.common_utils.anthropic_error_payload import proxy_error_type, route_error_content
from litellm.proxy.middleware.request_size_limit_middleware import MaxRequestSizeGetter, send_request_too_large
from litellm.proxy.middleware.request_size_limit_middleware import (
MaxRequestSizeGetter,
effective_max_request_size_mb,
send_request_too_large,
)
RFC_9659_ZSTD_WINDOW_LOG_MAX: Final = 23
GZIP_MAGIC: Final = b"\x1f\x8b"
@ -337,6 +341,7 @@ class RequestBodyDecompressionMiddleware:
max_decompression_ratio: int = MAX_REQUEST_BODY_DECOMPRESSION_RATIO,
max_decoded_bodies_in_flight_mb: float = MAX_DECOMPRESSED_REQUEST_BODIES_IN_FLIGHT_MB,
codecs: Mapping[str, Codec] = AVAILABLE_CODECS,
route_max_request_size_mb: Mapping[tuple[str, str], float] | None = None,
) -> None:
self.app = app
self.get_max_request_size_mb: Final = get_max_request_size_mb
@ -344,6 +349,7 @@ class RequestBodyDecompressionMiddleware:
self.max_decompression_ratio: Final = max_decompression_ratio
self.budget: Final = _DecodedBodyBudget(int(max_decoded_bodies_in_flight_mb * 1024 * 1024))
self.codecs: Final = codecs
self.route_max_request_size_mb: Final = MappingProxyType(dict(route_max_request_size_mb or {}))
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
coding: Final = _coding_for(scope)
@ -378,7 +384,10 @@ class RequestBodyDecompressionMiddleware:
async def _decode_then_call(
self, scope: Scope, receive: Receive, send: Send, codec: Codec, prefix: _Prefix
) -> None:
max_mb: Final = _effective_max_mb(self.max_decompressed_body_size_mb, self.get_max_request_size_mb())
request_max_mb: Final = effective_max_request_size_mb(
scope, self.get_max_request_size_mb(), self.route_max_request_size_mb
)
max_mb: Final = _effective_max_mb(self.max_decompressed_body_size_mb, request_max_mb)
limits: Final = _Limits(max_mb=max_mb, max_ratio=self.max_decompression_ratio)
match await _receive_body(scope, receive, prefix, limits.max_bytes):
case _Body(data=data):

View file

@ -1,7 +1,10 @@
import json
from collections.abc import Callable
from collections.abc import Callable, Mapping
from types import MappingProxyType
from typing import Final
from fastapi import HTTPException
from starlette._utils import get_route_path
from starlette.types import ASGIApp, Message, Receive, Scope, Send
from litellm.proxy.common_utils.anthropic_error_payload import route_error_content
@ -10,8 +13,22 @@ MaxRequestSizeGetter = Callable[[], int | float | None]
RequestSizeLimitEnabledGetter = Callable[[], bool]
class RequestEntityTooLarge(Exception):
pass
class RequestEntityTooLarge(HTTPException):
def __init__(self, max_request_size_mb: float) -> None:
super().__init__(status_code=413, detail=f"Request size is too large. Max size is {max_request_size_mb} MB")
def effective_max_request_size_mb(
scope: Scope,
configured: float | None,
route_limits: Mapping[tuple[str, str], float],
) -> float | None:
global_limit: Final = configured if configured is not None and configured > 0 else None
method: Final = scope.get("method")
route_limit: Final = route_limits.get((method, get_route_path(scope))) if isinstance(method, str) else None
if route_limit is None:
return global_limit
return min(route_limit, global_limit) if global_limit is not None else route_limit
class RequestSizeLimitMiddleware:
@ -28,19 +45,29 @@ class RequestSizeLimitMiddleware:
app: ASGIApp,
get_max_request_size_mb: MaxRequestSizeGetter,
is_request_size_limit_enabled: RequestSizeLimitEnabledGetter,
route_max_request_size_mb: Mapping[tuple[str, str], float] | None = None,
) -> None:
self.app = app
self.get_max_request_size_mb = get_max_request_size_mb
self.is_request_size_limit_enabled = is_request_size_limit_enabled
self.route_max_request_size_mb = MappingProxyType(dict(route_max_request_size_mb or {}))
def _max_request_size_mb(self, scope: Scope) -> float | None:
configured: Final = self.get_max_request_size_mb()
return effective_max_request_size_mb(
scope,
configured if self.is_request_size_limit_enabled() else None,
self.route_max_request_size_mb,
)
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
max_request_size_mb: Final = self.get_max_request_size_mb()
max_request_size_mb: Final = self._max_request_size_mb(scope)
max_request_size_bytes: Final = _mb_to_bytes(max_request_size_mb)
if max_request_size_bytes is None or not self.is_request_size_limit_enabled():
if max_request_size_bytes is None or max_request_size_mb is None:
await self.app(scope, receive, send)
return
@ -61,7 +88,7 @@ class RequestSizeLimitMiddleware:
received_body_bytes += len(message.get("body", b""))
if received_body_bytes > max_request_size_bytes:
raise RequestEntityTooLarge
raise RequestEntityTooLarge(max_request_size_mb)
return message
async def tracking_send(message: Message) -> None:

View file

@ -2931,9 +2931,15 @@ def configured_max_request_size_mb() -> int | float | None:
return MAX_REQUEST_SIZE_MB_SETTING.validate_python(general_settings.get("max_request_size_mb"))
_ROUTE_MAX_REQUEST_SIZE_MB: Final[Mapping[tuple[str, str], float]] = MappingProxyType(
{("POST", "/claude-code/plugins/upload"): 11}
)
app.add_middleware(
RequestBodyDecompressionMiddleware,
get_max_request_size_mb=lambda: configured_max_request_size_mb() if premium_user is True else None,
route_max_request_size_mb=_ROUTE_MAX_REQUEST_SIZE_MB,
)
app.add_middleware(
CORSMiddleware,
@ -22420,6 +22426,7 @@ app.add_middleware(
RequestSizeLimitMiddleware,
get_max_request_size_mb=configured_max_request_size_mb,
is_request_size_limit_enabled=lambda: premium_user is True,
route_max_request_size_mb=_ROUTE_MAX_REQUEST_SIZE_MB,
)
app.add_middleware(
AdmissionControlMiddleware,

View file

@ -1615,9 +1615,18 @@ model LiteLLM_ClaudeCodePluginTable {
updated_at DateTime? @default(now()) @updatedAt
created_by String?
uploaded_archive LiteLLM_ClaudeCodePluginUpload?
@@map("LiteLLM_ClaudeCodePluginTable")
}
model LiteLLM_ClaudeCodePluginUpload {
plugin_name String @id
content Bytes
plugin LiteLLM_ClaudeCodePluginTable @relation(fields: [plugin_name], references: [name], onDelete: Cascade, onUpdate: Cascade)
}
// User/team-scoped memory store with a GLOBAL unique key.
// `value` is a string (typically markdown/text meant for LLM context);
// `metadata` is an optional JSON envelope for structured tags without schema changes.

View file

@ -0,0 +1,71 @@
import json
from types import MappingProxyType
from typing import TYPE_CHECKING, Final, cast # noqa: TID251 # Prisma dynamically exposes its typed table actions
from pydantic import TypeAdapter
from litellm.models.plugin_upload import PluginArchiveMetadata, PluginUpload
from litellm.repositories.prisma_protocols import TableActions
from litellm.repositories.table_repositories import ClaudeCodePluginRepository
if TYPE_CHECKING:
from prisma import models as prisma_models
from litellm.proxy.utils import PrismaClient
_MANIFEST: Final = TypeAdapter(dict[str, object])
_SOURCE: Final = TypeAdapter(dict[str, str])
class PluginUploadRepository:
def __init__(self, prisma_client: "PrismaClient") -> None:
self._plugins = ClaudeCodePluginRepository(prisma_client, use_writer=True).table
self._archives = cast( # cast-ok: Prisma exposes generated table actions through an untyped delegate
TableActions["prisma_models.LiteLLM_ClaudeCodePluginUpload"],
prisma_client.writer_db.litellm_claudecodepluginupload,
)
async def create(self, upload: PluginUpload) -> str | None:
from prisma import Base64
from prisma.errors import UniqueViolationError
try:
plugin: Final = await self._plugins.create(
data={
"name": upload.name,
"version": upload.version,
"description": upload.description,
"manifest_json": json.dumps(dict(upload.manifest)),
"created_by": upload.created_by,
"uploaded_archive": {"create": {"content": Base64.encode(upload.content)}},
}
)
except UniqueViolationError:
return None
return plugin.id
async def get_metadata(self, name: str) -> PluginArchiveMetadata | None:
plugin: Final = await self._plugins.find_unique(where={"name": name})
if plugin is None:
return None
manifest: Final = _MANIFEST.validate_json(plugin.manifest_json or "{}")
return PluginArchiveMetadata(
name=plugin.name,
enabled=plugin.enabled,
source=MappingProxyType(_SOURCE.validate_python(manifest.get("source", {}))),
manifest_json=plugin.manifest_json,
)
async def get_content(self, metadata: PluginArchiveMetadata) -> bytes | None:
archive: Final = await self._archives.find_first(
where={
"plugin_name": metadata.name,
"plugin": {
"is": {
"manifest_json": metadata.manifest_json,
"enabled": metadata.enabled,
}
},
}
)
return None if archive is None else archive.content.decode()

View file

@ -4,7 +4,7 @@ Claude Code Marketplace endpoint types for LiteLLM Proxy
from typing import Final, Literal, TypeAlias
from pydantic import AliasChoices, Field, TypeAdapter, model_validator
from pydantic import AliasChoices, ConfigDict, Field, TypeAdapter, model_validator
from litellm.types.llms.base import LiteLLMBaseModel
@ -27,20 +27,11 @@ class PluginOwner(LiteLLMBaseModel):
email: str | None = Field(None, description="Owner email")
class PluginSpec(LiteLLMBaseModel):
class PluginMetadata(LiteLLMBaseModel):
"""Mutable fields shared by plugin create and update requests."""
source: dict[str, str] = Field(
...,
description=(
"Plugin source reference. Supported formats:\n"
"- GitHub: {'source': 'github', 'repo': 'org/repo'}\n"
"- Git URL: {'source': 'url', 'url': 'https://github.com/org/repo.git'}\n"
"- Git Subdir: {'source': 'git-subdir', 'url': 'https://github.com/org/repo.git', 'path': 'plugins/plugin-name'}\n"
"- Zip archive on any https host (e.g. S3): "
"{'source': 'archive', 'url': 'https://bucket.s3.amazonaws.com/plugin.zip', 'sha256': '<optional hex digest>'}"
),
)
model_config = ConfigDict(frozen=True)
version: str | None = Field("1.0.0", description="Semantic version")
description: str | None = Field(None, description="Plugin description")
author: PluginAuthor | None = Field(None, description="Plugin author")
@ -75,6 +66,20 @@ class PluginSpec(LiteLLMBaseModel):
}
class PluginSpec(PluginMetadata):
source: dict[str, str] = Field(
...,
description=(
"Plugin source reference. Supported formats:\n"
"- GitHub: {'source': 'github', 'repo': 'org/repo'}\n"
"- Git URL: {'source': 'url', 'url': 'https://github.com/org/repo.git'}\n"
"- Git Subdir: {'source': 'git-subdir', 'url': 'https://github.com/org/repo.git', 'path': 'plugins/plugin-name'}\n"
"- Zip archive on any https host (e.g. S3): "
"{'source': 'archive', 'url': 'https://bucket.s3.amazonaws.com/plugin.zip', 'sha256': '<optional hex digest>'}"
),
)
class RegisterPluginRequest(PluginSpec):
"""
Request body for registering a plugin in the marketplace.
@ -90,6 +95,10 @@ class RegisterPluginRequest(PluginSpec):
)
class UploadPluginRequest(PluginMetadata):
name: str = Field(..., pattern=r"^[a-z0-9-]+$", description="Skill name (kebab-case)")
class UpdatePluginRequest(PluginSpec):
"""
Request body for replacing an existing plugin.

View file

@ -1615,9 +1615,18 @@ model LiteLLM_ClaudeCodePluginTable {
updated_at DateTime? @default(now()) @updatedAt
created_by String?
uploaded_archive LiteLLM_ClaudeCodePluginUpload?
@@map("LiteLLM_ClaudeCodePluginTable")
}
model LiteLLM_ClaudeCodePluginUpload {
plugin_name String @id
content Bytes
plugin LiteLLM_ClaudeCodePluginTable @relation(fields: [plugin_name], references: [name], onDelete: Cascade, onUpdate: Cascade)
}
// User/team-scoped memory store with a GLOBAL unique key.
// `value` is a string (typically markdown/text meant for LLM context);
// `metadata` is an optional JSON envelope for structured tags without schema changes.

View file

@ -18,7 +18,7 @@ from collections.abc import Iterator, Mapping
from typing import Final, NamedTuple
GATED_METHODS: Final = frozenset({"post"})
SCHEMA_MEDIA_TYPES: Final = ("application/json", "application/sdp")
SCHEMA_MEDIA_TYPES: Final = ("application/json", "application/sdp", "multipart/form-data")
class Violation(NamedTuple):

View file

@ -4,15 +4,20 @@ 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 hashlib
import io
import json
from collections.abc import Mapping
import zipfile
from collections.abc import AsyncIterator, Mapping
from types import MappingProxyType
from typing import Final
from unittest.mock import AsyncMock, MagicMock
from typing import Final, TypeAlias
from unittest.mock import AsyncMock, MagicMock, call
import httpx
import pytest
from fastapi import HTTPException
from prisma import Base64, models as prisma_models
from prisma.errors import UniqueViolationError
from pydantic import ValidationError
import litellm
@ -29,7 +34,9 @@ from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketp
register_plugin,
update_plugin,
)
from litellm.proxy.deps.repositories import get_plugin_upload_repository
from litellm.proxy.proxy_server import LitellmUserRoles, hash_token
from litellm.repositories.plugin_upload_repository import PluginUploadRepository
from litellm.types.proxy.claude_code_endpoints import (
MarketplaceResponse,
RegisterPluginRequest,
@ -83,6 +90,7 @@ def _make_mock_prisma():
mock_table.create = AsyncMock(side_effect=_create)
mock_table.update = AsyncMock(side_effect=_update)
mock_client.db.litellm_claudecodeplugintable = mock_table
mock_client.writer_db = mock_client.db
return mock_client
@ -196,6 +204,25 @@ async def test_update_plugin_replaces_existing_source():
assert stored["version"] == "2.0.0"
@pytest.mark.asyncio
async def test_metadata_edits_preserve_uploaded_source_and_reject_a_changed_http_destination() -> None:
table: Final = litellm.proxy.proxy_server.prisma_client.writer_db.litellm_claudecodeplugintable
await table.create(data={"name": _UPLOAD_NAME, "manifest_json": json.dumps({"source": _UPLOAD_SOURCE})})
request: Final = UpdatePluginRequest(source=_UPLOAD_SOURCE, description="Updated description")
response: Final = await update_plugin(plugin_name=_UPLOAD_NAME, request=request, user_api_key_dict=_USER)
expected: Final = {"name": _UPLOAD_NAME, "source": _UPLOAD_SOURCE, "description": "Updated description"}
assert response.plugin.description == "Updated description"
assert await _read_stored_manifest(_UPLOAD_NAME) == expected
with pytest.raises(HTTPException) as error:
await update_plugin(
plugin_name=_UPLOAD_NAME, user_api_key_dict=_USER,
request=UpdatePluginRequest(source={**_UPLOAD_SOURCE, "url": "http://other.example/skill.zip"}),
)
assert error.value.status_code == 400
table.update.assert_awaited_once()
assert await _read_stored_manifest(_UPLOAD_NAME) == expected
@pytest.mark.asyncio
async def test_update_plugin_not_found():
"""PUT on a name that does not exist raises HTTP 404."""
@ -1171,3 +1198,164 @@ async def test_marketplace_without_a_database_answers_db_not_connected_for_a_que
assert response.status_code == 500
assert CommonProxyErrors.db_not_connected_error.value in response.text
_UploadClient: TypeAlias = tuple[httpx.AsyncClient, MagicMock, MagicMock]
_UPLOAD_NAME: Final = "local-skill"
_UPLOAD_DIGEST: Final = hashlib.sha256(b"stored archive").hexdigest()
_UPLOAD_PATH: Final = f"/claude-code/plugins/{_UPLOAD_NAME}/archive/{_UPLOAD_DIGEST}.zip"
_UPLOAD_SOURCE: Final = {"source": "archive", "url": f"http://test{_UPLOAD_PATH}", "sha256": _UPLOAD_DIGEST}
@pytest.fixture
async def upload_storage(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[_UploadClient]:
prisma: Final = litellm.proxy.proxy_server.prisma_client
prisma.writer_db = MagicMock()
plugins: Final = prisma.writer_db.litellm_claudecodeplugintable
archives: Final = prisma.writer_db.litellm_claudecodepluginupload
plugins.create = AsyncMock(return_value=prisma_models.LiteLLM_ClaudeCodePluginTable(
id="uploaded-id", name=_UPLOAD_NAME, enabled=True,
))
plugins.find_unique = AsyncMock(return_value=prisma_models.LiteLLM_ClaudeCodePluginTable(
id="uploaded-id", name=_UPLOAD_NAME, enabled=True, manifest_json=json.dumps({"source": _UPLOAD_SOURCE}),
))
archives.find_first = AsyncMock(return_value=prisma_models.LiteLLM_ClaudeCodePluginUpload(
plugin_name=_UPLOAD_NAME, content=Base64.encode(b"stored archive"),
))
monkeypatch.setattr(litellm.proxy.proxy_server.app, "dependency_overrides", {
get_plugin_upload_repository: lambda: PluginUploadRepository(prisma),
})
monkeypatch.setattr(litellm.proxy.proxy_server, "general_settings", {})
monkeypatch.setattr(litellm.proxy.proxy_server, "user_api_key_cache", DualCache())
monkeypatch.setenv("PROXY_BASE_URL", "http://test")
monkeypatch.delenv("SERVER_ROOT_PATH", raising=False)
for key, skills in ((_LIMITED_KEY, [_UPLOAD_NAME]), ("sk-plain", [])):
litellm.proxy.proxy_server.user_api_key_cache.set_cache(
key=hash_token(key), value=_granted_user(skills).model_copy(update={"token": hash_token(key)}),
)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=litellm.proxy.proxy_server.app), base_url="http://test",
) as client:
yield client, plugins, archives
async def _post_skill_upload(
client: httpx.AsyncClient, content: bytes, metadata: str, key: str = MASTER_KEY,
) -> httpx.Response:
return await client.post(
"/claude-code/plugins/upload", headers={"Authorization": f"Bearer {key}"},
data={"metadata": metadata}, files={"file": ("local.zip", content, "application/zip")},
)
@pytest.mark.asyncio
async def test_skill_upload_creates_metadata_and_bytes_atomically_and_rejects_duplicates(
upload_storage: _UploadClient,
) -> None:
client, plugins, _ = upload_storage
buffer: Final = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as archive:
archive.writestr("SKILL.md", "Read the personal instructions")
metadata: Final = {"name": _UPLOAD_NAME, "description": "My notes", "namespace": "personal", "version": "2.0.0"}
response: Final = await _post_skill_upload(client, buffer.getvalue(), json.dumps(metadata))
assert response.status_code == 200, response.text
plugins.create.assert_awaited_once()
stored: Final = plugins.create.await_args.kwargs["data"]
content: Final = stored["uploaded_archive"]["create"]["content"].decode()
digest: Final = hashlib.sha256(content).hexdigest()
source: Final = {
"source": "archive",
"url": f"http://test/claude-code/plugins/{_UPLOAD_NAME}/archive/{digest}.zip",
"sha256": digest,
}
assert json.loads(stored["manifest_json"]) == {**metadata, "source": source}
assert stored == {
"name": _UPLOAD_NAME, "description": "My notes", "version": "2.0.0",
"manifest_json": stored["manifest_json"], "created_by": litellm.proxy.proxy_server.litellm_proxy_admin_name,
"uploaded_archive": {"create": {"content": Base64.encode(content)}},
}
assert response.json() == {"status": "success", "action": "created", "plugin": {
"id": "uploaded-id", "name": _UPLOAD_NAME, "version": "2.0.0", "description": "My notes",
"source": source, "installation_preference": None, "enabled": True,
}}
plugins.create.side_effect = UniqueViolationError({"message": "duplicate", "error_code": "P2002"})
duplicate: Final = await _post_skill_upload(client, buffer.getvalue(), json.dumps(metadata))
assert duplicate.status_code == 409, duplicate.text
@pytest.mark.asyncio
@pytest.mark.parametrize("content, metadata, key, status", [
(b"invalid", '{"name":"local-skill"}', MASTER_KEY, 400),
(b"x" * (10 * 1024 * 1024 + 1), '{"name":"local-skill"}', MASTER_KEY, 413),
(b"ignored", "not-json", MASTER_KEY, 400),
(b"ignored", '{"name":"../invalid"}', MASTER_KEY, 422),
(b"ignored", '{"name":"local-skill"}', _LIMITED_KEY, 403),
], ids=["invalid-zip", "file-over-cap", "invalid-metadata", "invalid-name", "non-admin"])
async def test_skill_upload_rejects_invalid_or_unauthorized_input_without_writing(
upload_storage: _UploadClient, content: bytes, metadata: str, key: str, status: int,
) -> None:
client, plugins, _ = upload_storage
response: Final = await _post_skill_upload(client, content, metadata, key)
assert response.status_code == status, response.text
plugins.create.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize("enabled, key, source, blob, status, reads", [
(True, None, _UPLOAD_SOURCE, True, 200, 1),
(False, None, _UPLOAD_SOURCE, True, 404, 0),
(False, "sk-plain", _UPLOAD_SOURCE, True, 404, 0),
(False, _LIMITED_KEY, _UPLOAD_SOURCE, True, 200, 1),
(True, None, None, True, 404, 0),
(True, None, {**_UPLOAD_SOURCE, "sha256": "0" * 64}, True, 404, 0),
(True, None, {**_UPLOAD_SOURCE, "url": "https://external.example/skill.zip"}, True, 404, 0),
(True, None, _UPLOAD_SOURCE, False, 404, 1),
])
async def test_archive_reads_only_authorized_current_bytes(
upload_storage: _UploadClient, enabled: bool, key: str | None,
source: Mapping[str, str] | None, blob: bool, status: int, reads: int,
) -> None:
client, plugins, archives = upload_storage
manifest: Final = json.dumps({"source": source})
plugins.find_unique.return_value = None if source is None else prisma_models.LiteLLM_ClaudeCodePluginTable(
id="uploaded-id", name=_UPLOAD_NAME, enabled=enabled, manifest_json=manifest,
)
if not blob:
archives.find_first.return_value = None
response: Final = await client.get(_UPLOAD_PATH, headers={"Authorization": f"Bearer {key}"} if key else {})
assert response.status_code == status, response.text
assert (response.content == b"stored archive") is (status == 200)
plugins.find_unique.assert_awaited_once_with(where={"name": _UPLOAD_NAME})
assert archives.find_first.await_args_list == ([call(where={
"plugin_name": _UPLOAD_NAME, "plugin": {"is": {"manifest_json": manifest, "enabled": enabled}},
})] if reads else [])
litellm.proxy.proxy_server.prisma_client.db.litellm_claudecodeplugintable.find_unique.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize("required, header, credential, query, status", [
(True, "Authorization", _LIMITED_KEY, None, 200),
(False, "x-skill-key", _LIMITED_KEY, MASTER_KEY, 200),
(True, "x-skill-key", _LIMITED_KEY, MASTER_KEY, 200),
(False, "x-skill-key", "", MASTER_KEY, 401),
(False, None, None, MASTER_KEY, 404),
(False, None, None, "invalid-query-key", 404),
(True, None, None, MASTER_KEY, 401),
(True, None, None, None, 401),
])
async def test_archive_requires_header_credentials_for_private_content(
upload_storage: _UploadClient, monkeypatch: pytest.MonkeyPatch, required: bool,
header: str | None, credential: str | None, query: str | None, status: int,
) -> None:
client, plugins, archives = upload_storage
plugins.find_unique.return_value.enabled = False
monkeypatch.setattr(litellm.proxy.proxy_server, "general_settings", {
"litellm_key_header_name": "X-Skill-Key", "claude_code_marketplace_auth_required": required,
})
response: Final = await client.get(
_UPLOAD_PATH, headers={header: f"Bearer {credential}" if credential else ""} if header else {},
params={"key": query} if query else {},
)
assert response.status_code == status, response.text
assert (response.content == b"stored archive") is (status == 200)
assert archives.find_first.await_count == int(status == 200)

View file

@ -0,0 +1,92 @@
import io
import json
import stat
import zipfile
from typing import Final
import pytest
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.skill_upload_archive import (
MAX_SKILL_ARCHIVE_COMPRESSION_RATIO,
MAX_SKILL_UPLOAD_BYTES,
build_uploaded_skill_archive,
)
from litellm.proxy.discovery_endpoints.agent_skills_archive import MAX_ARCHIVE_ENTRIES, MAX_ARCHIVE_UNPACKED_BYTES
MANIFEST: Final = b"---\nname: skill\ndescription: Notes\n---\nRead guide.md"
def _zip(files: tuple[tuple[str | zipfile.ZipInfo, bytes], ...], compression: int = zipfile.ZIP_DEFLATED) -> bytes:
buffer: Final = io.BytesIO()
with zipfile.ZipFile(buffer, "w", compression=compression) as archive:
for name, content in files:
archive.writestr(name, content)
return buffer.getvalue()
def _entry(name: str, mode: int, system: int = 3) -> zipfile.ZipInfo:
entry: Final = zipfile.ZipInfo(name)
entry.create_system = system
entry.external_attr = mode << 16
return entry
@pytest.mark.parametrize("prefix, manifest", [
("", MANIFEST), ("folder/", MANIFEST),
("folder/", b"---\nnotes: " + b"[" * 1000 + b"0" + b"]" * 1000 + b"\n---\nRead guide.md"),
])
@pytest.mark.parametrize("script, compression", [
(b"#!/bin/sh\necho ready\n", zipfile.ZIP_DEFLATED), (b"echo ready\n" * 10000, zipfile.ZIP_STORED),
])
def test_installable_archive_preserves_files_and_safe_modes(
prefix: str, manifest: bytes, script: bytes, compression: int,
) -> None:
files: Final = (
("SKILL.md", manifest, 0o644, 3, 0o644),
("guide.md", b"Keep filenames unchanged", 0o666, 3, 0o644),
("scripts/run.sh", script, stat.S_IFREG | 0o7777, 3, 0o755),
("scripts/owner.sh", script, stat.S_IFREG | 0o700, 3, 0o744),
("scripts/no-type.sh", script, 0o755, 3, 0o755),
("scripts/dos.sh", script, stat.S_IFREG | 0o7777, 0, 0o644),
("scripts/dos-link.sh", script, stat.S_IFLNK | 0o777, 0, 0o644),
)
upload: Final = _zip(tuple(
(_entry(prefix + name, mode, system), data) for name, data, mode, system, _ in files
) + ((prefix + ".DS_Store", b"Finder"), ("__MACOSX/._SKILL.md", b"Finder")))
result: Final = build_uploaded_skill_archive(upload, "registered-name")
expected: Final = {".claude-plugin/plugin.json": (json.dumps({"name": "registered-name"}).encode(), 0o644)} | {
f"skills/registered-name/{name}": (data, mode) for name, data, _, _, mode in files
}
assert result is not None
with zipfile.ZipFile(io.BytesIO(result)) as archive:
assert {entry.filename: (archive.read(entry), entry.external_attr >> 16) for entry in archive.infolist()} == expected
assert {entry.create_system for entry in archive.infolist()} == {3}
assert {entry.compress_type for entry in archive.infolist()} == {compression}
assert sum(entry.file_size for entry in archive.infolist()) <= len(result) * MAX_SKILL_ARCHIVE_COMPRESSION_RATIO
assert build_uploaded_skill_archive(upload, "registered-name") == result
_VALID: Final = _zip((("SKILL.md", MANIFEST),))
_ENCRYPTED: Final = _zip((("__MACOSX/encrypted", b"secret"), ("SKILL.md", MANIFEST)))
_CENTRAL: Final = _ENCRYPTED.index(b"PK\x01\x02")
@pytest.mark.parametrize("content, name", [
(b"not a zip", "skill"),
(_zip((("SKILL.md", MANIFEST),), zipfile.ZIP_STORED).replace(b"Read guide.md", b"Dead guide.md"), "skill"),
(_ENCRYPTED[:_CENTRAL + 8] + b"\x01\x00" + _ENCRYPTED[_CENTRAL + 10:], "skill"),
*[(_VALID, name) for name in ("../escape", "", "Not a slug", "x" * 65536)],
*[(_zip(((path, MANIFEST),)), "skill") for path in ("readme.md", "wrapper/nested/SKILL.md", "skills/example/SKILL.md")],
*[(_zip((("SKILL.md", MANIFEST), (path, b"unsafe"))), "skill") for path in (
"../escape", "/absolute", "C:/escape", "a\\escape", "__MACOSX/../escape", "bad/./file",
)],
*[(_zip((("SKILL.md", MANIFEST), (_entry(path, stat.S_IFLNK | 0o777), b"../../outside"))), "skill")
for path in ("__MACOSX/link", "scripts/link")],
(_zip(((".claude-plugin/plugin.json", b'{"name":"plugin"}'), ("skills/example/SKILL.md", MANIFEST))), "skill"),
(_zip((("SKILL.md", MANIFEST), ("__MACOSX/a", b"one"), ("__MACOSX//a", b"two"))), "skill"),
(_zip((("SKILL.md", MANIFEST), ("asset.bin", bytes(MAX_SKILL_UPLOAD_BYTES))), zipfile.ZIP_STORED), "skill"),
(_zip((("SKILL.md", MANIFEST), ("__MACOSX/large", bytes(MAX_ARCHIVE_UNPACKED_BYTES)))), "skill"),
(_zip((("SKILL.md", MANIFEST),) + tuple((f"__MACOSX/{i}", b"") for i in range(MAX_ARCHIVE_ENTRIES))), "skill"),
])
def test_rejects_unsafe_invalid_or_oversized_archives(content: bytes, name: str) -> None:
assert build_uploaded_skill_archive(content, name) is None

View file

@ -65,6 +65,24 @@ def _starlette_request(
return Request(scope, receive)
@pytest.mark.asyncio
@pytest.mark.parametrize("path, path_params", [
("/claude-code/plugins/skill/archive/digest.zip", {"plugin_name": "skill"}),
("/vector_stores/vs-safe/files", {}),
])
async def test_request_vector_store_values_cannot_forge_debug_log_lines(
path: str, path_params: Mapping[str, str], caplog: pytest.LogCaptureFixture,
) -> None:
untrusted: Final = "private-value\r\nFORGED LOG LINE"
request: Final = _starlette_request(b"{}", "application/json", path)
request.scope["path_params"] = dict(path_params)
with caplog.at_level("DEBUG", logger=http_parsing_utils.verbose_proxy_logger.name):
result: Final = populate_request_with_path_params({"vector_store_ids": [untrusted]}, request)
assert untrusted in result["vector_store_ids"]
assert "private-value" not in caplog.text
assert "FORGED LOG LINE" not in caplog.text
@pytest.mark.asyncio
@pytest.mark.parametrize(
"body",
@ -377,7 +395,7 @@ async def test_form_data_with_json_metadata():
@pytest.mark.asyncio
async def test_form_data_with_invalid_json_metadata():
async def test_form_data_with_invalid_json_metadata() -> None:
"""
Test that form data with invalid JSON in metadata field raises an exception.
@ -398,9 +416,10 @@ async def test_form_data_with_invalid_json_metadata():
mock_request.scope = {}
mock_request.state._cached_headers = None
# Should raise JSONDecodeError when trying to parse invalid JSON metadata
with pytest.raises(json.JSONDecodeError):
with pytest.raises(ProxyException) as exc_info:
await read_request_body(mock_request)
assert exc_info.value.code == "400"
assert exc_info.value.param == "metadata"
@pytest.mark.asyncio

View file

@ -1,6 +1,8 @@
import hashlib
import io
import stat
import zipfile
from typing import Final
from litellm.proxy.discovery_endpoints.agent_skills_archive import (
MAX_ARCHIVE_ENTRIES,
@ -16,7 +18,7 @@ Read the PDF, then write the brief.
"""
def zip_bytes(files: dict[str, bytes]) -> bytes:
def zip_bytes(files: dict[str | zipfile.ZipInfo, bytes]) -> bytes:
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
for name, content in files.items():
@ -104,3 +106,15 @@ def test_an_upload_over_the_entry_cap_is_not_installable():
}
assert build_skill_archive(zip_bytes(files)) is None
def test_discovery_keeps_its_permission_and_finder_metadata_policy() -> None:
executable: Final = zipfile.ZipInfo("scripts/run.sh")
executable.create_system = 3
executable.external_attr = (stat.S_IFREG | 0o7777) << 16
result: Final = build_skill_archive(zip_bytes({"SKILL.md": MANIFEST, executable: b"echo ready\n"}))
assert build_skill_archive(zip_bytes({"folder/SKILL.md": MANIFEST, "__MACOSX/._SKILL.md": b"Finder"})) is None
assert result is not None
with zipfile.ZipFile(io.BytesIO(result.content)) as archive:
assert {entry.create_system for entry in archive.infolist()} == {3}
assert {entry.external_attr >> 16 for entry in archive.infolist()} == {0o644}

View file

@ -54,10 +54,11 @@ async def _echo_request(request: Request) -> Response:
)
def _client(max_decompressed_body_size_mb: float = CAP_MB, **options: object) -> TestClient:
def _client(max_decompressed_body_size_mb: float = CAP_MB, root_path: str = "", **options: object) -> TestClient:
echo_app: Final = Starlette(
routes=[
Route("/v1/systemone", _echo_request, methods=["POST"]),
Route("/claude-code/plugins/upload", _echo_request, methods=["POST"]),
Route("/plugin-proxy/{path:path}", _echo_request, methods=["POST"]),
Route("/claude_code_gateway/v1/{signal}", _echo_request, methods=["POST"]),
]
@ -67,7 +68,8 @@ def _client(max_decompressed_body_size_mb: float = CAP_MB, **options: object) ->
echo_app,
max_decompressed_body_size_mb=max_decompressed_body_size_mb,
**options, # pyright: ignore[reportArgumentType] # each test passes one typed keyword
)
),
root_path=root_path,
)
@ -220,19 +222,51 @@ def test_body_decoding_past_the_cap_is_rejected():
@pytest.mark.parametrize(
("operator_max_mb", "effective_max_mb"),
[(0.25, 0.25), (CAP_MB * 4, CAP_MB), (None, CAP_MB), (0, CAP_MB)],
[(0.25, 0.25), (CAP_MB * 4, CAP_MB), (None, CAP_MB), (0, CAP_MB), (1 / 64, 1 / 64)],
)
@pytest.mark.parametrize(("route_max_mb", "root_path"), [(None, ""), (1 / 32, ""), (1 / 32, "/gateway")])
@pytest.mark.parametrize("coding", ("gzip", "zstd"))
def test_operator_request_size_limit_caps_the_decoded_body_when_it_is_lower(
operator_max_mb: float | None, effective_max_mb: float
operator_max_mb: float | None, effective_max_mb: float, route_max_mb: float | None, root_path: str, coding: str,
):
client: Final = _client(get_max_request_size_mb=lambda: operator_max_mb)
within: Final = zstd.compress(_low_entropy_text(int(effective_max_mb * MIB)))
over: Final = zstd.compress(_low_entropy_text(int(effective_max_mb * MIB) + 1))
assert _post(client, within, ("zstd",)).status_code == 200
over_response: Final = _post(client, over, ("zstd",))
path: Final = "/claude-code/plugins/upload" if route_max_mb else "/v1/systemone"
limit: Final = min(effective_max_mb, route_max_mb or effective_max_mb)
client: Final = _client(
root_path=root_path,
get_max_request_size_mb=lambda: operator_max_mb,
route_max_request_size_mb={("POST", path): route_max_mb} if route_max_mb else None,
)
compress: Final = gzip.compress if coding == "gzip" else zstd.compress
within: Final = compress(_low_entropy_text(int(limit * MIB)))
over: Final = compress(_low_entropy_text(int(limit * MIB) + 1))
assert _post(client, within, (coding,), f"{root_path}{path}").status_code == 200
over_response: Final = _post(client, over, (coding,), f"{root_path}{path}")
assert over_response.status_code == 413
assert over_response.json() == _decoded_too_large(effective_max_mb)
assert over_response.json() == _decoded_too_large(limit)
@pytest.mark.parametrize(("coding", "compress"), [
("identity", bytes), ("gzip", gzip.compress), ("zstd", zstd.compress),
])
def test_proxy_bounds_skill_uploads_before_authentication_or_body_parsing(
coding: str, compress: Callable[[bytes], bytes],
) -> None:
from litellm.proxy.proxy_server import app
body: Final = random.Random(0).randbytes(6 * MIB) + bytes(6 * MIB)
encoded: Final = compress(body)
response: Final = TestClient(app).post(
"/claude-code/plugins/upload",
content=encoded,
headers={"content-encoding": coding, "content-type": "multipart/form-data; boundary=upload"},
)
assert (len(encoded) < 11 * MIB) == (coding != "identity")
assert response.status_code == 413
assert response.json() == (
{"error": "Request size is too large. Max size is 11 MB"} if coding == "identity" else _decoded_too_large(11)
)
@pytest.mark.parametrize(("signature", "content_encoding"), [(GZIP_MAGIC, "gzip"), (ZSTD_FRAME_MAGIC, "zstd")])

View file

@ -1,5 +1,7 @@
from collections.abc import AsyncIterator
from typing import Final
import httpx
import pytest
from starlette.responses import JSONResponse
from starlette.testclient import TestClient
@ -10,7 +12,16 @@ from litellm.proxy.middleware.request_size_limit_middleware import (
)
def test_request_size_limit_middleware_rejects_content_length_before_body_read():
@pytest.mark.parametrize(("path", "root_path", "operator_mb", "route_mb"), [
("/chat/completions", "", 1, None),
("/claude-code/plugins/upload", "", None, 1),
("/gateway/claude-code/plugins/upload", "/gateway", None, 1),
("/claude-code/plugins/upload", "", 2, 1),
("/claude-code/plugins/upload", "", 1, 2),
])
def test_request_size_limit_middleware_rejects_content_length_before_body_read(
path: str, root_path: str, operator_mb: int | None, route_mb: int | None,
) -> None:
downstream_called = False
async def app(scope, receive, send):
@ -22,13 +33,15 @@ def test_request_size_limit_middleware_rejects_content_length_before_body_read()
client = TestClient(
RequestSizeLimitMiddleware(
app,
get_max_request_size_mb=lambda: 1,
is_request_size_limit_enabled=lambda: True,
)
get_max_request_size_mb=lambda: operator_mb,
is_request_size_limit_enabled=lambda: operator_mb is not None,
route_max_request_size_mb={("POST", "/claude-code/plugins/upload"): route_mb} if route_mb else None,
),
root_path=root_path,
)
response = client.post(
"/chat/completions",
path,
content=b"x" * (1024 * 1024 + 1),
headers={"content-type": "application/json"},
)
@ -39,7 +52,16 @@ def test_request_size_limit_middleware_rejects_content_length_before_body_read()
assert downstream_called is False
def test_request_size_limit_middleware_zero_limit_disables_guard():
@pytest.mark.parametrize(("enabled", "operator_mb"), [(True, 0), (False, 1)])
@pytest.mark.parametrize(("method", "path"), [
("POST", "/chat/completions"),
("PUT", "/claude-code/plugins/upload"),
("POST", "/claude-code/plugins/upload-other"),
("POST", "/claude-code/plugins"),
])
def test_request_size_limit_middleware_zero_or_disabled_global_limit_preserves_other_routes(
enabled: bool, operator_mb: int, method: str, path: str,
) -> None:
downstream_called = False
async def app(scope, receive, send):
@ -51,14 +73,15 @@ def test_request_size_limit_middleware_zero_limit_disables_guard():
client = TestClient(
RequestSizeLimitMiddleware(
app,
get_max_request_size_mb=lambda: 0,
is_request_size_limit_enabled=lambda: True,
get_max_request_size_mb=lambda: operator_mb,
is_request_size_limit_enabled=lambda: enabled,
route_max_request_size_mb={("POST", "/claude-code/plugins/upload"): 1},
)
)
response = client.post(
"/chat/completions",
content=b"x",
response = client.request(
method, path,
content=b"x" * (1024 * 1024 + 1),
headers={"content-type": "application/json"},
)
@ -154,3 +177,33 @@ def test_an_oversized_messages_request_gets_the_anthropic_request_too_large_erro
"type": "error",
"error": {"type": "request_too_large", "message": "Request size is too large. Max size is 1 MB"},
}
@pytest.mark.asyncio
@pytest.mark.parametrize("path", ("/claude-code/plugins/upload", "/chat/completions"))
async def test_chunked_upload_overflow_stops_fastapi_and_auth_multipart_parsing(
path: str,
) -> None:
from litellm.proxy.proxy_server import app
limited: Final = RequestSizeLimitMiddleware(
app,
get_max_request_size_mb=lambda: None,
is_request_size_limit_enabled=lambda: False,
route_max_request_size_mb={("POST", path): 1 / 1024},
)
async def chunks() -> AsyncIterator[bytes]:
yield b'--skill\r\nContent-Disposition: form-data; name="file"; filename="skill.zip"\r\n\r\n'
yield b"x" * 1024
pytest.fail("The server consumed more of the upload after its byte limit")
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=limited), base_url="http://test") as client:
response: Final = await client.post(
path,
content=chunks(),
headers={"content-type": "multipart/form-data; boundary=skill"},
)
assert response.status_code == 413
assert response.json() == {"detail": "Request size is too large. Max size is 0.0009765625 MB"}

View file

@ -20,14 +20,17 @@ def _post_with_media_types(request_media_type: str, response_media_type: str) ->
}
@pytest.mark.parametrize("media_type", ("application/json", "application/sdp"))
def test_a_fully_documented_post_has_no_findings(media_type: str) -> None:
operation: Final = _post_with_media_types(media_type, media_type)
@pytest.mark.parametrize("request_type, response_type", [
("application/json", "application/json"), ("application/sdp", "application/sdp"),
("multipart/form-data", "application/json"),
])
def test_a_fully_documented_post_has_no_findings(request_type: str, response_type: str) -> None:
operation: Final = _post_with_media_types(request_type, response_type)
assert checker.violations({"paths": {"/v1/thing": {"post": operation}}}) == ()
def test_a_schema_under_another_media_type_still_counts_as_undocumented() -> None:
operation: Final = _post_with_media_types("multipart/form-data", "audio/wav")
operation: Final = _post_with_media_types("application/octet-stream", "audio/wav")
assert {v.code for v in checker.violations({"paths": {"/v1/thing": {"post": operation}}})} == {"OA001", "OA003"}
@ -37,8 +40,9 @@ def test_each_missing_piece_is_its_own_rule() -> None:
assert codes == {"OA001", "OA002", "OA003"}
def test_only_the_missing_piece_is_reported() -> None:
no_body: Final = {**_DOCUMENTED, "requestBody": {}}
@pytest.mark.parametrize("body", [{}, {"content": {"multipart/form-data": {}}}])
def test_only_the_missing_piece_is_reported(body: Mapping[str, object]) -> None:
no_body: Final = {**_DOCUMENTED, "requestBody": body}
blank_description: Final = {**_DOCUMENTED, "description": " "}
untyped_200: Final = {**_DOCUMENTED, "responses": {"200": {"content": {"application/json": {"schema": {}}}}}}
spec: Final = {

View file

@ -3,14 +3,16 @@ import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderWithProviders } from "@/../tests/test-utils";
import AddPluginForm from "./add_plugin_form";
import { registerClaudeCodePlugin } from "@/components/networking";
import { registerClaudeCodePlugin, uploadClaudeCodeSkill } from "@/components/networking";
import { toast } from "@/lib/toast";
vi.mock("@/components/networking", () => ({
registerClaudeCodePlugin: vi.fn().mockResolvedValue({ status: "success" }),
uploadClaudeCodeSkill: vi.fn().mockResolvedValue({ status: "success" }),
}));
const mockRegister = vi.mocked(registerClaudeCodePlugin);
const mockUpload = vi.mocked(uploadClaudeCodeSkill);
const mockMessageError = vi.mocked(toast.error);
const DEFAULT_PROPS = {
@ -34,7 +36,7 @@ describe("AddPluginForm", () => {
it("renders the host-agnostic source URL input and subfolder field, hiding the digest until a zip is entered", () => {
renderWithProviders(<AddPluginForm {...DEFAULT_PROPS} />);
expect(screen.getByText("Source URL")).toBeInTheDocument();
expect(screen.getByRole("radio", { name: "Source URL" })).toBeChecked();
expect(screen.getByPlaceholderText(URL_PLACEHOLDER)).toBeInTheDocument();
expect(screen.getByText("Subfolder path (Optional)")).toBeInTheDocument();
expect(screen.getByPlaceholderText(SUBPATH_PLACEHOLDER)).toBeInTheDocument();
@ -364,4 +366,75 @@ describe("AddPluginForm", () => {
});
expect(mockMessageError).toHaveBeenCalledTimes(1);
});
const chooseUpload = () => fireEvent.click(screen.getByRole("radio", { name: "Upload ZIP" }));
const file = new File(["skill archive"], "personal-skill.zip", { type: "application/zip" });
const chooseFile = (selected = file) =>
fireEvent.change(screen.getByLabelText("Skill ZIP file"), { target: { files: [selected] } });
it("preserves a pending or failed ZIP upload and registers its metadata only after retry succeeds", async () => {
const pending = Promise.withResolvers<never>();
mockUpload.mockReturnValueOnce(pending.promise);
renderWithProviders(<AddPluginForm {...DEFAULT_PROPS} />);
await typeUrl(S3_ZIP_URL);
fireEvent.change(screen.getByPlaceholderText(SHA256_PLACEHOLDER), { target: { value: "invalid-for-url" } });
chooseUpload();
chooseFile();
fireEvent.change(screen.getByPlaceholderText("my-skill"), { target: { value: "personal-skill" } });
fireEvent.change(screen.getByPlaceholderText("A skill that helps with..."), { target: { value: "My workflow" } });
await submit();
expect(screen.getByRole("button", { name: "Adding..." })).toBeDisabled();
expect(DEFAULT_PROPS.onClose).not.toHaveBeenCalled();
await act(async () => pending.reject(new Error("Unable to save skill")));
expect(mockMessageError).toHaveBeenCalledWith("Unable to save skill");
expect(DEFAULT_PROPS.onSuccess).not.toHaveBeenCalled();
expect(screen.getByPlaceholderText("my-skill")).toHaveValue("personal-skill");
await submit();
expect(mockUpload).toHaveBeenLastCalledWith("sk-test", file, {
name: "personal-skill",
description: "My workflow",
});
expect(mockRegister).not.toHaveBeenCalled();
expect(DEFAULT_PROPS.onSuccess).toHaveBeenCalledOnce();
expect(DEFAULT_PROPS.onClose).toHaveBeenCalledOnce();
});
it.each([
[new File(["text"], "SKILL.md"), "Please choose a .zip file"],
[new File([new Uint8Array(10 * 1024 * 1024 + 1)], "large.zip"), "The ZIP file must be 10 MiB or smaller"],
])("rejects invalid file case %#", async (invalidFile, message) => {
renderWithProviders(<AddPluginForm {...DEFAULT_PROPS} />);
chooseUpload();
fireEvent.change(screen.getByPlaceholderText("my-skill"), { target: { value: "personal-skill" } });
chooseFile(invalidFile);
await submit();
expect(screen.getByRole("alert")).toHaveTextContent(message);
expect(mockUpload).not.toHaveBeenCalled();
});
it("clears file removal and cancellation, allows reselection, and restores URL validation", async () => {
const { rerender } = renderWithProviders(<AddPluginForm {...DEFAULT_PROPS} />);
chooseUpload();
chooseFile();
fireEvent.click(screen.getByRole("button", { name: "Remove file" }));
expect((screen.getByLabelText("Skill ZIP file") as HTMLInputElement).files).toHaveLength(0);
await submit();
expect(screen.getByRole("alert")).toHaveTextContent("Please choose a .zip file");
chooseFile();
expect(screen.getByRole("button", { name: "Remove file" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
rerender(<AddPluginForm {...DEFAULT_PROPS} visible={false} />);
rerender(<AddPluginForm {...DEFAULT_PROPS} />);
expect(screen.getByRole("radio", { name: "Source URL" })).toBeChecked();
expect(screen.getByPlaceholderText("my-skill")).toHaveValue("");
chooseUpload();
fireEvent.change(screen.getByPlaceholderText("my-skill"), { target: { value: "personal-skill" } });
await submit();
expect(screen.getByRole("alert")).toHaveTextContent("Please choose a .zip file");
fireEvent.click(screen.getByRole("radio", { name: "Source URL" }));
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
await submit();
expect(await screen.findByText("Please enter a repository or zip archive URL")).toBeInTheDocument();
expect(mockUpload).not.toHaveBeenCalled();
});
});

View file

@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
import i18n from "@/i18n/i18n";
import { z } from "zod";
import { toast } from "@/lib/toast";
import { registerClaudeCodePlugin } from "@/components/networking";
import { registerClaudeCodePlugin, uploadClaudeCodeSkill } from "@/components/networking";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { Button } from "@/components/ui/button";
@ -19,6 +19,7 @@ import {
ComboboxList,
} from "@/components/ui/combobox";
import { Input } from "@/components/ui/input";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Textarea } from "@/components/ui/textarea";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useZodForm } from "@/lib/forms/useZodForm";
@ -42,12 +43,20 @@ interface AddPluginFormProps {
onSuccess: () => void;
}
const MAX_SKILL_UPLOAD_BYTES = 10 * 1024 * 1024;
const addPluginShape = {
skillUrl: z.string().min(1, { error: () => i18n.t("skills.addForm.errors.urlRequired") }),
subPath: z.string().refine((value) => !value || isValidSubPath(value), {
error: () => i18n.t("skills.addForm.errors.subPathInvalid"),
}),
sha256: z.string().refine(isValidSha256, { error: () => i18n.t("skills.addForm.errors.sha256Invalid") }),
sourceType: z.enum(["url", "upload"]),
archiveFile: z
.file()
.refine((file) => file.name.toLowerCase().endsWith(".zip"), {
error: () => i18n.t("skills.addForm.errors.zipRequired"),
})
.max(MAX_SKILL_UPLOAD_BYTES, { error: () => i18n.t("skills.addForm.errors.zipTooLarge") })
.nullable(),
skillUrl: z.string(),
subPath: z.string(),
sha256: z.string(),
name: z
.string()
.min(1, { error: () => i18n.t("skills.addForm.errors.nameRequired") })
@ -64,11 +73,29 @@ const addPluginShape = {
}),
};
const addPluginSchema = z.object(addPluginShape);
const addPluginSchema = z.object(addPluginShape).superRefine((values, context) => {
if (values.sourceType === "upload") {
if (!values.archiveFile) {
context.addIssue({ code: "custom", path: ["archiveFile"], message: i18n.t("skills.addForm.errors.zipRequired") });
}
return;
}
if (!values.skillUrl) {
context.addIssue({ code: "custom", path: ["skillUrl"], message: i18n.t("skills.addForm.errors.urlRequired") });
}
if (values.subPath && !isValidSubPath(values.subPath)) {
context.addIssue({ code: "custom", path: ["subPath"], message: i18n.t("skills.addForm.errors.subPathInvalid") });
}
if (!isValidSha256(values.sha256)) {
context.addIssue({ code: "custom", path: ["sha256"], message: i18n.t("skills.addForm.errors.sha256Invalid") });
}
});
type AddPluginFormValues = z.infer<typeof addPluginSchema>;
const EMPTY_VALUES: AddPluginFormValues = {
sourceType: "url",
archiveFile: null,
skillUrl: "",
subPath: "",
sha256: "",
@ -100,11 +127,10 @@ const withArchiveDigest = (source: PluginSource, sha256: string): PluginSource =
return source.source === "archive" && digest ? { ...source, sha256: digest.toLowerCase() } : source;
};
const buildRegisterRequest = (values: AddPluginFormValues, source: PluginSource): SkillRegisterRequest => {
const buildSkillMetadata = (values: AddPluginFormValues): Omit<SkillRegisterRequest, "source"> => {
const author = buildAuthor(values);
return {
name: values.name.trim(),
source: withArchiveDigest(source, values.sha256),
...(values.version ? { version: values.version.trim() } : {}),
...(values.description ? { description: values.description.trim() } : {}),
...(author ? { author } : {}),
@ -152,6 +178,43 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessT
const [isSubmitting, setIsSubmitting] = useState(false);
const [urlPreview, setUrlPreview] = useState<SkillSourcePreview | null>(null);
const [subPathLock, setSubPathLock] = useState<SubPathLock | null>(null);
const [archiveInputKey, setArchiveInputKey] = useState(0);
const isUpload = form.watch("sourceType") === "upload";
const clearArchive = () => {
form.setValue("archiveFile", null);
form.clearErrors("archiveFile");
setArchiveInputKey((key) => key + 1);
};
const resetDraft = () => {
form.reset(EMPTY_VALUES);
setArchiveInputKey((key) => key + 1);
setUrlPreview(null);
setSubPathLock(null);
};
const handleSourceChange = (value: unknown) => {
if (value !== "url" && value !== "upload") return;
form.setValue("sourceType", value);
clearArchive();
form.clearErrors(["skillUrl", "subPath", "sha256"]);
};
const selectArchive = (file: File | undefined) => {
form.setValue("archiveFile", file ?? null, { shouldValidate: true });
if (!file || !addPluginShape.archiveFile.safeParse(file).success) return;
if (!form.getValues("name")) {
form.setValue(
"name",
file.name
.slice(0, -4)
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, ""),
);
}
};
const recomputePreview = (skillUrl: string, subPath: string) => {
const lock = subPathLockFor(parseSkillSource(skillUrl)?.parsed.source);
@ -175,7 +238,7 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessT
return;
}
if (!urlPreview) {
if (values.sourceType === "url" && !urlPreview) {
toast.error(t("skills.addForm.toasts.invalidUrl"));
return;
}
@ -197,11 +260,18 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessT
setIsSubmitting(true);
try {
await registerClaudeCodePlugin(accessToken, buildRegisterRequest(values, urlPreview.parsed));
const metadata = buildSkillMetadata(values);
if (values.sourceType === "upload") {
if (!values.archiveFile) return;
await uploadClaudeCodeSkill(accessToken, values.archiveFile, metadata);
} else if (urlPreview) {
await registerClaudeCodePlugin(accessToken, {
...metadata,
source: withArchiveDigest(urlPreview.parsed, values.sha256),
});
}
toast.success(t("skills.addForm.toasts.registered"));
form.reset(EMPTY_VALUES);
setUrlPreview(null);
setSubPathLock(null);
resetDraft();
onSuccess();
onClose();
} catch (error) {
@ -213,9 +283,8 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessT
};
const handleCancel = () => {
form.reset(EMPTY_VALUES);
setUrlPreview(null);
setSubPathLock(null);
if (isSubmitting) return;
resetDraft();
onClose();
};
@ -228,67 +297,124 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessT
<TooltipProvider>
<form onSubmit={form.handleSubmit(handleSubmit)} noValidate className="mt-4">
<FieldGroup>
<FormField
control={form.control}
name="skillUrl"
label={labelWithHint(t("skills.addForm.labels.sourceUrl"), t("skills.addForm.hints.sourceUrl"))}
>
{({ ref, onChange, ...field }) => (
<Input
{...field}
ref={ref}
placeholder={t("skills.addForm.placeholders.sourceUrl")}
className="rounded-lg"
onChange={(event) => {
onChange(event);
recomputePreview(event.target.value, form.getValues("subPath"));
}}
/>
<FormField control={form.control} name="sourceType" label={t("skills.addForm.labels.source")}>
{({ value }) => (
<RadioGroup
value={value}
disabled={isSubmitting}
onValueChange={handleSourceChange}
aria-label={t("skills.addForm.labels.source")}
className="flex gap-6"
>
<label className="flex items-center gap-2 text-sm">
<RadioGroupItem value="url" />
{t("skills.addForm.sourceOptions.url")}
</label>
<label className="flex items-center gap-2 text-sm">
<RadioGroupItem value="upload" />
{t("skills.addForm.sourceOptions.upload")}
</label>
</RadioGroup>
)}
</FormField>
<FormField
control={form.control}
name="subPath"
label={labelWithHint(t("skills.addForm.labels.subPath"), t("skills.addForm.hints.subPath"))}
description={subPathLock ? t(SUB_PATH_LOCK_REASON_KEY[subPathLock]) : undefined}
>
{({ ref, onChange, ...field }) => (
<Input
{...field}
ref={ref}
placeholder="plugins/my-skill"
className="rounded-lg"
onChange={(event) => {
onChange(event);
recomputePreview(form.getValues("skillUrl"), event.target.value);
}}
disabled={subPathLock !== null}
/>
)}
</FormField>
{urlPreview?.parsed.source === "archive" && (
{isUpload ? (
<FormField
control={form.control}
name="sha256"
label={labelWithHint(t("skills.addForm.labels.sha256"), t("skills.addForm.hints.sha256"))}
name="archiveFile"
label={t("skills.addForm.labels.archive")}
description={t("skills.addForm.hints.archive")}
>
{({ ref, ...field }) => (
<Input
{...field}
ref={ref}
placeholder={t("skills.addForm.placeholders.sha256")}
className="rounded-lg font-mono"
/>
{({ value, ...field }) => (
<>
<Input
{...field}
key={archiveInputKey}
type="file"
accept=".zip,application/zip"
disabled={isSubmitting}
onChange={(event) => selectArchive(event.target.files?.[0])}
/>
{value && (
<Button
type="button"
variant="ghost"
size="sm"
className="self-start"
onClick={clearArchive}
disabled={isSubmitting}
>
{t("skills.addForm.removeFile")}
</Button>
)}
</>
)}
</FormField>
)}
) : (
<>
<FormField
control={form.control}
name="skillUrl"
label={labelWithHint(t("skills.addForm.labels.sourceUrl"), t("skills.addForm.hints.sourceUrl"))}
>
{({ ref, onChange, ...field }) => (
<Input
{...field}
ref={ref}
placeholder={t("skills.addForm.placeholders.sourceUrl")}
className="rounded-lg"
onChange={(event) => {
onChange(event);
recomputePreview(event.target.value, form.getValues("subPath"));
}}
/>
)}
</FormField>
{urlPreview && (
<div className="rounded-lg border border-info/20 bg-info/10 px-3 py-2 text-sm text-info">
{t("skills.addForm.detected", { source: urlPreview.label })}
</div>
<FormField
control={form.control}
name="subPath"
label={labelWithHint(t("skills.addForm.labels.subPath"), t("skills.addForm.hints.subPath"))}
description={subPathLock ? t(SUB_PATH_LOCK_REASON_KEY[subPathLock]) : undefined}
>
{({ ref, onChange, ...field }) => (
<Input
{...field}
ref={ref}
placeholder="plugins/my-skill"
className="rounded-lg"
onChange={(event) => {
onChange(event);
recomputePreview(form.getValues("skillUrl"), event.target.value);
}}
disabled={subPathLock !== null}
/>
)}
</FormField>
{urlPreview?.parsed.source === "archive" && (
<FormField
control={form.control}
name="sha256"
label={labelWithHint(t("skills.addForm.labels.sha256"), t("skills.addForm.hints.sha256"))}
>
{({ ref, ...field }) => (
<Input
{...field}
ref={ref}
placeholder={t("skills.addForm.placeholders.sha256")}
className="rounded-lg font-mono"
/>
)}
</FormField>
)}
{urlPreview && (
<div className="rounded-lg border border-info/20 bg-info/10 px-3 py-2 text-sm text-info">
{t("skills.addForm.detected", { source: urlPreview.label })}
</div>
)}
</>
)}
<FormField

View file

@ -1,9 +1,11 @@
import { describe, expect, it } from "vitest";
import type { PluginSource } from "./types";
import {
formatInstallCommand,
validatePluginName,
getSourceDisplayText,
getSourceLink,
getManagedSkillArchivePath,
getCategoryBadgeColor,
isValidSemanticVersion,
isValidEmail,
@ -15,6 +17,42 @@ import {
buildMarketplaceSettingsSnippet,
} from "./helpers";
describe("getManagedSkillArchivePath", () => {
const base = "https://proxy.example.com";
const digest = "a".repeat(64);
const path = `/claude-code/plugins/my-skill/archive/${digest}.zip`;
const source = { source: "archive" as const, url: `${base}${path}`, sha256: digest };
const identify = (overrides: Partial<PluginSource> = {}, name = "my-skill") =>
getManagedSkillArchivePath(name, { ...source, ...overrides }, base);
it("recognizes only the gateway archive, including a mounted gateway", () => {
expect(identify()).toBe(path);
const mounted = { ...source, url: `${base}/gateway${path}` };
expect(getManagedSkillArchivePath("my-skill", mounted, "/gateway", base)).toBe(path);
});
it.each([
`https://other.example.com${path}`,
`http://proxy.example.com${path}`,
`https://proxy.example.com:8443${path}`,
`https://user@proxy.example.com${path}`,
`${base}/other${path}`,
`${base}${path}?redirect=other`,
`${base}${path}#fragment`,
`${base}${path.replace("my-skill", "another-skill")}`,
`${base}${path.replace(digest, "b".repeat(64))}`,
])("rejects a different credential recipient or archive: %s", (url) => {
expect(identify({ url })).toBeNull();
});
it("requires an archive type, safe skill name and full SHA256 digest", () => {
expect(identify({ source: "url" })).toBeNull();
expect(identify({}, "../my-skill")).toBeNull();
expect(identify({ sha256: undefined })).toBeNull();
expect(identify({ sha256: "short" })).toBeNull();
});
});
describe("buildMarketplaceSettingsSnippet", () => {
it("sends the key placeholder as a bearer header when the proxy requires a key", () => {
expect(JSON.parse(buildMarketplaceSettingsSnippet("https://proxy.example.com", true))).toEqual({

View file

@ -305,6 +305,27 @@ export const getSourceLink = (source: Plugin["source"]): string | null => {
return linksToUrl && source.url && BROWSABLE_URL_REGEX.test(source.url) ? source.url : null;
};
export const getManagedSkillArchivePath = (
name: string,
source: Plugin["source"],
proxyBaseUrl: string,
pageOrigin?: string,
): string | null => {
if (source.source !== "archive" || !validatePluginName(name)) {
return null;
}
if (!source.sha256 || !SHA256_REGEX.test(source.sha256)) {
return null;
}
const path = `/claude-code/plugins/${name}/archive/${source.sha256}.zip`;
try {
const expectedUrl = new URL(`${proxyBaseUrl.replace(/\/+$/, "")}${path}`, pageOrigin);
return /^https?:$/.test(expectedUrl.protocol) && source.url === expectedUrl.href ? path : null;
} catch {
return null;
}
};
/**
* Get badge color based on category
*/

View file

@ -0,0 +1,68 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { renderWithProviders, testQueryClient } from "@/../tests/test-utils";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { getProxyBaseUrl, setGlobalLitellmHeaderName } from "@/components/networking";
import SkillDetail from "./skill_detail";
import type { Plugin } from "./types";
const digest = "a".repeat(64);
const archivePath = `/claude-code/plugins/my-skill/archive/${digest}.zip`;
const url = `${getProxyBaseUrl()}${archivePath}`;
const skill: Plugin = {
id: "private-skill",
name: "my-skill",
source: { source: "archive", url, sha256: digest },
enabled: true,
version: null,
description: null,
created_at: null,
updated_at: null,
};
const fetchMock = vi.fn<typeof fetch>();
const renderSkill = (source = skill.source) =>
renderWithProviders(<SkillDetail skill={{ ...skill, source }} onBack={vi.fn()} accessToken="dashboard-token" />);
beforeEach(() => {
testQueryClient.clear();
fetchMock.mockReset().mockResolvedValue(Response.json({ claude_code_marketplace_auth_required: true }));
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
setGlobalLitellmHeaderName();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("retries a private ZIP download through the configured auth header and local route", async () => {
const click = vi.spyOn(HTMLAnchorElement.prototype, "click");
URL.createObjectURL = vi.fn(() => "blob:skill-download");
setGlobalLitellmHeaderName("X-LiteLLM-Key");
renderSkill();
fetchMock.mockResolvedValueOnce(Response.json({ detail: "Archive not found" }, { status: 404 }));
fireEvent.click(screen.getByRole("button", { name: "Download ZIP" }));
expect(screen.getByRole("button", { name: "Downloading…" })).toBeDisabled();
expect(await screen.findByRole("alert")).toHaveTextContent("Archive not found");
expect(click).not.toHaveBeenCalled();
fetchMock.mockResolvedValueOnce(new Response("zip-bytes", { headers: { "Content-Type": "application/zip" } }));
fireEvent.click(screen.getByRole("button", { name: "Download ZIP" }));
await waitFor(() => expect(click).toHaveBeenCalledOnce());
expect((click.mock.contexts[0] as HTMLAnchorElement).download).toBe("my-skill.zip");
expect(fetchMock).toHaveBeenLastCalledWith(
url,
expect.objectContaining({
method: "GET",
headers: expect.objectContaining({ "X-LiteLLM-Key": "Bearer dashboard-token" }),
redirect: "error",
}),
);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it("keeps an external archive as an ordinary link even when its path matches the gateway", () => {
const externalUrl = `https://external.example.com${archivePath}`;
renderSkill({ ...skill.source, url: externalUrl });
expect(screen.getByRole("link")).toHaveAttribute("href", externalUrl);
expect(screen.getByRole("link")).toHaveAttribute("target", "_blank");
expect(screen.queryByRole("button", { name: "Download ZIP" })).not.toBeInTheDocument();
});

View file

@ -1,25 +1,64 @@
import React, { useState } from "react";
import { ArrowLeft, Check, Copy, Link2 } from "lucide-react";
import { ArrowLeft, Check, Copy, Download, Link2 } from "lucide-react";
import { Trans, useTranslation } from "react-i18next";
import { useClaudeCodeMarketplaceRequiresKey } from "@/app/(dashboard)/hooks/claudeCodeMarketplace/useClaudeCodeMarketplaceRequiresKey";
import { cn } from "@/lib/cva.config";
import { downloadBlob } from "@/components/EntityUsageExport/utils";
import { apiClient, getProxyBaseUrl } from "@/components/networking";
import { Button } from "@/components/ui/button";
import {
buildMarketplaceSettingsSnippet,
formatInstallCommand,
getManagedSkillArchivePath,
getSourceDisplayText,
getSourceLink,
MARKETPLACE_KEY_PLACEHOLDER,
} from "./helpers";
import { Plugin } from "./types";
const SkillSource: React.FC<{ source: Plugin["source"] }> = ({ source }) => {
const SkillSource: React.FC<Pick<SkillDetailProps, "skill" | "accessToken">> = ({ skill, accessToken }) => {
const { t } = useTranslation();
const [downloading, setDownloading] = useState(false);
const [downloadError, setDownloadError] = useState<string | null>(null);
const { source } = skill;
const archivePath = getManagedSkillArchivePath(
skill.name,
source,
getProxyBaseUrl(),
typeof window !== "undefined" ? window.location.origin : undefined,
);
const downloadArchive = async () => {
if (!archivePath || downloading) return;
setDownloading(true);
setDownloadError(null);
try {
const blob = await apiClient.getBlob(archivePath, { accessToken, redirect: "error" });
downloadBlob(blob, `${skill.name}.zip`);
} catch (error) {
setDownloadError(
error instanceof Error ? error.message : t("claudeCodePlugins.skillDetail.overview.downloadFailed"),
);
} finally {
setDownloading(false);
}
};
const link = getSourceLink(source);
const href = link && source.source === "git-subdir" && source.path ? `${link}/tree/main/${source.path}` : link;
if (href) {
return (
<div className="mb-6">
<div className="mb-1 text-xs text-muted-foreground">{t("claudeCodePlugins.skillDetail.overview.source")}</div>
if (!href && !source.url) return null;
return (
<div className="mb-6">
<div className="mb-1 text-xs text-muted-foreground">{t("claudeCodePlugins.skillDetail.overview.source")}</div>
{archivePath && (
<Button variant="link" className="h-auto p-0 text-[13px]" onClick={downloadArchive} disabled={downloading}>
<Download className="size-3 shrink-0" />
{t(
downloading
? "claudeCodePlugins.skillDetail.overview.downloading"
: "claudeCodePlugins.skillDetail.overview.downloadZip",
)}
</Button>
)}
{!archivePath && href && (
<a
href={href}
target="_blank"
@ -29,16 +68,13 @@ const SkillSource: React.FC<{ source: Plugin["source"] }> = ({ source }) => {
{href.replace("https://", "")}
<Link2 className="size-3 shrink-0" />
</a>
</div>
);
}
if (!source.url) {
return null;
}
return (
<div className="mb-6">
<div className="mb-1 text-xs text-muted-foreground">{t("claudeCodePlugins.skillDetail.overview.source")}</div>
<div className="break-all text-[13px] text-foreground">{getSourceDisplayText(source)}</div>
)}
{!href && <div className="break-all text-[13px] text-foreground">{getSourceDisplayText(source)}</div>}
{downloadError && (
<p role="alert" className="mt-2 text-xs text-destructive">
{downloadError}
</p>
)}
</div>
);
};
@ -51,7 +87,7 @@ interface SkillDetailProps {
onPublishClick?: () => void;
}
const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack }) => {
const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack, accessToken }) => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState("overview");
const [copiedKey, setCopiedKey] = useState<string | null>(null);
@ -187,7 +223,7 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack }) => {
</span>
</div>
<SkillSource source={skill.source} />
<SkillSource skill={skill} accessToken={accessToken} />
{skill.keywords && skill.keywords.length > 0 && (
<div className="mb-6">

View file

@ -1225,3 +1225,23 @@ describe("searchToolQueryCall", () => {
await expect(Networking.searchToolQueryCall("token", "exa-search-2", "litellm")).rejects.toThrow(errorMessage);
});
});
describe("uploadClaudeCodeSkill", () => {
afterEach(() => vi.unstubAllGlobals());
it("sends the archive bytes and metadata as one authenticated multipart request", async () => {
const fetch = vi.fn<typeof globalThis.fetch>().mockResolvedValue(Response.json({ status: "success" }));
vi.stubGlobal("fetch", fetch);
const file = new File(["skill archive bytes"], "personal.zip", { type: "application/zip" });
const metadata = { name: "personal", description: "Personal workflow", keywords: ["workflow"] };
await Networking.uploadClaudeCodeSkill("upload-token", file, metadata);
const [url, init] = fetch.mock.calls[0];
expect(url).toBe(`${Networking.getProxyBaseUrl()}/claude-code/plugins/upload`);
expect(init?.method).toBe("POST");
const headers = new Headers(init?.headers);
expect(headers.get("Authorization")).toBe("Bearer upload-token");
expect(headers.has("Content-Type")).toBe(false);
expect(Object.fromEntries(init?.body as FormData)).toEqual({ file, metadata: JSON.stringify(metadata) });
});
});

View file

@ -6942,6 +6942,17 @@ export const registerClaudeCodePlugin = async (accessToken: string, pluginData:
}
};
export const uploadClaudeCodeSkill = async (
accessToken: string,
file: File,
metadata: Omit<SkillRegisterRequest, "source">,
): Promise<components["schemas"]["RegisterPluginResponse"]> => {
const body = new FormData();
body.append("file", file);
body.append("metadata", JSON.stringify(metadata));
return apiClient.post("/claude-code/plugins/upload", { accessToken, rawBody: body });
};
/**
* Enable a Claude Code plugin (admin only)
* @param accessToken - Admin access token

View file

@ -1642,7 +1642,14 @@
},
"addForm": {
"title": "Add New Skill",
"sourceOptions": {
"url": "Source URL",
"upload": "Upload ZIP"
},
"removeFile": "Remove file",
"labels": {
"source": "Skill source",
"archive": "Skill ZIP file",
"sourceUrl": "Source URL",
"subPath": "Subfolder path (Optional)",
"sha256": "Archive SHA-256 (Optional)",
@ -1657,6 +1664,7 @@
"authorEmail": "Author Email (Optional)"
},
"hints": {
"archive": "Choose a ZIP from your computer, up to 10 MiB. Include SKILL.md at the root or inside one skill folder, along with any supporting files. The skill will use the same visibility settings as skills added by URL.",
"sourceUrl": "Paste an HTTPS git repository URL from GitHub, GitLab, Bitbucket, or a self-hosted host (e.g. github.com/org/repo or github.com/org/repo/tree/main/my-skill), or an HTTPS link to a .zip archive of the skill hosted on S3 or any static file server. For a private repository use its SSH clone URL (git@ghe.example.com:org/repo.git) so Claude Code clones it with your own SSH key.",
"subPath": "Path within the repository where the skill lives (e.g., plugins/my-skill). Leave empty if the skill is at the repo root.",
"sha256": "Hex digest of the zip file. Claude Code refuses to install the archive if its checksum does not match.",
@ -1689,6 +1697,8 @@
"adding": "Adding...",
"submit": "Add Skill",
"errors": {
"zipRequired": "Please choose a .zip file",
"zipTooLarge": "The ZIP file must be 10 MiB or smaller",
"urlRequired": "Please enter a repository or zip archive URL",
"subPathInvalid": "Subfolder must be a relative path like plugins/my-skill (letters, numbers, dots, hyphens, underscores)",
"sha256Invalid": "SHA-256 must be a 64-character hex digest",
@ -1739,6 +1749,9 @@
"public": "Public",
"draft": "Draft",
"source": "Source",
"downloadZip": "Download ZIP",
"downloading": "Downloading…",
"downloadFailed": "Could not download the skill. Try again.",
"tags": "Tags",
"skillId": "Skill ID"
},

View file

@ -2528,6 +2528,26 @@ export interface paths {
patch?: never;
trace?: never;
};
"/claude-code/plugins/upload": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Upload Plugin
* @description Create a skill and its uploaded ZIP atomically. Duplicate names return 409.
*/
post: operations["upload_plugin_claude_code_plugins_upload_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/claude-code/plugins/{plugin_name}": {
parameters: {
query?: never;
@ -2604,6 +2624,26 @@ export interface paths {
patch?: never;
trace?: never;
};
"/claude-code/plugins/{plugin_name}/archive/{digest}.zip": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Download Plugin Archive
* @description Download an uploaded ZIP under the same visibility policy as its skill.
*/
get: operations["download_plugin_archive_claude_code_plugins__plugin_name__archive__digest__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;
@ -28082,6 +28122,19 @@ export interface components {
/** File */
file: string;
};
/** Body_upload_plugin_claude_code_plugins_upload_post */
Body_upload_plugin_claude_code_plugins_upload_post: {
/**
* File
* @description Skill ZIP containing SKILL.md and supporting files, up to 10 MiB
*/
file: string;
/**
* Metadata
* @description JSON skill metadata without a source URL
*/
metadata: string;
};
/** Body_video_create_character_v1_videos_characters_post */
Body_video_create_character_v1_videos_characters_post: {
/** Name */
@ -49877,6 +49930,57 @@ export interface components {
/** User Role */
user_role?: ("proxy_admin" | "proxy_admin_viewer" | "internal_user" | "internal_user_viewer") | null;
};
/** UploadPluginRequest */
UploadPluginRequest: {
/** @description Plugin author */
author?: components["schemas"]["PluginAuthor"] | null;
/**
* Category
* @description Plugin category
*/
category?: string | null;
/**
* Description
* @description Plugin description
*/
description?: string | null;
/**
* Domain
* @description Skill domain (e.g., 'Productivity')
*/
domain?: string | null;
/**
* Homepage
* @description Plugin homepage URL
*/
homepage?: string | null;
/**
* Installation Preference
* @description Emitted as installationPreference on this plugin's marketplace.json entry. Needs an archive source, the only plugins Claude Desktop lists from a marketplace.json URL, and 'auto_install' or 'required' also need its sha256. Claude Desktop honors the mark only when the archive and marketplace.json share its inference gateway origin
*/
installation_preference?: ("available" | "auto_install" | "required") | null;
/**
* Keywords
* @description Search keywords
*/
keywords?: string[] | null;
/**
* Name
* @description Skill name (kebab-case)
*/
name: string;
/**
* Namespace
* @description Skill namespace within domain (e.g., 'workflows')
*/
namespace?: string | null;
/**
* Version
* @description Semantic version
* @default 1.0.0
*/
version: string | null;
};
/** UsageCaptureSettings */
UsageCaptureSettings: {
/**
@ -55820,6 +55924,39 @@ export interface operations {
};
};
};
upload_plugin_claude_code_plugins_upload_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": components["schemas"]["Body_upload_plugin_claude_code_plugins_upload_post"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["RegisterPluginResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_plugin_claude_code_plugins__plugin_name__get: {
parameters: {
query?: never;
@ -55917,6 +56054,38 @@ export interface operations {
};
};
};
download_plugin_archive_claude_code_plugins__plugin_name__archive__digest__zip_get: {
parameters: {
query?: never;
header?: never;
path: {
plugin_name: string;
digest: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/zip": string;
};
};
/** @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;