feat(skills): let authors edit and withdraw pending submissions (#51312)

This commit is contained in:
tin-berri 2026-11-12 08:30:10 -08:00 • committed by GitHub
parent 5a511b6fcc
commit d076cee903
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1468 additions and 147 deletions

View file

@ -11,7 +11,7 @@ from litellm.models.plugin_upload import (
)
from litellm.types.proxy.skill_scanning import SkillScan
SubmissionStatus: TypeAlias = Literal["pending_review", "approved", "rejected"]
SubmissionStatus: TypeAlias = Literal["pending_review", "approved", "rejected", "withdrawn"]
SubmissionOperation: TypeAlias = Literal["create", "update"]
@ -38,6 +38,7 @@ class SkillSubmission:
reviewed_by: str | None
reviewed_at: datetime | None
rejection_reason: str | None
revision: int = 0
@dataclass(frozen=True, slots=True)
@ -99,6 +100,14 @@ class SkillSubmissionStore(Protocol):
async def get_archive(self, submission_id: str, submitted_by: str | None) -> bytes | None: ...
async def edit(
self, submission_id: str, owner: str, revision: int, upload: PluginPublication
) -> SkillSubmission | SubmissionMissing | SubmissionConflict | SubmissionLimitExceeded: ...
async def withdraw(
self, submission_id: str, owner: str, revision: int
) -> SkillSubmission | SubmissionMissing | SubmissionConflict: ...
async def review(
self,
submission_id: str,
@ -107,4 +116,5 @@ class SkillSubmissionStore(Protocol):
reason: str | None,
*,
publication_base_url: str | None = None,
expected_revision: int | None = None,
) -> SubmissionReviewResult: ...

View file

@ -59670,6 +59670,38 @@
"skill_hub": {
"components": {
"schemas": {
"Body_edit_submission_archive_skill_hub_submissions__submission_id__upload_put": {
"properties": {
"expected_revision": {
"minimum": 0.0,
"title": "Expected Revision",
"type": "integer"
},
"file": {
"contentMediaType": "application/octet-stream",
"description": "Replacement Agent Skills ZIP with the same declared skill name",
"title": "File",
"type": "string"
},
"version": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Version"
}
},
"required": [
"file",
"expected_revision"
],
"title": "Body_edit_submission_archive_skill_hub_submissions__submission_id__upload_put",
"type": "object"
},
"Body_replace_skill_archive_skill_hub_skills__name__upload_put": {
"properties": {
"expected_updated_at": {
@ -60227,6 +60259,18 @@
"SkillHubRejection": {
"additionalProperties": false,
"properties": {
"expected_revision": {
"anyOf": [
{
"minimum": 0.0,
"type": "integer"
},
{
"type": "null"
}
],
"title": "Expected Revision"
},
"reason": {
"anyOf": [
{
@ -60536,6 +60580,12 @@
],
"title": "Base Updated At"
},
"can_edit": {
"default": false,
"description": "Whether the caller may edit or withdraw this pending submission",
"title": "Can Edit",
"type": "boolean"
},
"created_at": {
"format": "date-time",
"title": "Created At",
@ -60594,6 +60644,13 @@
],
"title": "Reviewed By"
},
"revision": {
"default": 0,
"description": "Proposal revision used to prevent stale edits and reviews",
"minimum": 0.0,
"title": "Revision",
"type": "integer"
},
"skill_name": {
"title": "Skill Name",
"type": "string"
@ -60602,7 +60659,8 @@
"enum": [
"pending_review",
"approved",
"rejected"
"rejected",
"withdrawn"
],
"title": "Status",
"type": "string"
@ -60653,6 +60711,95 @@
"title": "SkillHubSubmissionDetail",
"type": "object"
},
"SkillHubSubmissionEdit": {
"additionalProperties": false,
"properties": {
"description": {
"anyOf": [
{
"maxLength": 1024,
"type": "string"
},
{
"type": "null"
}
],
"title": "Description"
},
"domain": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Domain"
},
"expected_revision": {
"description": "Revision read from the submission detail",
"minimum": 0.0,
"title": "Expected Revision",
"type": "integer"
},
"expected_updated_at": {
"anyOf": [
{
"format": "date-time",
"type": "string"
},
{
"type": "null"
}
],
"description": "Published updated_at read by the editor. A stale value returns 409",
"title": "Expected Updated At"
},
"keywords": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Keywords"
},
"namespace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Namespace"
},
"version": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Explicit higher SemVer, or omit/null for an automatic patch bump",
"title": "Version"
}
},
"required": [
"expected_revision"
],
"title": "SkillHubSubmissionEdit",
"type": "object"
},
"SkillHubSubmissionList": {
"properties": {
"count": {
@ -60679,6 +60826,22 @@
"title": "SkillHubSubmissionList",
"type": "object"
},
"SkillHubSubmissionRevision": {
"additionalProperties": false,
"properties": {
"expected_revision": {
"description": "Revision read from the submission detail",
"minimum": 0.0,
"title": "Expected Revision",
"type": "integer"
}
},
"required": [
"expected_revision"
],
"title": "SkillHubSubmissionRevision",
"type": "object"
},
"SkillHubVersion": {
"properties": {
"content_kind": {
@ -62118,7 +62281,8 @@
"enum": [
"pending_review",
"approved",
"rejected"
"rejected",
"withdrawn"
],
"type": "string"
},
@ -62188,7 +62352,7 @@
},
"/skill-hub/submissions/{submission_id}": {
"get": {
"description": "Read an immutable proposal and its currently visible published skill.",
"description": "Read a proposal revision and its currently visible published skill.",
"operationId": "get_submission_skill_hub_submissions__submission_id__get",
"parameters": [
{
@ -62232,6 +62396,62 @@
"tags": [
"skill_hub"
]
},
"put": {
"description": "Replace a pending proposal's metadata, preserving files. Only its original submitting user may edit.",
"operationId": "edit_submission_skill_hub_submissions__submission_id__put",
"parameters": [
{
"in": "path",
"name": "submission_id",
"required": true,
"schema": {
"title": "Submission Id",
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SkillHubSubmissionEdit"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SkillHubSubmission"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"security": [
{
"APIKeyHeader": []
}
],
"summary": "Edit Submission",
"tags": [
"skill_hub"
]
}
},
"/skill-hub/submissions/{submission_id}/approve": {
@ -62249,6 +62469,23 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/SkillHubSubmissionRevision"
},
{
"type": "null"
}
],
"title": "Body"
}
}
}
},
"responses": {
"200": {
"content": {
@ -62506,6 +62743,122 @@
"skill_hub"
]
}
},
"/skill-hub/submissions/{submission_id}/upload": {
"put": {
"description": "Replace a pending proposal's files using the upload limits. Only its original submitting user may edit.",
"operationId": "edit_submission_archive_skill_hub_submissions__submission_id__upload_put",
"parameters": [
{
"in": "path",
"name": "submission_id",
"required": true,
"schema": {
"title": "Submission Id",
"type": "string"
}
}
],
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/Body_edit_submission_archive_skill_hub_submissions__submission_id__upload_put"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SkillHubSubmission"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"security": [
{
"APIKeyHeader": []
}
],
"summary": "Edit Submission Archive",
"tags": [
"skill_hub"
]
}
},
"/skill-hub/submissions/{submission_id}/withdraw": {
"put": {
"description": "Withdraw a pending proposal without publishing it. Only its original submitting user may withdraw.",
"operationId": "withdraw_submission_skill_hub_submissions__submission_id__withdraw_put",
"parameters": [
{
"in": "path",
"name": "submission_id",
"required": true,
"schema": {
"title": "Submission Id",
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SkillHubSubmissionRevision"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SkillHubSubmission"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"security": [
{
"APIKeyHeader": []
}
],
"summary": "Withdraw Submission",
"tags": [
"skill_hub"
]
}
}
}
},

View file

@ -470,6 +470,8 @@ class LiteLLMRoutes(enum.Enum):
"/skill-hub/submissions",
"/skill-hub/submissions/{submission_id}",
"/skill-hub/submissions/{submission_id}/archive",
"/skill-hub/submissions/{submission_id}/upload",
"/skill-hub/submissions/{submission_id}/withdraw",
"/skill-hub/submissions/{submission_id}/approve",
"/skill-hub/submissions/{submission_id}/reject",
"/skill-hub/submissions/{submission_id}/scan",

View file

@ -58,6 +58,15 @@ def submission_owner(caller: UserAPIKeyAuth) -> str | None | SkillMutationError:
return caller.user_id
def submission_editor(caller: UserAPIKeyAuth) -> str | SkillMutationError:
error: Final = writer_error(caller)
if error is not None:
return error
if not caller.user_id or caller.user_id == DB_UNAVAILABLE_FALLBACK_USER_ID:
return SkillMutationError(403, "Editing submissions requires the original submitting user's identity.")
return caller.user_id
async def archive_writer(caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)]) -> UserAPIKeyAuth:
from litellm.proxy.proxy_server import proxy_logging_obj
@ -149,13 +158,19 @@ async def review_skill(
approve: bool,
reason: str | None,
publication_base_url: str | None = None,
expected_revision: int | None = None,
) -> SubmissionReviewed | SkillMutationError:
if not is_proxy_admin(caller):
return SkillMutationError(403, "Only proxy admins may review skill submissions.")
if submissions is None:
return SkillMutationError(503, "Skills Hub requires a connected database")
result: Final = await submissions.review(
submission_id, caller.user_id or "proxy-admin", approve, reason, publication_base_url=publication_base_url
submission_id,
caller.user_id or "proxy-admin",
approve,
reason,
publication_base_url=publication_base_url,
expected_revision=expected_revision,
)
match result:
case SubmissionMissing():

View file

@ -1,27 +1,49 @@
import asyncio
from dataclasses import replace
from typing import Annotated, Final
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, Response, UploadFile
from litellm.models.plugin_upload import PluginPublication
from litellm.models.skill_submission import SkillSubmission, SkillSubmissionStore, SubmissionStatus
from litellm.models.plugin_upload import PluginPublication, PluginPublicationError, PluginUploadStore
from litellm.models.skill_submission import (
SkillSubmission,
SkillSubmissionStore,
SubmissionConflict,
SubmissionLimitExceeded,
SubmissionMissing,
SubmissionStatus,
)
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_skill_access import resolve_skill_visibility
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.deps.repositories import PluginUploadRepositoryDep, SkillSubmissionRepositoryDep
from litellm.proxy.discovery_endpoints.agent_skills_archive import build_agent_skill_archive
from litellm.proxy.discovery_endpoints.skill_hub_endpoints import (
prepare_skill_publication,
skill_metadata_response,
skill_proposal_response,
skill_publication_response,
)
from litellm.proxy.discovery_endpoints.skill_hub_mutations import SkillMutationError, review_skill, submission_owner
from litellm.proxy.discovery_endpoints.skill_hub_mutations import (
SkillMutationError,
archive_writer,
publication_error,
review_skill,
submission_editor,
submission_owner,
write_target,
)
from litellm.proxy.discovery_endpoints.skill_import import MAX_IMPORT_BYTES
from litellm.repositories.plugin_publication import resolve_plugin_version
from litellm.types.proxy.skill_hub import (
SkillHubMetadata,
SkillHubRejection,
SkillHubReviewResponse,
SkillHubSubmission,
SkillHubSubmissionDetail,
SkillHubSubmissionEdit,
SkillHubSubmissionList,
SkillHubSubmissionRevision,
)
router: Final = APIRouter(prefix="/skill-hub/submissions", tags=["Skills Hub"])
@ -40,7 +62,7 @@ def _owner(caller: UserAPIKeyAuth) -> str | None:
return owner
def _response(request: Request, submission: SkillSubmission) -> SkillHubSubmission:
def _response(request: Request, submission: SkillSubmission, caller: UserAPIKeyAuth) -> SkillHubSubmission:
return SkillHubSubmission(
id=submission.id,
skill_name=submission.plugin_name,
@ -55,6 +77,8 @@ def _response(request: Request, submission: SkillSubmission) -> SkillHubSubmissi
reviewed_by=submission.reviewed_by,
reviewed_at=submission.reviewed_at,
rejection_reason=submission.rejection_reason,
revision=submission.revision,
can_edit=submission.status == "pending_review" and submission_editor(caller) == submission.submitted_by,
)
@ -71,7 +95,7 @@ async def list_submissions(
owner: Final = _owner(caller)
page: Final = await _store(submissions).list(owner, status, limit, offset)
return SkillHubSubmissionList(
submissions=tuple(_response(request, row) for row in page.submissions),
submissions=tuple(_response(request, row, caller) for row in page.submissions),
count=len(page.submissions),
has_more=page.has_more,
)
@ -85,7 +109,7 @@ async def get_submission(
uploads: PluginUploadRepositoryDep,
caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
) -> SkillHubSubmissionDetail:
"""Read an immutable proposal and its currently visible published skill."""
"""Read a proposal revision and its currently visible published skill."""
owner: Final = _owner(caller)
submission: Final = await _store(submissions).get(submission_id, owner)
if submission is None:
@ -95,7 +119,7 @@ async def get_submission(
current: Final = await uploads.get_metadata(submission.plugin_name)
visible: Final = current is not None and (owner is None or (await resolve_skill_visibility(caller)).allows(current))
return SkillHubSubmissionDetail(
submission=_response(request, submission),
submission=_response(request, submission, caller),
current_skill=skill_metadata_response(request, current) if current is not None and visible else None,
)
@ -143,11 +167,12 @@ async def _review(
caller: UserAPIKeyAuth,
approve: bool,
reason: str | None,
expected_revision: int | None,
) -> SkillHubReviewResponse:
from litellm.proxy.utils import get_custom_url
result: Final = await review_skill(
submission_id, submissions, caller, approve, reason, get_custom_url(str(request.base_url))
submission_id, submissions, caller, approve, reason, get_custom_url(str(request.base_url)), expected_revision
)
if isinstance(result, SkillMutationError):
raise HTTPException(result.status, result.message)
@ -164,7 +189,7 @@ async def _review(
)
return SkillHubReviewResponse(
action="approved" if approve else "rejected",
submission=_response(request, result.submission),
submission=_response(request, result.submission, caller),
skill=None if result.plugin is None else skill_publication_response(request, publication, result.plugin),
)
@ -175,9 +200,12 @@ async def approve_submission(
submission_id: str,
submissions: SkillSubmissionRepositoryDep,
caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
body: SkillHubSubmissionRevision | None = None,
) -> SkillHubReviewResponse:
"""Publish a pending proposal and record its approval atomically. Only proxy admins may review."""
return await _review(request, submission_id, submissions, caller, True, None)
return await _review(
request, submission_id, submissions, caller, True, None, None if body is None else body.expected_revision
)
@router.put("/{submission_id}/reject", response_model=SkillHubReviewResponse)
@ -189,4 +217,133 @@ async def reject_submission(
body: SkillHubRejection | None = None,
) -> SkillHubReviewResponse:
"""Reject a pending proposal without changing the published skill. Only proxy admins may review."""
return await _review(request, submission_id, submissions, caller, False, None if body is None else body.reason)
return await _review(
request,
submission_id,
submissions,
caller,
False,
None if body is None else body.reason,
None if body is None else body.expected_revision,
)
def _editor(caller: UserAPIKeyAuth) -> str:
owner: Final = submission_editor(caller)
if isinstance(owner, SkillMutationError):
raise HTTPException(owner.status, owner.message)
return owner
def _changed(
result: SkillSubmission | SubmissionMissing | SubmissionConflict | SubmissionLimitExceeded,
) -> SkillSubmission:
if isinstance(result, SubmissionMissing):
raise HTTPException(404, "Skill submission not found")
if isinstance(result, SubmissionConflict):
raise HTTPException(409, result.message)
if isinstance(result, SubmissionLimitExceeded):
raise HTTPException(429, result.message)
return result
async def _editable(
submission_id: str,
owner: str,
store: SkillSubmissionStore,
revision: int,
version: str | None,
uploads: PluginUploadStore | None,
caller: UserAPIKeyAuth,
) -> SkillSubmission:
submission: Final = await store.get(submission_id, owner)
if submission is None:
raise HTTPException(404, "Skill submission not found")
if submission.status != "pending_review" or submission.revision != revision:
raise HTTPException(409, "This submission changed or is no longer pending. Reload it before trying again.")
existing: Final = await write_target(
submission.plugin_name, submission.operation == "update", uploads, caller, submission.base_updated_at
)
if isinstance(existing, SkillMutationError):
raise HTTPException(existing.status, existing.message)
validation: Final = resolve_plugin_version(
version, None if existing is None else existing.version, updating=existing is not None
)
if isinstance(validation, PluginPublicationError):
error: Final = publication_error(validation)
raise HTTPException(error.status, error.message)
return submission
@router.put("/{submission_id}", response_model=SkillHubSubmission)
async def edit_submission(
request: Request,
submission_id: str,
body: SkillHubSubmissionEdit,
submissions: SkillSubmissionRepositoryDep,
uploads: PluginUploadRepositoryDep,
caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
) -> SkillHubSubmission:
"""Replace a pending proposal's metadata, preserving files. Only its original submitting user may edit."""
owner: Final = _editor(caller)
store: Final = _store(submissions)
submission: Final = await _editable(
submission_id, owner, store, body.expected_revision, body.version, uploads, caller
)
publication: Final = PluginPublication(
name=submission.plugin_name,
version=body.version,
description=body.description,
manifest={
**{
key: value
for key, value in submission.proposal.manifest.items()
if key not in SkillHubMetadata.model_fields
},
**body.model_dump(exclude={"expected_revision"}, exclude_none=True),
},
created_by=owner,
)
result: Final = await store.edit(submission_id, owner, body.expected_revision, publication)
return _response(request, _changed(result), caller)
@router.put("/{submission_id}/upload", response_model=SkillHubSubmission)
async def edit_submission_archive(
request: Request,
submission_id: str,
submissions: SkillSubmissionRepositoryDep,
uploads: PluginUploadRepositoryDep,
caller: Annotated[UserAPIKeyAuth, Depends(archive_writer)],
file: Annotated[UploadFile, File(description="Replacement Agent Skills ZIP with the same declared skill name")],
expected_revision: Annotated[int, Form(ge=0)],
version: Annotated[str | None, Form()] = None,
) -> SkillHubSubmission:
"""Replace a pending proposal's files using the upload limits. Only its original submitting user may edit."""
owner: Final = _editor(caller)
store: Final = _store(submissions)
submission: Final = await _editable(submission_id, owner, store, expected_revision, version, uploads, caller)
content: Final = await file.read(MAX_IMPORT_BYTES + 1)
if len(content) > MAX_IMPORT_BYTES:
raise HTTPException(413, "Skill ZIP must be 10 MiB or smaller")
archive: Final = await asyncio.to_thread(build_agent_skill_archive, content)
if archive is None or archive.declared_name != submission.plugin_name:
raise HTTPException(422, "Replacement SKILL.md must declare the existing skill name and a description")
prepared: Final = await prepare_skill_publication(request, archive, version, None, owner)
publication: Final = replace(prepared, manifest={**submission.proposal.manifest, **prepared.manifest})
result: Final = await store.edit(submission_id, owner, expected_revision, publication)
return _response(request, _changed(result), caller)
@router.put("/{submission_id}/withdraw", response_model=SkillHubSubmission)
async def withdraw_submission(
request: Request,
submission_id: str,
body: SkillHubSubmissionRevision,
submissions: SkillSubmissionRepositoryDep,
caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
) -> SkillHubSubmission:
"""Withdraw a pending proposal without publishing it. Only its original submitting user may withdraw."""
owner: Final = _editor(caller)
result: Final = await _store(submissions).withdraw(submission_id, owner, body.expected_revision)
return _response(request, _changed(result), caller)

View file

@ -2993,6 +2993,7 @@ _ROUTE_MAX_REQUEST_SIZE_MB: Final[Mapping[tuple[str, str], float]] = MappingProx
("POST", "/claude-code/plugins/upload"): 11,
("POST", "/skill-hub/skills/upload"): 11,
("PUT", "/skill-hub/skills/{name}/upload"): 11,
("PUT", "/skill-hub/submissions/{submission_id}/upload"): 11,
}
)

View file

@ -7,7 +7,7 @@ from datetime import datetime, timezone
from typing import TYPE_CHECKING, Final, Protocol, cast # noqa: TID251 # Prisma's generated delegates are dynamic
from urllib.parse import urlsplit
from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError
from litellm.models.plugin_upload import (
PluginPublication,
@ -45,6 +45,7 @@ class _StoredProposal(BaseModel):
manifest: Mapping[str, object]
version: str | None
revision: int = Field(default=0, ge=0)
def proposal(self, name: str) -> SkillProposal:
return SkillProposal(
@ -92,6 +93,7 @@ class _SubmissionRow(BaseModel):
reviewed_by=self.reviewed_by,
reviewed_at=self.reviewed_at,
rejection_reason=self.rejection_reason,
revision=stored.revision,
)
@ -283,6 +285,71 @@ class SkillSubmissionRepository:
)
return None if row is None or row.archive_content is None else row.archive_content.decode()
async def edit(
self, submission_id: str, owner: str, revision: int, upload: PluginPublication
) -> SkillSubmission | SubmissionMissing | SubmissionConflict | SubmissionLimitExceeded:
from prisma import Base64
async with self._database.tx() as transaction:
await transaction.query_raw(
"SELECT 1 AS locked FROM pg_advisory_xact_lock(hashtext('litellm.skill_submissions'), hashtext($1))",
owner,
)
row: Final = await _owned_pending(transaction, submission_id, owner, revision)
if isinstance(row, (SubmissionMissing, SubmissionConflict)):
return row
if upload.name != row.plugin_name:
return SubmissionConflict("A submission's skill name cannot change.")
proposal_json: Final = _StoredProposal(
manifest=upload.manifest, version=upload.version, revision=revision + 1
).model_dump_json()
archive: Final = row.archive_content if upload.content is None else Base64.encode(upload.content)
usage: Final = _USAGE.validate_python(
await transaction.query_raw(
"""SELECT COUNT(*) AS count,
COALESCE(SUM(octet_length(proposal_json)
+ COALESCE(octet_length(archive_content), 0)), 0) AS storage_bytes
FROM "LiteLLM_SkillSubmission" WHERE submitted_by = $1 AND status = 'pending_review'""",
owner,
)
)[0]
previous_bytes: Final = len(row.proposal_json.encode()) + (
0 if row.archive_content is None else len(row.archive_content.decode())
)
incoming_bytes: Final = len(proposal_json.encode()) + (0 if archive is None else len(archive.decode()))
if usage.storage_bytes - previous_bytes + incoming_bytes > _MAX_PENDING_BYTES:
return SubmissionLimitExceeded(
"Pending skill submissions cannot exceed 50 MiB of stored metadata and archives per user."
)
updated: Final = await transaction.litellm_skillsubmission.update(
where={"id": submission_id},
data={"proposal_json": proposal_json, "archive_content": archive, "scan_json": None},
)
return SubmissionMissing() if updated is None else _submission(updated)
async def withdraw(
self, submission_id: str, owner: str, revision: int
) -> SkillSubmission | SubmissionMissing | SubmissionConflict:
async with self._database.tx() as transaction:
row: Final = await _owned_pending(transaction, submission_id, owner, revision)
if isinstance(row, (SubmissionMissing, SubmissionConflict)):
return row
receipt: Final = _StoredProposal(
manifest={"name": row.plugin_name, "source": {}},
version=None,
revision=revision,
)
updated: Final = await transaction.litellm_skillsubmission.update(
where={"id": submission_id},
data={
"status": "withdrawn",
"proposal_json": receipt.model_dump_json(),
"archive_content": None,
"scan_json": None,
},
)
return SubmissionMissing() if updated is None else _submission(updated)
async def review(
self,
submission_id: str,
@ -291,6 +358,7 @@ class SkillSubmissionRepository:
reason: str | None,
*,
publication_base_url: str | None = None,
expected_revision: int | None = None,
) -> SubmissionReviewResult:
from prisma.errors import UniqueViolationError
@ -304,7 +372,9 @@ class SkillSubmissionRepository:
return SubmissionMissing()
submission: Final = _submission(row)
if submission.status != "pending_review":
return SubmissionConflict("This submission has already been reviewed.")
return SubmissionConflict("This submission is no longer pending review.")
if submission.revision != (expected_revision if expected_revision is not None else 0):
return SubmissionConflict("This submission changed. Reload it before reviewing.")
content: Final = None if row.archive_content is None else row.archive_content.decode()
if approve and content is not None and not publication_base_url:
return SubmissionConflict("Uploaded skill approval requires a trusted publication URL.")
@ -344,6 +414,20 @@ class SkillSubmissionRepository:
return SubmissionConflict("A skill with this name is already published. Submit a new update proposal.")
async def _owned_pending(
transaction: SubmissionTransaction, submission_id: str, owner: str, revision: int
) -> "prisma_models.LiteLLM_SkillSubmission | SubmissionMissing | SubmissionConflict":
await transaction.query_raw('SELECT id FROM "LiteLLM_SkillSubmission" WHERE id = $1 FOR UPDATE', submission_id)
row: Final = await transaction.litellm_skillsubmission.find_unique(where={"id": submission_id})
if row is None or not owner or row.submitted_by != owner:
return SubmissionMissing()
if row.status != "pending_review":
return SubmissionConflict("Only pending submissions can be edited or withdrawn.")
if _StoredProposal.model_validate_json(row.proposal_json).revision != revision:
return SubmissionConflict("This submission changed. Reload it before trying again.")
return row
async def _publish_submission(
transaction: PluginPublicationTransaction,
row: "prisma_models.LiteLLM_SkillSubmission",

View file

@ -156,6 +156,8 @@ class SkillHubSubmission(LiteLLMBaseModel):
reviewed_by: str | None
reviewed_at: datetime | None
rejection_reason: str | None
revision: int = Field(0, ge=0, description="Proposal revision used to prevent stale edits and reviews")
can_edit: bool = Field(False, description="Whether the caller may edit or withdraw this pending submission")
class SkillHubSubmissionList(LiteLLMBaseModel):
@ -173,6 +175,16 @@ class SkillHubSubmissionDetail(LiteLLMBaseModel):
current_skill: SkillHubSkill | None
class SkillHubSubmissionRevision(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
expected_revision: int = Field(ge=0, description="Revision read from the submission detail")
class SkillHubSubmissionEdit(SkillHubMetadata):
expected_revision: int = Field(ge=0, description="Revision read from the submission detail")
class SkillHubReviewResponse(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True)
@ -186,3 +198,4 @@ class SkillHubRejection(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
reason: str | None = Field(None, max_length=2000)
expected_revision: int | None = Field(None, ge=0)

View file

@ -31,6 +31,8 @@ const published: SkillHubSkill = {
const pending: SkillSubmission = {
id: "submission-1",
revision: 0,
can_edit: false,
skill_name: published.name,
operation: "update",
status: "pending_review",
@ -70,8 +72,9 @@ let reviewFailure: string | null;
let directPublication: boolean;
const respondToWrite = (path: string, body: RequestInit["body"]) => {
if (reviewFailure && path.startsWith("/skill-hub/submissions/"))
return Response.json({ detail: { error: reviewFailure } }, { status: 409 });
if (path.endsWith("/approve") || path.endsWith("/reject")) {
if (reviewFailure) return Response.json({ detail: { error: reviewFailure } }, { status: 409 });
const approved = path.endsWith("/approve");
const reason = typeof body === "string" ? JSON.parse(body).reason : null;
submissions = submissions.map((submission) =>
@ -88,6 +91,19 @@ const respondToWrite = (path: string, body: RequestInit["body"]) => {
};
return Response.json(result);
}
if (path === `/skill-hub/submissions/${pending.id}`) {
const metadata = typeof body === "string" ? JSON.parse(body) : {};
submissions = submissions.map((item) => ({
...item,
revision: (item.revision ?? 0) + 1,
proposal: { ...item.proposal, ...metadata },
}));
return Response.json(submissions[0]);
}
if (path.endsWith("/withdraw")) {
submissions = submissions.map((item) => ({ ...item, status: "withdrawn", can_edit: false }));
return Response.json(submissions[0]);
}
if (directPublication) {
catalog = [{ ...published, version: "3.0.0", description: "Admin-published instructions" }];
return Response.json(catalog[0]);
@ -573,7 +589,7 @@ it("saves an admin rejection reason for the author and keeps the published metad
expect(writes[0]).toMatchObject({
path: `/skill-hub/submissions/${pending.id}/reject`,
method: "PUT",
body: JSON.stringify({ reason: "Please include setup instructions" }),
body: JSON.stringify({ expected_revision: 0, reason: "Please include setup instructions" }),
});
fireEvent.click(screen.getByRole("tab", { name: "Published skills" }));
await waitFor(() =>
@ -792,3 +808,84 @@ it("applies tag and scan filters across the catalog and resets pagination when s
expect(catalogRequests.at(-1)?.searchParams.get("tag")).toBe("engineering");
expect(catalogRequests.at(-1)?.searchParams.get("security_scan_passed")).toBe("true");
});
it("lets an owner edit the viewed proposal and withdraw it with its current revision", async () => {
submissions = [{ ...pending, can_edit: true, revision: 3, proposal: { ...pending.proposal, version: "3.0.0" } }];
renderWithProviders(<ClaudeCodePluginsPanel accessToken="member-token" userRole="Internal User" />, {
searchParams: "tab=submissions",
});
fireEvent.click(await screen.findByRole("button", { name: "View details" }));
fireEvent.click(await screen.findByRole("button", { name: "Edit submission" }));
expect(screen.getByLabelText("Version override (optional)")).toHaveValue("3.0.0");
expect(
screen.getByText(
`Current version ${published.version}. Leave blank for the next patch version, or enter a higher version`,
),
).toBeInTheDocument();
fireEvent.change(await screen.findByLabelText("Description (Optional)"), {
target: { value: "Owner revised instructions" },
});
fireEvent.click(screen.getByRole("button", { name: "Save submission" }));
expect(await screen.findByRole("cell", { name: "Owner revised instructions" })).toBeInTheDocument();
expect(JSON.parse(String(writes[0].body))).toMatchObject({
expected_revision: 3,
description: "Owner revised instructions",
});
expect(catalog).toEqual([published]);
fireEvent.click(screen.getByRole("button", { name: "Withdraw submission" }));
expect(writes).toHaveLength(1);
fireEvent.click(await screen.findByRole("button", { name: "Confirm withdrawal" }));
expect(await screen.findByText("Withdrawn")).toBeInTheDocument();
expect(writes[1]).toMatchObject({
path: `/skill-hub/submissions/${pending.id}/withdraw`,
body: JSON.stringify({ expected_revision: 4 }),
});
fireEvent.click(screen.getByRole("button", { name: "View details" }));
expect(screen.queryByRole("button", { name: "Edit submission" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Withdraw submission" })).not.toBeInTheDocument();
});
it("keeps admin review separate from ownership and sends the displayed revision", async () => {
submissions = [{ ...pending, revision: 7, can_edit: false }];
renderSubmissions();
fireEvent.click(await screen.findByRole("button", { name: "Review" }));
expect(screen.queryByRole("button", { name: "Edit submission" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Withdraw submission" })).not.toBeInTheDocument();
fireEvent.click(await screen.findByRole("button", { name: "Approve and publish" }));
expect(await screen.findByText("Approved")).toBeInTheDocument();
expect(JSON.parse(String(writes[0].body))).toEqual({ expected_revision: 7 });
});
it.each(["metadata", "upload"])(
"reloads a conflicted %s edit only when the author discards the draft",
async (mode) => {
submissions = [{ ...pending, can_edit: true }];
renderWithProviders(<ClaudeCodePluginsPanel accessToken="member-token" userRole="Internal User" />, {
searchParams: "tab=submissions",
});
fireEvent.click(await screen.findByRole("button", { name: "View details" }));
fireEvent.click(await screen.findByRole("button", { name: "Edit submission" }));
fireEvent.change(screen.getByLabelText("Description (Optional)"), { target: { value: "Unsaved draft" } });
if (mode === "upload") {
fireEvent.click(screen.getByRole("radio", { name: "Upload ZIP" }));
fireEvent.change(screen.getByLabelText("Skill ZIP file"), {
target: { files: [new File(["replacement"], "skill.zip", { type: "application/zip" })] },
});
}
submissions = [
{ ...pending, can_edit: true, revision: 1, proposal: { ...pending.proposal, description: "Latest edit" } },
];
reviewFailure = "This submission changed. Reload it before trying again.";
fireEvent.click(screen.getByRole("button", { name: "Save submission" }));
expect(await screen.findByRole("alert")).toHaveTextContent(reviewFailure);
expect(screen.getByRole("dialog", { name: "Edit submission" })).toBeInTheDocument();
reviewFailure = null;
fireEvent.click(screen.getByRole("button", { name: "Discard draft and reload submission" }));
expect(await screen.findByRole("cell", { name: "Latest edit" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Edit submission" }));
expect(screen.getByLabelText("Description (Optional)")).toHaveValue("Latest edit");
fireEvent.click(screen.getByRole("button", { name: "Save submission" }));
await waitFor(() => expect(writes).toHaveLength(2));
expect(JSON.parse(String(writes[1].body))).toMatchObject({ expected_revision: 1, description: "Latest edit" });
},
);

View file

@ -245,6 +245,7 @@ const SkillsPanelContent: React.FC<ClaudeCodePluginsPanelProps> = ({
accessToken={accessToken}
queryScope={queryScope}
canReview={isAdmin}
canWrite={canWrite}
adminView={adminView}
onChanged={refreshSkills}
/>

View file

@ -7,6 +7,7 @@ import {
getSkillSubmission,
listSkillSubmissions,
reviewSkillSubmission,
withdrawSkillSubmission,
} from "@/components/networking";
import type { SkillHubSkill, SkillSubmission } from "@/components/claude_code_plugins/types";
import { Badge } from "@/components/ui/badge";
@ -16,6 +17,16 @@ import { Textarea } from "@/components/ui/textarea";
import { useUrlTableState, type UrlTableStateOptions } from "@/components/shared/DataTable/useUrlTableState";
import SkillScanPanel from "./SkillScanPanel";
import { toast } from "@/lib/toast";
import AddPluginForm from "./add_plugin_form";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
const METADATA_FIELDS = ["name", "version", "description", "source", "keywords", "domain", "namespace"] as const;
@ -28,6 +39,7 @@ const STATUS_CLASSES = {
pending_review: "border-warning/30 text-warning",
approved: "border-success/30 text-success",
rejected: "border-destructive/30 text-destructive",
withdrawn: "border-muted text-muted-foreground",
};
const SUBMISSION_TABLE_STATE: UrlTableStateOptions<never> = {
@ -82,51 +94,63 @@ function SubmissionSummary({ submission }: { submission: SkillSubmission }) {
function SubmissionComparison({
submission,
current,
onDownload,
downloading,
}: {
submission: SkillSubmission;
current?: SkillHubSkill | null;
onDownload: () => Promise<void>;
downloading: boolean;
}) {
const { t } = useTranslation();
return (
<div className="overflow-x-auto">
<table className="w-full table-fixed border-collapse text-sm">
<thead>
<tr className="border-b">
<th className="w-1/5 p-2 text-left">{t("skills.submissions.field")}</th>
<th className="p-2 text-left">{t("skills.submissions.current")}</th>
<th className="p-2 text-left">{t("skills.submissions.proposed")}</th>
</tr>
</thead>
<tbody>
{METADATA_FIELDS.map((field) => (
<tr key={field} className="border-b align-top">
<th className="p-2 text-left font-medium">{t(`skills.submissions.fields.${field}`)}</th>
<>
{!current && <p className="text-sm text-muted-foreground">{t("skills.submissions.noCurrent")}</p>}
<div className="overflow-x-auto">
<table className="w-full table-fixed border-collapse text-sm">
<thead>
<tr className="border-b">
<th className="w-1/5 p-2 text-left">{t("skills.submissions.field")}</th>
<th className="p-2 text-left">{t("skills.submissions.current")}</th>
<th className="p-2 text-left">{t("skills.submissions.proposed")}</th>
</tr>
</thead>
<tbody>
{METADATA_FIELDS.map((field) => (
<tr key={field} className="border-b align-top">
<th className="p-2 text-left font-medium">{t(`skills.submissions.fields.${field}`)}</th>
<td className="p-2">
<pre className="whitespace-pre-wrap break-words font-mono text-xs">
{displayValue(current?.[field]) || t("skills.submissions.notSet")}
</pre>
</td>
<td className="p-2">
<pre className="whitespace-pre-wrap break-words font-mono text-xs">
{field === "version" && submission.operation === "update" && !submission.proposal.version
? t("skills.submissions.nextPatchVersion")
: displayValue(submission.proposal[field]) || t("skills.submissions.notSet")}
</pre>
</td>
</tr>
))}
<tr className="border-b align-top">
<th className="p-2 text-left font-medium">{t("skills.submissions.fields.agent_skill")}</th>
<td className="p-2 text-muted-foreground">{t("skills.submissions.descriptorUnavailable")}</td>
<td className="p-2">
<pre className="whitespace-pre-wrap break-words font-mono text-xs">
{displayValue(current?.[field]) || t("skills.submissions.notSet")}
</pre>
</td>
<td className="p-2">
<pre className="whitespace-pre-wrap break-words font-mono text-xs">
{field === "version" && submission.operation === "update" && !submission.proposal.version
? t("skills.submissions.nextPatchVersion")
: displayValue(submission.proposal[field]) || t("skills.submissions.notSet")}
{displayValue(submission.proposal.agent_skill) || t("skills.submissions.notSet")}
</pre>
</td>
</tr>
))}
<tr className="border-b align-top">
<th className="p-2 text-left font-medium">{t("skills.submissions.fields.agent_skill")}</th>
<td className="p-2 text-muted-foreground">{t("skills.submissions.descriptorUnavailable")}</td>
<td className="p-2">
<pre className="whitespace-pre-wrap break-words font-mono text-xs">
{displayValue(submission.proposal.agent_skill) || t("skills.submissions.notSet")}
</pre>
</td>
</tr>
</tbody>
</table>
</div>
</tbody>
</table>
</div>
{submission.has_archive && submission.proposal.agent_skill && (
<Button variant="outline" onClick={onDownload} disabled={downloading}>
{t(downloading ? "skills.submissions.downloading" : "skills.submissions.download")}
</Button>
)}
</>
);
}
@ -135,33 +159,46 @@ interface SubmissionReviewProps {
accessToken: string;
queryScope: QueryKey;
canReview: boolean;
canWrite: boolean;
onClose: () => void;
onChanged: () => Promise<void>;
}
function SubmissionReview({ id, accessToken, queryScope, canReview, onClose, onChanged }: SubmissionReviewProps) {
function SubmissionReview({
id,
accessToken,
queryScope,
canReview,
canWrite,
onClose,
onChanged,
}: SubmissionReviewProps) {
const { t } = useTranslation();
const [reason, setReason] = useState("");
const [busy, setBusy] = useState(false);
const [downloading, setDownloading] = useState(false);
const [error, setError] = useState<string | null>(null);
const detail = useQuery({
const [editing, setEditing] = useState<SkillSubmission | null>(null);
const [withdrawing, setWithdrawing] = useState<SkillSubmission | null>(null);
const detailOptions = {
queryKey: [...queryScope, "submission", id],
queryFn: () => getSkillSubmission(accessToken, id),
});
refetchOnWindowFocus: false,
refetchOnReconnect: false,
};
const detail = useQuery(detailOptions);
const { submission, current_skill: current } = detail.data ?? {};
const canDecide = canReview && submission?.status === "pending_review";
const actionsDisabled = busy || detail.isFetching || detail.isError;
const review = async (action: "approve" | "reject") => {
if (!canDecide || busy) return;
if (!canDecide || !submission || busy) return;
setBusy(true);
setError(null);
try {
await reviewSkillSubmission(
accessToken,
id,
action,
action === "reject" ? { reason: reason.trim() || null } : undefined,
);
await reviewSkillSubmission(accessToken, id, action, {
expected_revision: submission.revision ?? 0,
...(action === "reject" ? { reason: reason.trim() || null } : {}),
});
onClose();
await onChanged();
toast.success(t(action === "approve" ? "skills.submissions.approved" : "skills.submissions.rejected"));
@ -172,6 +209,24 @@ function SubmissionReview({ id, accessToken, queryScope, canReview, onClose, onC
setBusy(false);
}
};
const withdraw = async () => {
if (!withdrawing || busy) return;
setBusy(true);
setError(null);
try {
await withdrawSkillSubmission(accessToken, withdrawing);
setWithdrawing(null);
onClose();
await onChanged();
toast.success(t("skills.submissions.withdrawn"));
} catch (err) {
setError(err instanceof Error ? err.message : t("skills.submissions.actionFailed"));
setWithdrawing(null);
await onChanged();
} finally {
setBusy(false);
}
};
const download = async () => {
if (!submission || downloading) return;
setDownloading(true);
@ -185,73 +240,111 @@ function SubmissionReview({ id, accessToken, queryScope, canReview, onClose, onC
}
};
return (
<Dialog open onOpenChange={(open) => !open && !busy && onClose()}>
<DialogContent className="top-8 max-h-[calc(100dvh-4rem)] translate-y-0 overflow-y-auto sm:max-w-[960px]">
<DialogHeader>
<DialogTitle>{t("skills.submissions.reviewTitle", { name: submission?.skill_name ?? "" })}</DialogTitle>
</DialogHeader>
{detail.isPending && <p>{t("skills.submissions.loading")}</p>}
{detail.isError && (
<div role="alert" className="text-destructive">
{t("skills.submissions.loadFailed")}
<Button variant="link" onClick={() => void detail.refetch()}>
{t("skills.submissions.retry")}
</Button>
</div>
)}
{submission && (
<>
<SubmissionSummary submission={submission} />
{!current && <p className="text-sm text-muted-foreground">{t("skills.submissions.noCurrent")}</p>}
<SubmissionComparison submission={submission} current={current} />
{submission.has_archive && submission.proposal.agent_skill && (
<Button variant="outline" onClick={download} disabled={downloading}>
{t(downloading ? "skills.submissions.downloading" : "skills.submissions.download")}
<>
<Dialog open={!editing && !withdrawing} onOpenChange={(open) => !open && !busy && onClose()}>
<DialogContent className="top-8 max-h-[calc(100dvh-4rem)] translate-y-0 overflow-y-auto sm:max-w-[960px]">
<DialogHeader>
<DialogTitle>{t("skills.submissions.reviewTitle", { name: submission?.skill_name ?? "" })}</DialogTitle>
</DialogHeader>
{detail.isPending && <p>{t("skills.submissions.loading")}</p>}
{detail.isError && (
<div role="alert" className="text-destructive">
{t("skills.submissions.loadFailed")}
<Button variant="link" onClick={() => void detail.refetch()}>
{t("skills.submissions.retry")}
</Button>
)}
{canReview && (
<SkillScanPanel
id={id}
accessToken={accessToken}
queryScope={queryScope}
pending={submission.status === "pending_review"}
</div>
)}
{submission && (
<>
<SubmissionSummary submission={submission} />
<SubmissionComparison
submission={submission}
current={current}
onDownload={download}
downloading={downloading}
/>
)}
{canDecide && (
<>
<label htmlFor="skill-rejection-reason" className="text-sm font-medium">
{t("skills.submissions.reasonLabel")}
</label>
<Textarea
id="skill-rejection-reason"
value={reason}
maxLength={2000}
onChange={(event) => setReason(event.target.value)}
disabled={busy}
/>
<div className="flex justify-end gap-2">
<Button
variant="destructive"
onClick={() => void review("reject")}
disabled={busy || detail.isFetching || detail.isError}
>
{t("skills.submissions.reject")}
{canWrite && submission.can_edit && (
<div className="flex gap-2">
<Button variant="outline" disabled={actionsDisabled} onClick={() => setEditing(submission)}>
{t("skills.submissions.edit")}
</Button>
<Button onClick={() => void review("approve")} disabled={busy || detail.isFetching || detail.isError}>
{t("skills.submissions.approve")}
<Button variant="destructive" disabled={actionsDisabled} onClick={() => setWithdrawing(submission)}>
{t("skills.submissions.withdraw")}
</Button>
</div>
</>
)}
</>
)}
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
</DialogContent>
</Dialog>
)}
{canReview && (
<SkillScanPanel
id={id}
accessToken={accessToken}
queryScope={queryScope}
pending={submission.status === "pending_review"}
/>
)}
{canDecide && (
<>
<label htmlFor="skill-rejection-reason" className="text-sm font-medium">
{t("skills.submissions.reasonLabel")}
</label>
<Textarea
id="skill-rejection-reason"
value={reason}
maxLength={2000}
onChange={(event) => setReason(event.target.value)}
disabled={busy}
/>
<div className="flex justify-end gap-2">
<Button variant="destructive" onClick={() => void review("reject")} disabled={actionsDisabled}>
{t("skills.submissions.reject")}
</Button>
<Button onClick={() => void review("approve")} disabled={actionsDisabled}>
{t("skills.submissions.approve")}
</Button>
</div>
</>
)}
</>
)}
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
</DialogContent>
</Dialog>
{editing && (
<AddPluginForm
key={`${editing.id}-${editing.revision}`}
visible
accessToken={accessToken}
skill={{ ...editing.proposal, id: editing.id, visibility: "private", security_scan_passed: false }}
submission={{
value: editing,
publishedVersion: current?.version ?? null,
onChanged: () => {
void onChanged();
},
}}
onClose={() => setEditing(null)}
requiresApproval
/>
)}
<AlertDialog open={Boolean(withdrawing)} onOpenChange={(open) => !open && !busy && setWithdrawing(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("skills.submissions.withdraw")}</AlertDialogTitle>
<AlertDialogDescription>{t("skills.submissions.withdrawNotice")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={busy}>{t("skills.addForm.cancel")}</AlertDialogCancel>
<Button variant="destructive" disabled={busy} onClick={() => void withdraw()}>
{t("skills.submissions.confirmWithdraw")}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@ -259,6 +352,7 @@ interface SkillSubmissionsProps {
accessToken: string;
queryScope: QueryKey;
canReview: boolean;
canWrite: boolean;
adminView: boolean;
onChanged: () => Promise<void>;
}
@ -267,6 +361,7 @@ export default function SkillSubmissions({
accessToken,
queryScope,
canReview,
canWrite,
adminView,
onChanged,
}: SkillSubmissionsProps) {
@ -358,6 +453,7 @@ export default function SkillSubmissions({
accessToken={accessToken}
queryScope={queryScope}
canReview={canReview}
canWrite={canWrite}
onClose={() => setSelectedId(null)}
onChanged={onChanged}
/>

View file

@ -3,7 +3,14 @@ import { useTranslation } from "react-i18next";
import { z } from "zod";
import i18n from "@/i18n/i18n";
import { toast } from "@/lib/toast";
import { importSkillHubSkill, updateSkillHubSkill, uploadSkillHubSkill } from "@/components/networking";
import { ApiError } from "@/lib/http/client";
import {
editSkillSubmission,
uploadSkillSubmission,
importSkillHubSkill,
updateSkillHubSkill,
uploadSkillHubSkill,
} from "@/components/networking";
import { FormField } from "@/components/shared/form/FormField";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
@ -13,7 +20,12 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Textarea } from "@/components/ui/textarea";
import { useZodForm } from "@/lib/forms/useZodForm";
import { isValidSemanticVersion } from "@/components/claude_code_plugins/helpers";
import { isPendingSkill, type SkillHubSkill, type SkillMutationResponse } from "@/components/claude_code_plugins/types";
import {
isPendingSkill,
type SkillHubSkill,
type SkillMutationResponse,
type SkillSubmission,
} from "@/components/claude_code_plugins/types";
import { buildSkillImport, buildSkillMetadata, skillFormValues } from "@/components/claude_code_plugins/skillForm";
const EDIT_SKILL_SOURCES = ["metadata", "upload"] as const;
@ -23,8 +35,9 @@ interface AddPluginFormProps {
visible: boolean;
onClose: () => void;
accessToken: string | null;
onSuccess: (response: SkillMutationResponse) => void;
onSuccess?: (response: SkillMutationResponse) => void;
skill?: SkillHubSkill;
submission?: { value: SkillSubmission; publishedVersion: string | null; onChanged: () => void };
requiresApproval?: boolean;
}
@ -65,23 +78,53 @@ const skillSchema = z.object(skillShape).superRefine((values, context) => {
}
});
function formLabels(editing: boolean, submission: boolean, requiresApproval: boolean) {
if (submission) return { title: "skills.submissions.edit", submit: "skills.submissions.save" } as const;
const directSubmit = editing ? "skills.addForm.save" : "skills.addForm.submit";
return {
title: editing ? "skills.addForm.editTitle" : "skills.addForm.title",
submit: requiresApproval ? "skills.addForm.submitReview" : directSubmit,
} as const;
}
export default function AddPluginForm({
visible,
onClose,
accessToken,
onSuccess,
skill,
submission,
requiresApproval = false,
}: AddPluginFormProps) {
const { t } = useTranslation();
const form = useZodForm(skillSchema, { defaultValues: skillFormValues(skill) });
const form = useZodForm(skillSchema, {
defaultValues: {
...skillFormValues(skill),
...(submission ? { version: submission.value.proposal.version ?? "" } : {}),
},
});
const mode = form.watch("mode");
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<Error | null>(null);
const conflictedSubmission = error instanceof ApiError && error.status === 409 ? submission : undefined;
const isSubmitting = form.formState.isSubmitting;
const submit = async (values: z.infer<typeof skillSchema>) => {
if (!accessToken) return;
setError(null);
try {
if (submission && skill) {
if (values.mode === "upload" && values.archiveFile) {
await uploadSkillSubmission(accessToken, submission.value, values.archiveFile, values.version);
} else {
await editSkillSubmission(accessToken, submission.value.id, {
...buildSkillMetadata(values, skill),
expected_revision: submission.value.revision ?? 0,
});
}
toast.success(t("skills.submissions.saved"));
submission.onChanged();
onClose();
return;
}
const write = () => {
if (values.mode === "upload" && values.archiveFile) {
return uploadSkillHubSkill(accessToken, values.archiveFile, values.version, skill);
@ -92,10 +135,10 @@ export default function AddPluginForm({
const response = await write();
const publishedMessage = skill ? "skills.addForm.toasts.updated" : "skills.addForm.toasts.registered";
toast.success(t(isPendingSkill(response) ? "skills.addForm.toasts.submitted" : publishedMessage));
onSuccess(response);
onSuccess?.(response);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : t("skills.addForm.toasts.registerFailed"));
setError(err instanceof Error ? err : new Error(t("skills.addForm.toasts.registerFailed")));
}
};
const changeMode = (value: string) => {
@ -105,15 +148,14 @@ export default function AddPluginForm({
form.clearErrors();
setError(null);
};
const directLabel = skill ? "skills.addForm.save" : "skills.addForm.submit";
const submitLabel = requiresApproval ? "skills.addForm.submitReview" : directLabel;
const labels = formLabels(Boolean(skill), Boolean(submission), requiresApproval);
const fields =
mode === "import" ? (["url", "skill", "ref", "path"] as const) : (["domain", "namespace", "keywords"] as const);
return (
<Dialog open={visible} onOpenChange={(open) => !open && !isSubmitting && onClose()}>
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>{t(skill ? "skills.addForm.editTitle" : "skills.addForm.title")}</DialogTitle>
<DialogTitle>{t(labels.title)}</DialogTitle>
</DialogHeader>
{skill && <p className="text-sm font-medium">{skill.name}</p>}
{requiresApproval && <p className="text-sm text-muted-foreground">{t("skills.addForm.approvalNotice")}</p>}
@ -176,7 +218,10 @@ export default function AddPluginForm({
label={t(skill ? "skills.addForm.versionOverride" : "skills.addForm.labels.version")}
description={
skill
? t("skills.addForm.versionContext", { version: skill.version ?? t("skills.submissions.notSet") })
? t("skills.addForm.versionContext", {
version:
(submission ? submission.publishedVersion : skill.version) ?? t("skills.submissions.notSet"),
})
: undefined
}
>
@ -186,15 +231,28 @@ export default function AddPluginForm({
</fieldset>
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
{error.message}
</p>
)}
<div className="flex justify-end gap-2">
{conflictedSubmission && (
<Button
type="button"
variant="outline"
onClick={() => {
conflictedSubmission.onChanged();
onClose();
}}
disabled={isSubmitting}
>
{t("skills.submissions.reload")}
</Button>
)}
<Button type="button" variant="outline" onClick={onClose} disabled={isSubmitting}>
{t("skills.addForm.cancel")}
</Button>
<Button type="submit" disabled={isSubmitting} aria-busy={isSubmitting}>
{t(isSubmitting ? "skills.addForm.saving" : submitLabel)}
{t(isSubmitting ? "skills.addForm.saving" : labels.submit)}
</Button>
</div>
</form>

View file

@ -6953,6 +6953,38 @@ export const reviewSkillSubmission = (
): Promise<components["schemas"]["SkillHubReviewResponse"]> =>
apiClient.put(`/skill-hub/submissions/${encodeURIComponent(id)}/${action}`, { accessToken, body });
export const editSkillSubmission = (
accessToken: string,
id: string,
body: components["schemas"]["SkillHubSubmissionEdit"],
): Promise<components["schemas"]["SkillHubSubmission"]> =>
apiClient.put(`/skill-hub/submissions/${encodeURIComponent(id)}`, { accessToken, body });
export const uploadSkillSubmission = (
accessToken: string,
submission: components["schemas"]["SkillHubSubmission"],
file: File,
version: string,
): Promise<components["schemas"]["SkillHubSubmission"]> => {
const body = new FormData();
body.append("file", file);
body.append("expected_revision", String(submission.revision ?? 0));
if (version.trim()) body.append("version", version.trim());
return apiClient.put(`/skill-hub/submissions/${encodeURIComponent(submission.id)}/upload`, {
accessToken,
rawBody: body,
});
};
export const withdrawSkillSubmission = (
accessToken: string,
submission: components["schemas"]["SkillHubSubmission"],
): Promise<components["schemas"]["SkillHubSubmission"]> =>
apiClient.put(`/skill-hub/submissions/${encodeURIComponent(submission.id)}/withdraw`, {
accessToken,
body: { expected_revision: submission.revision ?? 0 },
});
export const getSkillScan = (accessToken: string, id: string): Promise<components["schemas"]["SkillScanResponse"]> =>
apiClient.get(`/skill-hub/submissions/${encodeURIComponent(id)}/scan`, { accessToken });

View file

@ -1926,7 +1926,8 @@
"status": {
"pending_review": "Pending review",
"approved": "Approved",
"rejected": "Rejected"
"rejected": "Rejected",
"withdrawn": "Withdrawn"
},
"operation": {
"create": "New skill",
@ -1947,7 +1948,15 @@
"agent_skill": "Portable skill descriptor"
},
"nextPatchVersion": "Next patch version on approval",
"descriptorUnavailable": "The current portable descriptor is not included in this response"
"descriptorUnavailable": "The current portable descriptor is not included in this response",
"edit": "Edit submission",
"save": "Save submission",
"saved": "Submission updated and still awaiting review",
"reload": "Discard draft and reload submission",
"withdraw": "Withdraw submission",
"withdrawNotice": "This removes the unpublished files and proposal details and prevents approval. A withdrawal record remains. You can submit a new proposal later.",
"confirmWithdraw": "Confirm withdrawal",
"withdrawn": "Submission withdrawn"
},
"detail": {
"subtitle": "Published skill metadata and portable archive",

View file

@ -16152,10 +16152,14 @@ export interface paths {
};
/**
* Get Submission
* @description Read an immutable proposal and its currently visible published skill.
* @description Read a proposal revision and its currently visible published skill.
*/
get: operations["get_submission_skill_hub_submissions__submission_id__get"];
put?: never;
/**
* Edit Submission
* @description Replace a pending proposal's metadata, preserving files. Only its original submitting user may edit.
*/
put: operations["edit_submission_skill_hub_submissions__submission_id__put"];
post?: never;
delete?: never;
options?: never;
@ -16247,6 +16251,46 @@ export interface paths {
patch?: never;
trace?: never;
};
"/skill-hub/submissions/{submission_id}/upload": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/**
* Edit Submission Archive
* @description Replace a pending proposal's files using the upload limits. Only its original submitting user may edit.
*/
put: operations["edit_submission_archive_skill_hub_submissions__submission_id__upload_put"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/skill-hub/submissions/{submission_id}/withdraw": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/**
* Withdraw Submission
* @description Withdraw a pending proposal without publishing it. Only its original submitting user may withdraw.
*/
put: operations["withdraw_submission_skill_hub_submissions__submission_id__withdraw_put"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/spend/calculate": {
parameters: {
query?: never;
@ -28837,6 +28881,18 @@ export interface components {
*/
target_storage: string;
};
/** Body_edit_submission_archive_skill_hub_submissions__submission_id__upload_put */
Body_edit_submission_archive_skill_hub_submissions__submission_id__upload_put: {
/** Expected Revision */
expected_revision: number;
/**
* File
* @description Replacement Agent Skills ZIP with the same declared skill name
*/
file: string;
/** Version */
version?: string | null;
};
/** Body_image_edit_api_images_edits_post */
Body_image_edit_api_images_edits_post: {
/** Image */
@ -47588,6 +47644,8 @@ export interface components {
SkillHubPublication: Record<string, never>;
/** SkillHubRejection */
SkillHubRejection: {
/** Expected Revision */
expected_revision?: number | null;
/** Reason */
reason?: string | null;
};
@ -47681,6 +47739,12 @@ export interface components {
base_skill_id: string | null;
/** Base Updated At */
base_updated_at: string | null;
/**
* Can Edit
* @description Whether the caller may edit or withdraw this pending submission
* @default false
*/
can_edit: boolean;
/**
* Created At
* Format: date-time
@ -47702,13 +47766,19 @@ export interface components {
reviewed_at: string | null;
/** Reviewed By */
reviewed_by: string | null;
/**
* Revision
* @description Proposal revision used to prevent stale edits and reviews
* @default 0
*/
revision: number;
/** Skill Name */
skill_name: string;
/**
* Status
* @enum {string}
*/
status: "pending_review" | "approved" | "rejected";
status: "pending_review" | "approved" | "rejected" | "withdrawn";
/** Submitted By */
submitted_by: string;
};
@ -47717,6 +47787,32 @@ export interface components {
current_skill: components["schemas"]["SkillHubSkill"] | null;
submission: components["schemas"]["SkillHubSubmission"];
};
/** SkillHubSubmissionEdit */
SkillHubSubmissionEdit: {
/** Description */
description?: string | null;
/** Domain */
domain?: string | null;
/**
* Expected Revision
* @description Revision read from the submission detail
*/
expected_revision: number;
/**
* Expected Updated At
* @description Published updated_at read by the editor. A stale value returns 409
*/
expected_updated_at?: string | null;
/** Keywords */
keywords?: string[] | null;
/** Namespace */
namespace?: string | null;
/**
* Version
* @description Explicit higher SemVer, or omit/null for an automatic patch bump
*/
version?: string | null;
};
/** SkillHubSubmissionList */
SkillHubSubmissionList: {
/** Count */
@ -47726,6 +47822,14 @@ export interface components {
/** Submissions */
submissions: components["schemas"]["SkillHubSubmission"][];
};
/** SkillHubSubmissionRevision */
SkillHubSubmissionRevision: {
/**
* Expected Revision
* @description Revision read from the submission detail
*/
expected_revision: number;
};
/** SkillHubVersion */
SkillHubVersion: {
/**
@ -76029,7 +76133,7 @@ export interface operations {
list_submissions_skill_hub_submissions_get: {
parameters: {
query?: {
status?: ("pending_review" | "approved" | "rejected") | null;
status?: ("pending_review" | "approved" | "rejected" | "withdrawn") | null;
limit?: number;
offset?: number;
};
@ -76090,6 +76194,41 @@ export interface operations {
};
};
};
edit_submission_skill_hub_submissions__submission_id__put: {
parameters: {
query?: never;
header?: never;
path: {
submission_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SkillHubSubmissionEdit"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SkillHubSubmission"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
approve_submission_skill_hub_submissions__submission_id__approve_put: {
parameters: {
query?: never;
@ -76099,7 +76238,11 @@ export interface operations {
};
cookie?: never;
};
requestBody?: never;
requestBody?: {
content: {
"application/json": components["schemas"]["SkillHubSubmissionRevision"] | null;
};
};
responses: {
/** @description Successful Response */
200: {
@ -76253,6 +76396,76 @@ export interface operations {
};
};
};
edit_submission_archive_skill_hub_submissions__submission_id__upload_put: {
parameters: {
query?: never;
header?: never;
path: {
submission_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": components["schemas"]["Body_edit_submission_archive_skill_hub_submissions__submission_id__upload_put"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SkillHubSubmission"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
withdraw_submission_skill_hub_submissions__submission_id__withdraw_put: {
parameters: {
query?: never;
header?: never;
path: {
submission_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SkillHubSubmissionRevision"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SkillHubSubmission"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
calculate_spend_spend_calculate_post: {
parameters: {
query?: never;

View file

@ -459,3 +459,60 @@ async def test_metadata_scan_requires_the_matching_managed_archive(
else:
assert isinstance(result, SubmissionConflict)
assert await case.submissions.get_scan(submission.id) is None
async def test_owner_edits_preserve_base_and_require_review_of_new_revision(pending_update: PendingUpdate) -> None:
case: Final = pending_update
before: Final = _published_rows(case.original.name)
replacement: Final = _publication(case.original.name, "Edited proposal instructions")
edited: Final = await case.submissions.edit(case.submission.id, case.submission.submitted_by, 0, replacement)
assert isinstance(edited, SkillSubmission)
assert (edited.revision, edited.submitted_by, edited.created_at, edited.base_updated_at) == (
1, case.submission.submitted_by, case.submission.created_at, case.submission.base_updated_at,
)
assert await case.submissions.get_archive(edited.id, edited.submitted_by) == replacement.content
assert _published_rows(case.original.name) == before
for revision in (None, 0):
stale: Final = await case.submissions.review(edited.id, "reviewer", True, None,
publication_base_url="https://skills.example.test", expected_revision=revision)
assert isinstance(stale, SubmissionConflict)
reviewed: Final = await case.submissions.review(edited.id, "reviewer", True, None,
publication_base_url="https://skills.example.test", expected_revision=edited.revision)
assert isinstance(reviewed, SubmissionReviewed)
current: Final = await case.publications.get_metadata(case.original.name)
assert current is not None
assert current.description == replacement.description
assert await case.publications.get_content(current) == replacement.content
@pytest.mark.parametrize("action", ["edit", "withdraw"])
async def test_owner_mutation_racing_approval_has_one_winner(pending_update: PendingUpdate, action: str) -> None:
case: Final = pending_update
before: Final = _published_rows(case.original.name)
with psycopg.connect(os.environ["DATABASE_URL"]) as blocker:
blocker.execute('SELECT id FROM "LiteLLM_SkillSubmission" WHERE id=%s FOR UPDATE', (case.submission.id,))
blocker_pid: Final = str(blocker.info.backend_pid)
requests: Final = (
asyncio.create_task(case.submissions.edit(case.submission.id, case.submission.submitted_by, 0,
_publication(case.original.name, "Concurrent owner edit"))) if action == "edit" else
asyncio.create_task(case.submissions.withdraw(case.submission.id, case.submission.submitted_by, 0)),
asyncio.create_task(case.submissions.review(case.submission.id, "reviewer", True, None,
publication_base_url="https://skills.example.test", expected_revision=0)),
)
try:
await asyncio.to_thread(eventually, lambda: read_rows(
"""WITH RECURSIVE blocked AS (
SELECT %s::integer AS pid UNION SELECT a.pid FROM pg_stat_activity a
JOIN blocked b ON b.pid = ANY(pg_blocking_pids(a.pid))
) SELECT pid FROM blocked WHERE pid<>%s::integer""", (blocker_pid, blocker_pid)),
lambda rows: len(rows) == 2, seconds=3)
finally:
blocker.rollback()
results: Final = await asyncio.gather(*requests)
assert sum(isinstance(result, SubmissionConflict) for result in results) == 1
if isinstance(results[0], SkillSubmission):
assert results[0].status == ("pending_review" if action == "edit" else "withdrawn")
assert _published_rows(case.original.name) == before
else:
assert isinstance(results[1], SubmissionReviewed)
assert (await case.submissions.get(case.submission.id, None)).status == "approved"

View file

@ -704,6 +704,15 @@ async def test_member_updates_preserve_manifest_and_original_editor_base(pending
assert detail["submission"]["proposal"]["domain"] == "Tools"
assert detail["submission"]["proposal"]["keywords"] == ["notes"]
assert detail["current_skill"]["namespace"] == "research"
for version in ("1.0.0", original.version):
invalid: Final = await hub.client.put(
path + ("/upload" if replacement else ""), headers=_headers(MEMBER_KEY),
**({"data": {"expected_revision": "0", "version": version},
"files": {"file": ("new.zip", _zip(updated_files), "application/zip")}}
if replacement else {"json": {"expected_revision": 0, "version": version}}),
)
assert invalid.status_code == 400, invalid.text
assert (await hub.client.get(path, headers=_headers(MEMBER_KEY))).json() == detail
assert hub.database.litellm_claudecodepluginversion.create.await_count == before
hub.plugins.update.assert_not_awaited()
approved: Final = await hub.client.put(path + "/approve", headers=_headers(MASTER_KEY))
@ -1045,3 +1054,115 @@ async def test_restore_checks_the_snapshot_again_under_the_publication_lock(hub:
)
assert response.status_code == 409, response.text
hub.plugins.update.assert_not_awaited()
@pytest.mark.parametrize("key,expected", [(OTHER_KEY, 404), (MASTER_KEY, 404), (VIEWER_KEY, 403), (PLAIN_KEY, 403)])
async def test_only_original_submitter_can_edit_or_withdraw(pending_hub: HubClient, key: str, expected: int) -> None:
hub: Final = pending_hub
created: Final = await _upload(hub, _zip(FILES), MEMBER_KEY)
path: Final = f"/skill-hub/submissions/{created.json()['submission_id']}"
for suffix in ("", "/upload", "/withdraw"):
response: Final = await hub.client.put(
path + suffix, headers=_headers(key),
**({"data": {"expected_revision": "0"}, "files": {"file": ("edit.zip", _zip(FILES))}}
if suffix == "/upload" else {"json": {"expected_revision": 0}}),
)
assert response.status_code == expected, response.text
own: Final = (await hub.client.get(path, headers=_headers(MEMBER_KEY))).json()["submission"]
assert (own["revision"], own["status"], own["can_edit"]) == (0, "pending_review", True)
admin: Final = (await hub.client.get(path, headers=_headers(MASTER_KEY))).json()["submission"]
assert admin["can_edit"] is False
hub.plugins.create.assert_not_awaited()
async def test_pending_edits_preserve_owner_and_files_until_fresh_review(pending_hub: HubClient) -> None:
hub: Final = pending_hub
created: Final = await _upload(hub, _zip(FILES), MEMBER_KEY)
path: Final = f"/skill-hub/submissions/{created.json()['submission_id']}"
before: Final = (await hub.client.get(path, headers=_headers(MEMBER_KEY))).json()["submission"]
edited: Final = await hub.client.put(path, headers=_headers(MEMBER_KEY), json={
"expected_revision": 0, "description": "Revised metadata", "version": "3.0.0", "keywords": ["review"],
})
assert edited.status_code == 200, edited.text
result: Final = edited.json()
assert (result["revision"], result["submitted_by"], result["created_at"], result["base_updated_at"]) == (
1, before["submitted_by"], before["created_at"], before["base_updated_at"],
)
assert result["proposal"]["description"] == "Revised metadata"
_assert_portable(await hub.client.get(path + "/archive", headers=_headers(MEMBER_KEY)))
for body in ({}, {"expected_revision": 0}):
stale: Final = await hub.client.put(path + "/approve", headers=_headers(MASTER_KEY), json=body or None)
assert stale.status_code == 409, stale.text
assert (await hub.client.put(path, headers=_headers(MEMBER_KEY), json={"expected_revision": 0})).status_code == 409
hub.plugins.create.assert_not_awaited()
approved: Final = await hub.client.put(path + "/approve", headers=_headers(MASTER_KEY), json={"expected_revision": 1})
assert approved.status_code == 200, approved.text
assert approved.json()["skill"]["description"] == "Revised metadata"
assert approved.json()["skill"]["version"] == "3.0.0"
for suffix in ("", "/withdraw"):
assert (await hub.client.put(path + suffix, headers=_headers(MEMBER_KEY), json={"expected_revision": 1})).status_code == 409
async def test_withdrawn_submission_cannot_be_edited_reviewed_or_published(pending_hub: HubClient) -> None:
hub: Final = pending_hub
created: Final = await _upload(hub, _zip(FILES), MEMBER_KEY)
path: Final = f"/skill-hub/submissions/{created.json()['submission_id']}"
withdrawn: Final = await hub.client.put(path + "/withdraw", headers=_headers(MEMBER_KEY), json={"expected_revision": 0})
assert withdrawn.status_code == 200, withdrawn.text
assert (withdrawn.json()["status"], withdrawn.json()["can_edit"], withdrawn.json()["reviewed_by"]) == ("withdrawn", False, None)
assert withdrawn.json()["has_archive"] is False
assert (await hub.client.get(path + "/archive", headers=_headers(MEMBER_KEY))).status_code == 404
for suffix, key in (("", MEMBER_KEY), ("/withdraw", MEMBER_KEY), ("/approve", MASTER_KEY), ("/reject", MASTER_KEY)):
response: Final = await hub.client.put(path + suffix, headers=_headers(key), json={"expected_revision": 0})
assert response.status_code == 409, response.text
listed: Final = await hub.client.get("/skill-hub/submissions?status=withdrawn", headers=_headers(MEMBER_KEY))
assert listed.json()["count"] == 1
hub.plugins.create.assert_not_awaited()
async def test_pending_archive_edit_revalidates_and_clears_scan(pending_hub: HubClient) -> None:
hub: Final = pending_hub
created: Final = await _upload(hub, _zip(FILES), MEMBER_KEY)
identifier: Final = created.json()["submission_id"]
path: Final = f"/skill-hub/submissions/{identifier}"
await hub.database.litellm_skillsubmission.update(where={"id": identifier}, data={"scan_json": "old scan"})
invalid: Final = await hub.client.put(path + "/upload", headers=_headers(MEMBER_KEY),
data={"expected_revision": "0"}, files={"file": ("invalid.zip", b"invalid")})
assert invalid.status_code == 422, invalid.text
updated_files: Final = {**FILES, "references/guide.md": b"Revised pending instructions.\n"}
edited: Final = await hub.client.put(path + "/upload", headers=_headers(MEMBER_KEY),
data={"expected_revision": "0", "version": "2.1.0"}, files={"file": ("edit.zip", _zip(updated_files))})
assert edited.status_code == 200, edited.text
assert edited.json()["revision"] == 1
stored: Final = await hub.database.litellm_skillsubmission.find_unique(where={"id": identifier})
assert stored.scan_json is None
archive: Final = await hub.client.get(path + "/archive", headers=_headers(MEMBER_KEY))
with zipfile.ZipFile(io.BytesIO(archive.content)) as content:
assert content.read("references/guide.md") == updated_files["references/guide.md"]
hub.plugins.create.assert_not_awaited()
@pytest.mark.parametrize("published_version", (None, "legacy"))
@pytest.mark.parametrize("replacement", (False, True), ids=("metadata", "archive"))
async def test_pending_legacy_update_keeps_explicit_version_requirement(
pending_hub: HubClient, published_version: str | None, replacement: bool,
) -> None:
hub: Final = pending_hub
assert (await _upload(hub, _zip(FILES))).status_code == 200
original: Final = _stored_upload(hub)
hub.plugins.find_unique.return_value = original.model_copy(update={"version": published_version})
created: Final = await hub.client.put(
f"{HUB}/{NAME}", headers=_headers(MEMBER_KEY), json={"version": "3.0.0"},
)
assert created.status_code == 200, created.text
path: Final = f"/skill-hub/submissions/{created.json()['submission_id']}"
before: Final = (await hub.client.get(path, headers=_headers(MEMBER_KEY))).json()
edited: Final = await hub.client.put(
path + ("/upload" if replacement else ""), headers=_headers(MEMBER_KEY),
**({"data": {"expected_revision": "0"}, "files": {"file": ("new.zip", _zip(FILES))}}
if replacement else {"json": {"expected_revision": 0}}),
)
assert edited.status_code == 409, edited.text
assert "explicit semantic version" in edited.json()["detail"]
assert (await hub.client.get(path, headers=_headers(MEMBER_KEY))).json() == before
hub.plugins.update.assert_not_awaited()

View file

@ -255,6 +255,7 @@ def test_operator_request_size_limit_caps_the_decoded_body_when_it_is_lower(
("POST", "/claude-code/plugins/upload"),
("POST", "/skill-hub/skills/upload"),
("PUT", "/skill-hub/skills/example/upload"),
("PUT", "/skill-hub/submissions/absent/upload"),
])
def test_proxy_bounds_skill_uploads_before_authentication_or_body_parsing(
coding: str, compress: Callable[[bytes], bytes], method: str, path: str,

View file

@ -215,6 +215,7 @@ def test_an_oversized_messages_request_gets_the_anthropic_request_too_large_erro
("POST", "/skill-hub/skills/upload", "/skill-hub/skills/upload"),
("POST", "/chat/completions", "/chat/completions"),
("PUT", "/skill-hub/skills/example/upload", "/skill-hub/skills/{name}/upload"),
("PUT", "/skill-hub/submissions/absent/upload", "/skill-hub/submissions/{submission_id}/upload"),
])
async def test_chunked_upload_overflow_stops_fastapi_and_auth_multipart_parsing(
method: str, path: str, route: str,