From 10b1e8328eb0bf0c50b05714e3184f2aa6809fcc Mon Sep 17 00:00:00 2001 From: yunpeng7 Date: Tue, 23 Jun 2026 16:11:52 +0800 Subject: [PATCH 1/8] feat: add system plugin runtime variants --- AGENTS.md | 2 + backend/app/api/endpoints/admin/plugins.py | 132 +++ backend/app/api/endpoints/admin/router.py | 2 + .../app/api/endpoints/installed_plugins.py | 52 ++ backend/app/schemas/installed_plugin.py | 58 +- .../device/capability_sync_service.py | 1 + .../app/services/installed_plugin_service.py | 610 +++++++++++++- .../tests/api/endpoints/test_plugins_api.py | 175 ++++ .../device/test_capability_sync_service.py | 2 + .../services/test_installed_plugin_upload.py | 136 ++- .../system-plugin-management.md | 73 ++ .../system-plugin-management.md | 73 ++ executor/modes/local/capabilities.py | 96 ++- .../test_local_capability_sync_handler.py | 99 +++ .../components/SystemPluginList.test.tsx | 199 +++++ frontend/src/apis/admin.ts | 155 ++++ frontend/src/app/admin/page.tsx | 6 + .../features/admin/components/AdminTabNav.tsx | 3 + .../admin/components/SystemPluginList.tsx | 591 +++++++++++++ frontend/src/i18n/locales/en/admin.json | 62 ++ frontend/src/i18n/locales/zh-CN/admin.json | 62 ++ wework/src/api/adapters.test.ts | 14 - wework/src/api/plugins.ts | 18 +- .../components/plugins/PluginCreateMenu.tsx | 17 +- .../PluginManagementWorkspace.test.tsx | 64 ++ .../plugins/PluginManagementWorkspace.tsx | 338 +++----- .../plugins/PluginUploadDialog.test.tsx | 44 - .../components/plugins/PluginUploadDialog.tsx | 138 ---- .../plugins/PluginsWorkspace.test.tsx | 334 +++++--- .../components/plugins/PluginsWorkspace.tsx | 779 +++++++++--------- wework/src/i18n/locales/en/common.json | 11 +- wework/src/i18n/locales/zh-CN/common.json | 11 +- wework/src/types/api.ts | 28 +- 33 files changed, 3343 insertions(+), 1042 deletions(-) create mode 100644 backend/app/api/endpoints/admin/plugins.py create mode 100644 backend/tests/api/endpoints/test_plugins_api.py create mode 100644 docs/en/developer-guide/system-plugin-management.md create mode 100644 docs/zh/developer-guide/system-plugin-management.md create mode 100644 frontend/src/__tests__/features/admin/components/SystemPluginList.test.tsx create mode 100644 frontend/src/features/admin/components/SystemPluginList.tsx create mode 100644 wework/src/components/plugins/PluginManagementWorkspace.test.tsx delete mode 100644 wework/src/components/plugins/PluginUploadDialog.test.tsx delete mode 100644 wework/src/components/plugins/PluginUploadDialog.tsx diff --git a/AGENTS.md b/AGENTS.md index a0e0455a41..c94b7750f2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,8 @@ Wegent is an open-source AI-native operating system for defining, organizing, an - English version: [`docs/en/developer-guide/shared-skill-directory.md`](docs/en/developer-guide/shared-skill-directory.md) - Runtime local work (Project -> Device Workspace -> LocalTask without DB task rows): [`docs/zh/developer-guide/runtime-local-work.md`](docs/zh/developer-guide/runtime-local-work.md) - English version: [`docs/en/developer-guide/runtime-local-work.md`](docs/en/developer-guide/runtime-local-work.md) +- System plugin management (admin-managed plugin catalog and Wework manual updates): [`docs/zh/developer-guide/system-plugin-management.md`](docs/zh/developer-guide/system-plugin-management.md) +- English version: [`docs/en/developer-guide/system-plugin-management.md`](docs/en/developer-guide/system-plugin-management.md) **📚 Documentation Writing Rules:** - All documentation files MUST include frontmatter with `sidebar_position` for ordering: diff --git a/backend/app/api/endpoints/admin/plugins.py b/backend/app/api/endpoints/admin/plugins.py new file mode 100644 index 0000000000..dbc6c4693d --- /dev/null +++ b/backend/app/api/endpoints/admin/plugins.py @@ -0,0 +1,132 @@ +# SPDX-FileCopyrightText: 2026 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +import logging + +from fastapi import APIRouter, Depends, File, Form, UploadFile, status +from sqlalchemy.orm import Session + +from app.api.dependencies import get_db +from app.core.security import get_admin_user +from app.models.user import User +from app.schemas.installed_plugin import ( + PluginRuntime, + SystemPlugin, + SystemPluginListResponse, + SystemPluginUpdateRequest, +) +from app.services.claude_plugin_parser import MAX_PLUGIN_PACKAGE_SIZE_BYTES +from app.services.installed_plugin_service import installed_plugin_service + +router = APIRouter() +logger = logging.getLogger(__name__) +PLUGIN_UPLOAD_CHUNK_SIZE_BYTES = 1024 * 1024 + + +@router.get("/plugins", response_model=SystemPluginListResponse) +async def list_system_plugins( + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user), +) -> SystemPluginListResponse: + """List system-managed Claude Code plugins.""" + return installed_plugin_service.list_system_plugins(db=db) + + +@router.post( + "/plugins", + response_model=SystemPlugin, + status_code=status.HTTP_201_CREATED, +) +async def upload_system_plugin( + file: UploadFile = File(...), + enabled: bool = Form(True), + runtime: PluginRuntime = Form("claudecode"), + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user), +) -> SystemPlugin: + """Upload or replace a system-managed Claude Code plugin package.""" + logger.info( + "System plugin upload requested: admin_id=%s filename=%s enabled=%s runtime=%s", + current_user.id, + file.filename, + enabled, + runtime, + ) + package_bytes = await _read_plugin_upload(file) + return installed_plugin_service.upload_system_plugin( + db=db, + package_bytes=package_bytes, + filename=file.filename or "plugin.zip", + enabled=enabled, + runtime=runtime, + ) + + +@router.put("/plugins/{system_plugin_id}", response_model=SystemPlugin) +async def update_system_plugin( + system_plugin_id: int, + request: SystemPluginUpdateRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user), +) -> SystemPlugin: + """Update system plugin display metadata and visibility.""" + return installed_plugin_service.update_system_plugin_metadata( + db=db, + system_plugin_id=system_plugin_id, + display_name=request.displayName, + description=request.description, + enabled=request.enabled, + ) + + +@router.put("/plugins/{system_plugin_id}/package", response_model=SystemPlugin) +async def replace_system_plugin_package( + system_plugin_id: int, + file: UploadFile = File(...), + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user), +) -> SystemPlugin: + """Replace a system plugin ZIP package with a new version.""" + logger.info( + "System plugin package replacement requested: admin_id=%s plugin_id=%s filename=%s", + current_user.id, + system_plugin_id, + file.filename, + ) + package_bytes = await _read_plugin_upload(file) + return installed_plugin_service.replace_system_plugin_package( + db=db, + system_plugin_id=system_plugin_id, + package_bytes=package_bytes, + filename=file.filename or "plugin.zip", + ) + + +@router.delete("/plugins/{system_plugin_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_system_plugin( + system_plugin_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user), +) -> None: + """Remove a system-managed plugin from the user-facing catalog.""" + installed_plugin_service.delete_system_plugin( + db=db, + system_plugin_id=system_plugin_id, + ) + + +async def _read_plugin_upload(file: UploadFile) -> bytes: + chunks: list[bytes] = [] + total_size = 0 + while True: + chunk = await file.read(PLUGIN_UPLOAD_CHUNK_SIZE_BYTES) + if not chunk: + break + total_size += len(chunk) + if total_size > MAX_PLUGIN_PACKAGE_SIZE_BYTES: + from fastapi import HTTPException + + raise HTTPException(status_code=413, detail="Plugin package is too large") + chunks.append(chunk) + return b"".join(chunks) diff --git a/backend/app/api/endpoints/admin/router.py b/backend/app/api/endpoints/admin/router.py index 45383deaaf..1e3780a4a0 100644 --- a/backend/app/api/endpoints/admin/router.py +++ b/backend/app/api/endpoints/admin/router.py @@ -11,6 +11,7 @@ device_monitor, im_channels, kind_management, + plugins, public_bots, public_ghosts, public_models, @@ -48,3 +49,4 @@ router.include_router(device_monitor.router, tags=["admin-device-monitor"]) router.include_router(templates.router, tags=["admin-templates"]) router.include_router(runtime_cleanup.router, tags=["admin-runtime-cleanup"]) +router.include_router(plugins.router, tags=["admin-plugins"]) diff --git a/backend/app/api/endpoints/installed_plugins.py b/backend/app/api/endpoints/installed_plugins.py index a3bd5868b2..e36bae290c 100644 --- a/backend/app/api/endpoints/installed_plugins.py +++ b/backend/app/api/endpoints/installed_plugins.py @@ -17,6 +17,7 @@ InstalledPlugin, InstalledPluginListResponse, InstalledPluginUpdateRequest, + PluginCatalogListResponse, ) from app.services.claude_plugin_parser import MAX_PLUGIN_PACKAGE_SIZE_BYTES from app.services.device.capability_sync_service import device_capability_sync_service @@ -39,6 +40,57 @@ def list_installed_plugins( ) +@router.get("/catalog", response_model=PluginCatalogListResponse) +def list_system_plugin_catalog( + db: Session = Depends(get_db), + current_user: User = Depends(security.get_current_user), +) -> PluginCatalogListResponse: + """List system-managed plugins available to the current user.""" + return installed_plugin_service.list_system_plugin_catalog( + db=db, + user_id=current_user.id, + ) + + +@router.post( + "/catalog/{system_plugin_id}/install", + response_model=InstalledPluginListResponse, + status_code=status.HTTP_201_CREATED, +) +async def install_system_plugin( + system_plugin_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(security.get_current_user), +) -> InstalledPluginListResponse: + """Install a system-managed plugin into the current user's inventory.""" + installed = installed_plugin_service.install_system_plugin( + db=db, + user_id=current_user.id, + system_plugin_id=system_plugin_id, + ) + await _sync_global_capabilities(db, current_user.id) + return installed + + +@router.post( + "/catalog/{system_plugin_id}/update", + response_model=InstalledPluginListResponse, +) +async def update_system_plugin_installation( + system_plugin_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(security.get_current_user), +) -> InstalledPluginListResponse: + """Manually update the current user's installation from the system catalog.""" + installed = installed_plugin_service.update_installed_plugin_from_system( + db=db, + user_id=current_user.id, + system_plugin_id=system_plugin_id, + ) + await _sync_global_capabilities(db, current_user.id) + return installed + + @router.post( "/upload", response_model=InstalledPlugin, diff --git a/backend/app/schemas/installed_plugin.py b/backend/app/schemas/installed_plugin.py index 76dc81cca4..ebac36bb5f 100644 --- a/backend/app/schemas/installed_plugin.py +++ b/backend/app/schemas/installed_plugin.py @@ -15,6 +15,8 @@ "uninstalled", ] +PluginRuntime = Literal["claudecode", "codex"] + class PluginSkillComponent(BaseModel): """Skill metadata discovered inside a Claude Code plugin.""" @@ -55,11 +57,12 @@ class InstalledPluginComponents(BaseModel): class InstalledPluginSource(BaseModel): """Source identity for a user-installed Claude Code plugin.""" - type: Literal["upload", "marketplace", "local"] = "upload" + type: Literal["upload", "marketplace", "local", "system"] = "upload" providerKey: str = "claude-code" pluginKey: str catalogItemId: Optional[str] = None marketplace: Optional[str] = None + systemPluginId: Optional[int] = None class InstalledPluginPackageRef(BaseModel): @@ -74,6 +77,7 @@ class InstalledPluginSpec(BaseModel): """User-scoped Claude Code plugin installation state.""" source: InstalledPluginSource + runtime: PluginRuntime = "claudecode" displayName: str description: str = "" version: Optional[str] = None @@ -111,6 +115,50 @@ class InstalledPluginListResponse(BaseModel): items: List[InstalledPlugin] +class SystemPlugin(BaseModel): + """System-managed Claude Code plugin catalog item.""" + + apiVersion: str = "agent.wecode.io/v1" + kind: Literal["Plugin"] = "Plugin" + metadata: Dict[str, Any] + spec: InstalledPluginSpec + status: InstalledPluginStatus = Field(default_factory=InstalledPluginStatus) + + +class SystemPluginListResponse(BaseModel): + """Response for admin system plugin management.""" + + total: int + items: List[SystemPlugin] + + +class PluginCatalogItem(BaseModel): + """User-facing system plugin catalog item with install state.""" + + id: int + name: str + displayName: str + description: str = "" + version: Optional[str] = None + author: Optional[str] = None + enabled: bool = True + installState: PluginInstallState = "not_installed" + installedPluginId: Optional[int] = None + installedPluginIds: Dict[PluginRuntime, int] = Field(default_factory=dict) + variantIds: Dict[PluginRuntime, int] = Field(default_factory=dict) + sourceChecksum: Optional[str] = None + installedChecksum: Optional[str] = None + components: InstalledPluginComponents = Field( + default_factory=InstalledPluginComponents + ) + + +class PluginCatalogListResponse(BaseModel): + """Response for user-facing plugin catalog.""" + + items: List[PluginCatalogItem] + + class InstalledPluginUpdateRequest(BaseModel): """Request to update installed plugin runtime state.""" @@ -120,6 +168,14 @@ class InstalledPluginUpdateRequest(BaseModel): description: Optional[str] = None +class SystemPluginUpdateRequest(BaseModel): + """Request to update system plugin catalog metadata.""" + + displayName: Optional[str] = None + description: Optional[str] = None + enabled: Optional[bool] = None + + class PluginUploadInfo(BaseModel): """Normalized plugin metadata parsed from an uploaded package.""" diff --git a/backend/app/services/device/capability_sync_service.py b/backend/app/services/device/capability_sync_service.py index 70c46e2640..ad93c99dd3 100644 --- a/backend/app/services/device/capability_sync_service.py +++ b/backend/app/services/device/capability_sync_service.py @@ -646,6 +646,7 @@ def _plugin_payload(self, installed: Kind) -> dict[str, Any]: "marketplace": marketplace, "version": spec.get("version"), "source": source, + "runtime": spec.get("runtime") or source.get("runtime") or "claudecode", } component_states = spec.get("componentStates") or {} if component_states: diff --git a/backend/app/services/installed_plugin_service.py b/backend/app/services/installed_plugin_service.py index 1d0f667dad..35e5b4d60d 100644 --- a/backend/app/services/installed_plugin_service.py +++ b/backend/app/services/installed_plugin_service.py @@ -17,7 +17,12 @@ InstalledPluginListResponse, InstalledPluginPackageRef, InstalledPluginUpdateRequest, + PluginCatalogItem, + PluginCatalogListResponse, + PluginRuntime, PluginUploadInfo, + SystemPlugin, + SystemPluginListResponse, ) from app.services.claude_plugin_parser import claude_plugin_parser @@ -25,6 +30,235 @@ class InstalledPluginService: """Manage user-scoped Claude Code plugin installations.""" + REQUIRED_SYSTEM_PLUGIN_RUNTIMES: tuple[PluginRuntime, ...] = ("claudecode", "codex") + + def upload_system_plugin( + self, + *, + db: Session, + package_bytes: bytes, + filename: str, + enabled: bool = True, + runtime: PluginRuntime = "claudecode", + ) -> SystemPlugin: + parsed = claude_plugin_parser.parse_package(package_bytes) + file_hash = hashlib.sha256(package_bytes).hexdigest() + existing = self._find_system_plugin(db, plugin_key=parsed.name, runtime=runtime) + if existing: + return self._replace_system_plugin( + db, + row=existing, + parsed=parsed, + package_bytes=package_bytes, + file_hash=file_hash, + filename=filename, + enabled=enabled, + runtime=runtime, + ) + + row = Kind( + user_id=0, + kind="Plugin", + name=self._safe_kind_name(f"{parsed.name}-{runtime}"), + namespace="default", + json={}, + is_active=True, + ) + db.add(row) + db.flush() + package_ref = self._package_ref(row.id, file_hash, len(package_bytes)) + row.json = self._build_payload( + kind="Plugin", + name=parsed.name, + parsed=parsed, + package_ref=package_ref, + enabled=enabled, + filename=filename, + source_type="upload", + runtime=runtime, + ) + self._upsert_package( + db, + kind_id=row.id, + package_bytes=package_bytes, + file_hash=file_hash, + filename=filename, + ) + db.commit() + db.refresh(row) + return self._kind_to_system_plugin(row) + + def list_system_plugins(self, *, db: Session) -> SystemPluginListResponse: + rows = ( + db.query(Kind) + .filter( + Kind.user_id == 0, + Kind.kind == "Plugin", + Kind.namespace == "default", + Kind.is_active == True, + ) + .order_by(Kind.updated_at.desc()) + .all() + ) + return SystemPluginListResponse( + total=len(rows), + items=[self._kind_to_system_plugin(row) for row in rows], + ) + + def list_system_plugin_catalog( + self, *, db: Session, user_id: int + ) -> PluginCatalogListResponse: + system_rows = ( + db.query(Kind) + .filter( + Kind.user_id == 0, + Kind.kind == "Plugin", + Kind.namespace == "default", + Kind.is_active == True, + ) + .order_by(Kind.updated_at.desc()) + .all() + ) + installed_by_system_id = self._installed_plugins_by_system_id( + db, user_id=user_id + ) + grouped = self._group_system_plugin_rows(system_rows) + items = [] + for variants in grouped.values(): + if not self._is_complete_enabled_variant_group(variants): + continue + items.append( + self._system_group_to_catalog_item(variants, installed_by_system_id) + ) + return PluginCatalogListResponse(items=items) + + def update_system_plugin_metadata( + self, + *, + db: Session, + system_plugin_id: int, + display_name: Optional[str] = None, + description: Optional[str] = None, + enabled: Optional[bool] = None, + ) -> SystemPlugin: + row = self._get_active_system_plugin(db, system_plugin_id=system_plugin_id) + plugin_key = self._plugin_key_from_row(row) + rows = ( + db.query(Kind) + .filter( + Kind.user_id == 0, + Kind.kind == "Plugin", + Kind.namespace == "default", + Kind.is_active == True, + ) + .all() + ) + for candidate in rows: + if self._plugin_key_from_row(candidate) != plugin_key: + continue + spec = self._get_spec(candidate) + if display_name is not None: + spec["displayName"] = display_name + if description is not None: + spec["description"] = description + if enabled is not None: + spec["enabled"] = enabled + candidate.json["spec"] = spec + flag_modified(candidate, "json") + db.commit() + db.refresh(row) + return self._kind_to_system_plugin(row) + + def delete_system_plugin(self, *, db: Session, system_plugin_id: int) -> None: + row = self._get_active_system_plugin(db, system_plugin_id=system_plugin_id) + spec = self._get_spec(row) + spec["enabled"] = False + row.json["spec"] = spec + row.is_active = False + flag_modified(row, "json") + db.commit() + + def replace_system_plugin_package( + self, + *, + db: Session, + system_plugin_id: int, + package_bytes: bytes, + filename: str, + ) -> SystemPlugin: + row = self._get_active_system_plugin(db, system_plugin_id=system_plugin_id) + parsed = claude_plugin_parser.parse_package(package_bytes) + current_spec = self._get_spec(row) + current_plugin_key = current_spec.get("source", {}).get("pluginKey") + runtime = self._runtime_from_spec(current_spec) + if parsed.name != current_plugin_key: + raise HTTPException( + status_code=400, + detail="Replacement plugin package must use the same plugin name", + ) + file_hash = hashlib.sha256(package_bytes).hexdigest() + return self._replace_system_plugin( + db, + row=row, + parsed=parsed, + package_bytes=package_bytes, + file_hash=file_hash, + filename=filename, + enabled=current_spec.get("enabled", True), + runtime=runtime, + display_name=current_spec.get("displayName"), + description=current_spec.get("description"), + ) + + def install_system_plugin( + self, *, db: Session, user_id: int, system_plugin_id: int + ) -> InstalledPluginListResponse: + system_row = self._get_enabled_system_plugin( + db, system_plugin_id=system_plugin_id + ) + variants = self._complete_enabled_variants_for_row(db, system_row) + installed_items = [] + for variant in self._ordered_variant_rows(variants): + existing = self._find_installed_system_plugin( + db, user_id=user_id, system_plugin_id=variant.id + ) + if existing and existing.is_active: + installed_items.append(self._kind_to_installed_plugin(existing)) + continue + installed_items.append( + self._copy_system_plugin_to_installed( + db, + user_id=user_id, + system_row=variant, + row=existing, + ) + ) + return InstalledPluginListResponse(items=installed_items) + + def update_installed_plugin_from_system( + self, *, db: Session, user_id: int, system_plugin_id: int + ) -> InstalledPluginListResponse: + system_row = self._get_enabled_system_plugin( + db, system_plugin_id=system_plugin_id + ) + variants = self._complete_enabled_variants_for_row(db, system_row) + installed_items = [] + for variant in self._ordered_variant_rows(variants): + installed = self._find_installed_system_plugin( + db, user_id=user_id, system_plugin_id=variant.id + ) + if not installed or not installed.is_active: + installed = None + installed_items.append( + self._copy_system_plugin_to_installed( + db, + user_id=user_id, + system_row=variant, + row=installed, + ) + ) + return InstalledPluginListResponse(items=installed_items) + def upload_plugin( self, *, @@ -62,11 +296,14 @@ def upload_plugin( db.flush() package_ref = self._package_ref(row.id, file_hash, len(package_bytes)) row.json = self._build_payload( + kind="InstalledPlugin", name=parsed.name, parsed=parsed, package_ref=package_ref, enabled=enabled, filename=filename, + source_type="upload", + runtime="claudecode", ) self._upsert_package( db, @@ -169,11 +406,14 @@ def _reactivate_existing( ) -> InstalledPlugin: package_ref = self._package_ref(row.id, file_hash, len(package_bytes)) row.json = self._build_payload( + kind="InstalledPlugin", name=parsed.name, parsed=parsed, package_ref=package_ref, enabled=enabled, filename=filename, + source_type="upload", + runtime="claudecode", ) row.is_active = True flag_modified(row, "json") @@ -210,6 +450,28 @@ def _find_uploaded_plugin( return row return None + def _find_system_plugin( + self, db: Session, *, plugin_key: str, runtime: PluginRuntime + ) -> Optional[Kind]: + rows = ( + db.query(Kind) + .filter( + Kind.user_id == 0, + Kind.kind == "Plugin", + Kind.namespace == "default", + ) + .all() + ) + for row in rows: + source = self._get_spec(row).get("source", {}) + if ( + isinstance(source, dict) + and source.get("pluginKey") == plugin_key + and self._runtime_from_spec(self._get_spec(row)) == runtime + ): + return row + return None + def _get_active_installed_plugin( self, db: Session, *, user_id: int, installed_id: int ) -> Kind: @@ -231,22 +493,31 @@ def _get_active_installed_plugin( def _build_payload( self, *, + kind: str, name: str, parsed: PluginUploadInfo, package_ref: InstalledPluginPackageRef, enabled: bool, filename: str, + source_type: str, + runtime: PluginRuntime, ) -> Dict[str, Any]: + provider_key = "codex" if runtime == "codex" else "claude-code" return { "apiVersion": "agent.wecode.io/v1", - "kind": "InstalledPlugin", - "metadata": {"name": self._safe_kind_name(name), "namespace": "default"}, + "kind": kind, + "metadata": { + "name": self._safe_kind_name(f"{name}-{runtime}"), + "namespace": "default", + }, "spec": { "source": { - "type": "upload", - "providerKey": "claude-code", + "type": source_type, + "providerKey": provider_key, "pluginKey": name, + "runtime": runtime, }, + "runtime": runtime, "displayName": parsed.displayName, "description": parsed.description, "version": parsed.version, @@ -262,6 +533,328 @@ def _build_payload( "status": {"state": "Available"}, } + def _replace_system_plugin( + self, + db: Session, + *, + row: Kind, + parsed: PluginUploadInfo, + package_bytes: bytes, + file_hash: str, + filename: str, + enabled: bool, + runtime: PluginRuntime, + display_name: Optional[str] = None, + description: Optional[str] = None, + ) -> SystemPlugin: + package_ref = self._package_ref(row.id, file_hash, len(package_bytes)) + row.json = self._build_payload( + kind="Plugin", + name=parsed.name, + parsed=parsed, + package_ref=package_ref, + enabled=enabled, + filename=filename, + source_type="upload", + runtime=runtime, + ) + spec = self._get_spec(row) + if display_name is not None: + spec["displayName"] = display_name + if description is not None: + spec["description"] = description + row.json["spec"] = spec + row.is_active = True + flag_modified(row, "json") + self._upsert_package( + db, + kind_id=row.id, + package_bytes=package_bytes, + file_hash=file_hash, + filename=filename, + ) + db.commit() + db.refresh(row) + return self._kind_to_system_plugin(row) + + def _get_active_system_plugin(self, db: Session, *, system_plugin_id: int) -> Kind: + row = ( + db.query(Kind) + .filter( + Kind.id == system_plugin_id, + Kind.user_id == 0, + Kind.kind == "Plugin", + Kind.namespace == "default", + Kind.is_active == True, + ) + .first() + ) + if not row: + raise HTTPException(status_code=404, detail="System plugin not found") + return row + + def _get_enabled_system_plugin(self, db: Session, *, system_plugin_id: int) -> Kind: + row = self._get_active_system_plugin(db, system_plugin_id=system_plugin_id) + if not self._get_spec(row).get("enabled", True): + raise HTTPException(status_code=404, detail="System plugin not found") + return row + + def _complete_enabled_variants_for_row( + self, db: Session, system_row: Kind + ) -> dict[PluginRuntime, Kind]: + plugin_key = self._plugin_key_from_row(system_row) + rows = ( + db.query(Kind) + .filter( + Kind.user_id == 0, + Kind.kind == "Plugin", + Kind.namespace == "default", + Kind.is_active == True, + ) + .all() + ) + grouped = self._group_system_plugin_rows(rows) + variants = grouped.get(plugin_key, {}) + if not self._is_complete_enabled_variant_group(variants): + raise HTTPException( + status_code=404, + detail="System plugin must include enabled ClaudeCode and Codex packages", + ) + return variants + + def _group_system_plugin_rows( + self, rows: list[Kind] + ) -> dict[str, dict[PluginRuntime, Kind]]: + grouped: dict[str, dict[PluginRuntime, Kind]] = {} + for row in rows: + plugin_key = self._plugin_key_from_row(row) + if not plugin_key: + continue + runtime = self._runtime_from_spec(self._get_spec(row)) + grouped.setdefault(plugin_key, {})[runtime] = row + return grouped + + def _is_complete_enabled_variant_group( + self, variants: dict[PluginRuntime, Kind] + ) -> bool: + for runtime in self.REQUIRED_SYSTEM_PLUGIN_RUNTIMES: + row = variants.get(runtime) + if not row or not self._get_spec(row).get("enabled", True): + return False + return True + + def _ordered_variant_rows(self, variants: dict[PluginRuntime, Kind]) -> list[Kind]: + return [variants[runtime] for runtime in self.REQUIRED_SYSTEM_PLUGIN_RUNTIMES] + + def _plugin_key_from_row(self, row: Kind) -> str: + spec = self._get_spec(row) + source = spec.get("source") or {} + if isinstance(source, dict): + plugin_key = source.get("pluginKey") + if isinstance(plugin_key, str) and plugin_key: + return plugin_key + return row.name + + def _runtime_from_spec(self, spec: dict[str, Any]) -> PluginRuntime: + value = spec.get("runtime") + if value not in self.REQUIRED_SYSTEM_PLUGIN_RUNTIMES: + source = spec.get("source") or {} + if isinstance(source, dict): + value = source.get("runtime") + return "codex" if value == "codex" else "claudecode" + + def _installed_plugins_by_system_id( + self, db: Session, *, user_id: int + ) -> dict[int, Kind]: + rows = ( + db.query(Kind) + .filter( + Kind.user_id == user_id, + Kind.kind == "InstalledPlugin", + Kind.namespace == "default", + Kind.is_active == True, + ) + .all() + ) + result: dict[int, Kind] = {} + for row in rows: + source = self._get_spec(row).get("source", {}) + system_id = self._system_plugin_id_from_source(source) + if system_id is not None: + result[system_id] = row + return result + + def _find_installed_system_plugin( + self, db: Session, *, user_id: int, system_plugin_id: int + ) -> Optional[Kind]: + rows = ( + db.query(Kind) + .filter( + Kind.user_id == user_id, + Kind.kind == "InstalledPlugin", + Kind.namespace == "default", + ) + .all() + ) + for row in rows: + source = self._get_spec(row).get("source", {}) + if self._system_plugin_id_from_source(source) == system_plugin_id: + return row + return None + + def _system_plugin_id_from_source(self, source: Any) -> Optional[int]: + if not isinstance(source, dict) or source.get("type") != "system": + return None + value = source.get("systemPluginId") or source.get("catalogItemId") + try: + parsed = int(value) + except (TypeError, ValueError): + return None + return parsed if parsed > 0 else None + + def _system_group_to_catalog_item( + self, + variants: dict[PluginRuntime, Kind], + installed_by_system_id: dict[int, Kind], + ) -> PluginCatalogItem: + primary = variants["claudecode"] + spec = self._get_spec(primary) + package_ref = spec.get("packageRef") or {} + installed_by_runtime: dict[PluginRuntime, Kind] = {} + for runtime, row in variants.items(): + installed = installed_by_system_id.get(row.id) + if installed: + installed_by_runtime[runtime] = installed + installed = installed_by_runtime.get("claudecode") + installed_spec = self._get_spec(installed) if installed else {} + installed_ref = installed_spec.get("packageRef") or {} + source_checksum = package_ref.get("checksum") + installed_checksum = installed_ref.get("checksum") + install_state = "not_installed" + installed_id = None + if installed_by_runtime: + installed_id = installed.id + install_state = "installed" + for runtime, row in variants.items(): + variant_ref = self._get_spec(row).get("packageRef") or {} + installed_row = installed_by_runtime.get(runtime) + installed_ref = ( + self._get_spec(installed_row).get("packageRef") + if installed_row + else {} + ) or {} + if not installed_row or variant_ref.get( + "checksum" + ) != installed_ref.get("checksum"): + install_state = "update_available" + break + return PluginCatalogItem( + id=primary.id, + name=spec.get("source", {}).get("pluginKey") or primary.name, + displayName=spec.get("displayName") or primary.name, + description=spec.get("description") or "", + version=spec.get("version"), + author=spec.get("author"), + enabled=spec.get("enabled", True), + installState=install_state, + installedPluginId=installed_id, + installedPluginIds={ + runtime: row.id for runtime, row in installed_by_runtime.items() + }, + variantIds={runtime: row.id for runtime, row in variants.items()}, + sourceChecksum=source_checksum, + installedChecksum=installed_checksum, + components=spec.get("components") or {}, + ) + + def _copy_system_plugin_to_installed( + self, + db: Session, + *, + user_id: int, + system_row: Kind, + row: Optional[Kind], + ) -> InstalledPlugin: + package = ( + db.query(SkillBinary).filter(SkillBinary.kind_id == system_row.id).first() + ) + if not package or not package.binary_data: + raise HTTPException( + status_code=404, detail="System plugin package not found" + ) + if row is None: + row = Kind( + user_id=user_id, + kind="InstalledPlugin", + name=system_row.name, + namespace="default", + json={}, + is_active=True, + ) + db.add(row) + db.flush() + previous_spec = self._get_spec(row) + row.json = self._installed_payload_from_system( + row=row, + system_row=system_row, + package=package, + enabled=previous_spec.get("enabled", True), + component_states=previous_spec.get("componentStates"), + ) + row.is_active = True + flag_modified(row, "json") + self._upsert_package( + db, + kind_id=row.id, + package_bytes=package.binary_data, + file_hash=package.file_hash, + filename=package.file_name or f"{system_row.name}.zip", + ) + db.commit() + db.refresh(row) + return self._kind_to_installed_plugin(row) + + def _installed_payload_from_system( + self, + *, + row: Kind, + system_row: Kind, + package: SkillBinary, + enabled: bool, + component_states: Optional[Dict[str, bool]], + ) -> Dict[str, Any]: + payload = dict(system_row.json) + payload["kind"] = "InstalledPlugin" + metadata = dict(payload.get("metadata") or {}) + metadata["name"] = row.name + metadata["namespace"] = "default" + payload["metadata"] = metadata + spec = dict(payload.get("spec") or {}) + source = dict(spec.get("source") or {}) + source.update( + { + "type": "system", + "catalogItemId": str(system_row.id), + "systemPluginId": system_row.id, + } + ) + spec["source"] = source + spec["installState"] = "installed" + spec["enabled"] = enabled + if component_states: + spec["componentStates"] = component_states + spec["packageRef"] = self._package_ref( + row.id, + package.file_hash, + package.file_size, + ).model_dump() + spec["sourcePayload"] = { + "filename": package.file_name, + "systemPluginId": system_row.id, + } + payload["spec"] = spec + return payload + def _default_component_states(self, parsed: PluginUploadInfo) -> Dict[str, bool]: components = parsed.components states: Dict[str, bool] = {} @@ -347,6 +940,15 @@ def _kind_to_installed_plugin(self, row: Kind) -> InstalledPlugin: payload["metadata"] = metadata return InstalledPlugin.model_validate(payload) + def _kind_to_system_plugin(self, row: Kind) -> SystemPlugin: + payload = dict(row.json) + metadata = dict(payload.get("metadata", {})) + labels = dict(metadata.get("labels", {})) + labels["id"] = str(row.id) + metadata["labels"] = labels + payload["metadata"] = metadata + return SystemPlugin.model_validate(payload) + def _get_spec(self, row: Kind) -> Dict[str, Any]: payload = row.json if isinstance(row.json, dict) else {} spec = payload.get("spec", {}) diff --git a/backend/tests/api/endpoints/test_plugins_api.py b/backend/tests/api/endpoints/test_plugins_api.py new file mode 100644 index 0000000000..2c2b4f8cd8 --- /dev/null +++ b/backend/tests/api/endpoints/test_plugins_api.py @@ -0,0 +1,175 @@ +# SPDX-FileCopyrightText: 2026 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +import io +import json +import zipfile + +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.services.installed_plugin_service import installed_plugin_service + + +def _plugin_zip(name: str = "superpowers", version: str = "1.0.0") -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr( + ".claude-plugin/plugin.json", + json.dumps( + { + "name": name, + "displayName": "Superpowers", + "description": "System plugin", + "version": version, + } + ), + ) + archive.writestr("commands/test.md", "# Test") + return buffer.getvalue() + + +def _auth_headers(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +def test_user_can_install_and_manually_update_system_plugin( + test_client: TestClient, + test_db: Session, + test_token: str, + test_user, +): + claudecode_plugin = installed_plugin_service.upload_system_plugin( + db=test_db, + package_bytes=_plugin_zip(version="1.0.0"), + filename="superpowers-1.0.0.zip", + runtime="claudecode", + ) + codex_plugin = installed_plugin_service.upload_system_plugin( + db=test_db, + package_bytes=_plugin_zip(version="1.0.0"), + filename="superpowers-codex-1.0.0.zip", + runtime="codex", + ) + system_plugin_id = int(claudecode_plugin.metadata["labels"]["id"]) + codex_plugin_id = int(codex_plugin.metadata["labels"]["id"]) + + catalog_response = test_client.get( + "/api/plugins/catalog", + headers=_auth_headers(test_token), + ) + assert catalog_response.status_code == 200 + assert catalog_response.json()["items"][0]["installState"] == "not_installed" + assert catalog_response.json()["items"][0]["variantIds"] == { + "claudecode": system_plugin_id, + "codex": codex_plugin_id, + } + + install_response = test_client.post( + f"/api/plugins/catalog/{system_plugin_id}/install", + headers=_auth_headers(test_token), + ) + assert install_response.status_code == 201 + installed_items = install_response.json()["items"] + assert {item["spec"]["runtime"] for item in installed_items} == { + "claudecode", + "codex", + } + installed_by_runtime = { + item["spec"]["runtime"]: int(item["metadata"]["labels"]["id"]) + for item in installed_items + } + installed_id = installed_by_runtime["claudecode"] + + installed_package = installed_plugin_service.package_data_for_download( + db=test_db, + user_id=test_user.id, + installed_id=installed_id, + ) + assert installed_package[1] == "superpowers-1.0.0.zip" + + installed_plugin_service.replace_system_plugin_package( + db=test_db, + system_plugin_id=system_plugin_id, + package_bytes=_plugin_zip(version="2.0.0"), + filename="superpowers-2.0.0.zip", + ) + installed_plugin_service.replace_system_plugin_package( + db=test_db, + system_plugin_id=codex_plugin_id, + package_bytes=_plugin_zip(version="2.0.0"), + filename="superpowers-codex-2.0.0.zip", + ) + + catalog_response = test_client.get( + "/api/plugins/catalog", + headers=_auth_headers(test_token), + ) + catalog_item = catalog_response.json()["items"][0] + assert catalog_item["installState"] == "update_available" + assert catalog_item["installedPluginId"] == installed_id + + update_response = test_client.post( + f"/api/plugins/catalog/{system_plugin_id}/update", + headers=_auth_headers(test_token), + ) + assert update_response.status_code == 200 + assert all( + item["spec"]["version"] == "2.0.0" for item in update_response.json()["items"] + ) + + updated_package = installed_plugin_service.package_data_for_download( + db=test_db, + user_id=test_user.id, + installed_id=installed_id, + ) + assert updated_package[1] == "superpowers-2.0.0.zip" + + +def test_admin_can_manage_system_plugin_catalog( + test_client: TestClient, + test_admin_token: str, +): + upload_response = test_client.post( + "/api/admin/plugins", + headers=_auth_headers(test_admin_token), + files={"file": ("superpowers.zip", _plugin_zip(), "application/zip")}, + data={"enabled": "true"}, + ) + assert upload_response.status_code == 201 + plugin_id = int(upload_response.json()["metadata"]["labels"]["id"]) + + update_response = test_client.put( + f"/api/admin/plugins/{plugin_id}", + headers=_auth_headers(test_admin_token), + json={ + "displayName": "Managed Plugin", + "description": "Visible in Wework", + "enabled": False, + }, + ) + assert update_response.status_code == 200 + assert update_response.json()["spec"]["displayName"] == "Managed Plugin" + assert update_response.json()["spec"]["enabled"] is False + + list_response = test_client.get( + "/api/admin/plugins", + headers=_auth_headers(test_admin_token), + ) + assert list_response.status_code == 200 + assert list_response.json()["total"] == 1 + + replace_response = test_client.put( + f"/api/admin/plugins/{plugin_id}/package", + headers=_auth_headers(test_admin_token), + files={ + "file": ( + "superpowers-2.0.0.zip", + _plugin_zip(version="2.0.0"), + "application/zip", + ) + }, + ) + assert replace_response.status_code == 200 + assert replace_response.json()["spec"]["version"] == "2.0.0" diff --git a/backend/tests/services/device/test_capability_sync_service.py b/backend/tests/services/device/test_capability_sync_service.py index 796d19359d..827c5caf76 100644 --- a/backend/tests/services/device/test_capability_sync_service.py +++ b/backend/tests/services/device/test_capability_sync_service.py @@ -173,6 +173,7 @@ def test_resolve_payload_uses_uploaded_plugin_key_and_download_ref(test_db): "providerKey": "claude-code", "pluginKey": "superpowers", }, + "runtime": "claudecode", "displayName": "Superpowers", "description": "Core skills", "version": "5.0.7", @@ -211,6 +212,7 @@ def test_resolve_payload_uses_uploaded_plugin_key_and_download_ref(test_db): "providerKey": "claude-code", "pluginKey": "superpowers", }, + "runtime": "claudecode", "checksum": "sha256:abc123", "download_path": "/api/plugins/installed/302/download", } diff --git a/backend/tests/services/test_installed_plugin_upload.py b/backend/tests/services/test_installed_plugin_upload.py index ba64485ef6..a6d7a64a54 100644 --- a/backend/tests/services/test_installed_plugin_upload.py +++ b/backend/tests/services/test_installed_plugin_upload.py @@ -25,7 +25,7 @@ async def read(self, _size: int) -> bytes: return self._chunks.pop(0) -def _create_plugin_zip(name: str = "superpowers") -> bytes: +def _create_plugin_zip(name: str = "superpowers", version: str = "1.0.0") -> bytes: buffer = io.BytesIO() with zipfile.ZipFile(buffer, "w") as archive: archive.writestr( @@ -35,7 +35,7 @@ def _create_plugin_zip(name: str = "superpowers") -> bytes: "name": name, "displayName": "Superpowers", "description": "Test plugin", - "version": "1.0.0", + "version": version, } ), ) @@ -87,3 +87,135 @@ def test_upload_plugin_stores_package_in_database(test_db, test_user): ) assert downloaded_bytes == package_bytes assert filename == "superpowers.zip" + + +def test_system_plugin_catalog_install_and_manual_update_flow(test_db, test_user): + service = InstalledPluginService() + package_v1 = _create_plugin_zip(version="1.0.0") + package_v2 = _create_plugin_zip(version="2.0.0") + + claudecode_plugin = service.upload_system_plugin( + db=test_db, + package_bytes=package_v1, + filename="superpowers-1.0.0.zip", + enabled=True, + runtime="claudecode", + ) + codex_plugin = service.upload_system_plugin( + db=test_db, + package_bytes=package_v1, + filename="superpowers-codex-1.0.0.zip", + enabled=True, + runtime="codex", + ) + claudecode_plugin_id = int(claudecode_plugin.metadata["labels"]["id"]) + codex_plugin_id = int(codex_plugin.metadata["labels"]["id"]) + + catalog = service.list_system_plugin_catalog(db=test_db, user_id=test_user.id) + assert len(catalog.items) == 1 + catalog_item = catalog.items[0] + assert catalog_item.id == claudecode_plugin_id + assert catalog_item.variantIds == { + "claudecode": claudecode_plugin_id, + "codex": codex_plugin_id, + } + assert catalog_item.installState == "not_installed" + assert catalog_item.installedPluginId is None + + installed_response = service.install_system_plugin( + db=test_db, + user_id=test_user.id, + system_plugin_id=claudecode_plugin_id, + ) + assert len(installed_response.items) == 2 + installed_by_runtime = { + item.spec.runtime: int(item.metadata["labels"]["id"]) + for item in installed_response.items + } + installed_id = installed_by_runtime["claudecode"] + codex_installed_id = installed_by_runtime["codex"] + + catalog = service.list_system_plugin_catalog(db=test_db, user_id=test_user.id) + assert catalog.items[0].installState == "installed" + assert catalog.items[0].installedPluginId == installed_id + assert catalog.items[0].installedPluginIds == { + "claudecode": installed_id, + "codex": codex_installed_id, + } + + service.update_system_plugin_metadata( + db=test_db, + system_plugin_id=claudecode_plugin_id, + display_name="Curated Superpowers", + description="Reviewed by admin", + ) + + service.replace_system_plugin_package( + db=test_db, + system_plugin_id=claudecode_plugin_id, + package_bytes=package_v2, + filename="superpowers-2.0.0.zip", + ) + service.replace_system_plugin_package( + db=test_db, + system_plugin_id=codex_plugin_id, + package_bytes=package_v2, + filename="superpowers-codex-2.0.0.zip", + ) + + catalog = service.list_system_plugin_catalog(db=test_db, user_id=test_user.id) + assert catalog.items[0].installState == "update_available" + assert catalog.items[0].version == "2.0.0" + assert catalog.items[0].displayName == "Curated Superpowers" + assert catalog.items[0].description == "Reviewed by admin" + + downloaded_bytes, filename = service.package_data_for_download( + db=test_db, + user_id=test_user.id, + installed_id=installed_id, + ) + assert downloaded_bytes == package_v1 + assert filename == "superpowers-1.0.0.zip" + + updated_response = service.update_installed_plugin_from_system( + db=test_db, + user_id=test_user.id, + system_plugin_id=claudecode_plugin_id, + ) + assert {item.spec.runtime for item in updated_response.items} == { + "claudecode", + "codex", + } + assert all(item.spec.version == "2.0.0" for item in updated_response.items) + + downloaded_bytes, filename = service.package_data_for_download( + db=test_db, + user_id=test_user.id, + installed_id=installed_id, + ) + assert downloaded_bytes == package_v2 + assert filename == "superpowers-2.0.0.zip" + + +def test_disabled_system_plugin_is_hidden_from_user_catalog(test_db, test_user): + service = InstalledPluginService() + system_plugin = service.upload_system_plugin( + db=test_db, + package_bytes=_create_plugin_zip(), + filename="superpowers.zip", + enabled=True, + runtime="claudecode", + ) + system_plugin_id = int(system_plugin.metadata["labels"]["id"]) + + service.update_system_plugin_metadata( + db=test_db, + system_plugin_id=system_plugin_id, + display_name="Internal Only", + description="Hidden from users", + enabled=False, + ) + + catalog = service.list_system_plugin_catalog(db=test_db, user_id=test_user.id) + + assert catalog.items == [] diff --git a/docs/en/developer-guide/system-plugin-management.md b/docs/en/developer-guide/system-plugin-management.md new file mode 100644 index 0000000000..00feaaa2f3 --- /dev/null +++ b/docs/en/developer-guide/system-plugin-management.md @@ -0,0 +1,73 @@ +--- +sidebar_position: 33 +--- + +# System Plugin Management + +System plugin management lets administrators maintain plugins shown to all users. Each logical plugin has separate ClaudeCode and Codex runtime ZIP packages. Users see one combined plugin in Wework, and installing or updating it deploys both runtime variants. + +## Data Model + +System plugins reuse the existing CRD storage model: + +- Admin-managed plugins use `Kind.kind = "Plugin"` with `user_id = 0` as system catalog entries. +- Variants of the same logical plugin are grouped by `spec.source.pluginKey`, with `spec.runtime = "claudecode"` or `"codex"` identifying the runtime package. +- User installations still use `Kind.kind = "InstalledPlugin"` and belong to the current user. +- ZIP package bytes reuse `SkillBinary` with `type = "plugin"`. + +System catalog entries and user installations are linked through `spec.source.systemPluginId`. Installing a system plugin copies the manifest, component inventory, and package reference into a user-owned installation instead of sharing user-editable runtime state. The user catalog only shows logical plugins whose ClaudeCode and Codex variants both exist and are enabled. + +## Administrator Capabilities + +The admin entry point is in the main frontend: + +```text +System Administration -> Plugins +``` + +Supported operations: + +- Upload a ZIP to create or replace a system plugin catalog entry and choose the runtime version. +- Edit display name and description. +- Enable or disable a catalog entry. If either runtime variant is disabled, the combined plugin is hidden from the user catalog. +- Re-upload a ZIP to replace the plugin package version. +- Delete a system plugin catalog entry. + +When replacing the ZIP, the new package must keep the same plugin name as the original package. This prevents updating user installations to a different plugin by mistake. + +## User Installation And Updates + +The Wework plugin management page only shows the system plugin catalog. It no longer provides a user upload entry for plugin ZIP packages. Users can: + +- Install system plugins. One install creates both ClaudeCode and Codex `InstalledPlugin` records. +- Enable or disable their own installed plugins. +- See an update-available state after an administrator replaces the system plugin package. +- Manually update both installed runtime variants to the new system plugin package version. + +Updates use a manual confirmation policy. Replacing a system package does not automatically modify user installations; it only makes the catalog API return `installState = "update_available"`. Install and update APIs return an `items` array containing both runtime installation results. + +## API + +Administrator APIs: + +```text +GET /api/admin/plugins +POST /api/admin/plugins +PUT /api/admin/plugins/{system_plugin_id} +PUT /api/admin/plugins/{system_plugin_id}/package +DELETE /api/admin/plugins/{system_plugin_id} +``` + +User APIs: + +```text +GET /api/plugins/catalog +POST /api/plugins/catalog/{system_plugin_id}/install +POST /api/plugins/catalog/{system_plugin_id}/update +``` + +Installing or updating a plugin triggers global capability sync so online devices receive the plugin changes. + +## Frontend Responsibilities + +The main frontend (`frontend/`) owns system plugin catalog administration. Wework consumes the catalog for users. Wework still supports skill upload and custom MCP creation, but user upload of Claude Code plugin ZIP packages has been removed. diff --git a/docs/zh/developer-guide/system-plugin-management.md b/docs/zh/developer-guide/system-plugin-management.md new file mode 100644 index 0000000000..78df98a80e --- /dev/null +++ b/docs/zh/developer-guide/system-plugin-management.md @@ -0,0 +1,73 @@ +--- +sidebar_position: 33 +--- + +# 系统插件管理 + +系统插件管理用于让管理员维护一组面向所有用户展示的插件。每个逻辑插件需要分别维护 ClaudeCode 和 Codex 两个运行时 ZIP 包;用户在 Wework 中只看到一个整合插件,安装或更新时两个运行时版本会一起下发。 + +## 数据模型 + +系统插件复用现有 CRD 存储方式: + +- 管理员维护的插件使用 `Kind.kind = "Plugin"`,`user_id = 0`,作为系统目录项。 +- 同一个逻辑插件通过 `spec.source.pluginKey` 分组,`spec.runtime = "claudecode"` 或 `"codex"` 标识运行时版本。 +- 用户安装后的插件仍使用 `Kind.kind = "InstalledPlugin"`,归属当前用户。 +- ZIP 包二进制复用 `SkillBinary`,`type = "plugin"`。 + +系统目录项和用户安装项通过 `spec.source.systemPluginId` 关联。用户安装时复制系统插件的 manifest、组件清单和包引用,不直接共享用户可修改的运行状态。目录接口只展示同时存在且启用的 ClaudeCode 和 Codex 成对版本。 + +## 管理员能力 + +管理员入口位于主前端: + +```text +系统管理 -> 插件管理 +``` + +支持的操作: + +- 上传 ZIP 创建或替换系统插件目录项,并选择运行时版本。 +- 修改展示名称和描述。 +- 启用或停用目录项。任一运行时版本停用后,用户目录不再展示该整合插件。 +- 重新上传 ZIP 替换插件包版本。 +- 删除系统插件目录项。 + +替换 ZIP 时要求新包中的插件名称和原插件一致,避免把用户已安装插件错误更新到另一个插件。 + +## 用户安装和更新 + +Wework 的插件管理页只展示系统插件目录,不再提供用户自行上传插件 ZIP 的入口。用户可以: + +- 安装系统插件。一次安装会生成 ClaudeCode 和 Codex 两个 `InstalledPlugin`。 +- 启用或停用自己已安装的插件。 +- 当系统插件包被管理员替换后,看到可更新状态。 +- 手动点击更新,将自己的两个安装项同步到系统插件的新包版本。 + +更新采用用户手动确认策略。管理员替换系统包不会自动修改用户安装项,只会让目录接口返回 `installState = "update_available"`。安装和更新接口返回 `items` 数组,包含两个运行时版本的安装结果。 + +## API + +管理员接口: + +```text +GET /api/admin/plugins +POST /api/admin/plugins +PUT /api/admin/plugins/{system_plugin_id} +PUT /api/admin/plugins/{system_plugin_id}/package +DELETE /api/admin/plugins/{system_plugin_id} +``` + +用户接口: + +```text +GET /api/plugins/catalog +POST /api/plugins/catalog/{system_plugin_id}/install +POST /api/plugins/catalog/{system_plugin_id}/update +``` + +用户安装或更新后会触发全局能力同步,将插件变更同步到在线设备。 + +## 前端职责 + +主前端 `frontend/` 负责管理员插件目录维护,Wework 负责用户消费系统插件目录。Wework 仍保留技能上传和自定义 MCP 创建能力,但 Claude Code 插件 ZIP 的用户上传入口已移除。 diff --git a/executor/modes/local/capabilities.py b/executor/modes/local/capabilities.py index e8b24ea19c..46749151a3 100644 --- a/executor/modes/local/capabilities.py +++ b/executor/modes/local/capabilities.py @@ -419,31 +419,41 @@ def reconcile_managed_plugins(self) -> list[str]: runtime = record.get("runtime") or {} if not isinstance(runtime, dict): runtime = {} - claude_link_raw = runtime.get("claude_link") or record.get( - "install_path" - ) - if not claude_link_raw: - continue - claude_link = Path(str(claude_link_raw)).expanduser() + target_runtime = record.get("target_runtime") + claude_link_raw = runtime.get("claude_link") + if target_runtime != "codex": + claude_link_raw = claude_link_raw or record.get("install_path") if not store_path.is_dir(): continue - if self._ensure_runtime_symlink( - claude_link, store_path, self.plugins_dir - ): - restored.append(key) + claude_link = None + if claude_link_raw: + claude_link = Path(str(claude_link_raw)).expanduser() + if self._ensure_runtime_symlink( + claude_link, store_path, self.plugins_dir + ): + restored.append(key) codex_link_raw = runtime.get("codex_link") if codex_link_raw: codex_link = Path(str(codex_link_raw)).expanduser() - self._ensure_runtime_symlink( - codex_link, - store_path, - self.codex_plugins_dir, - optional=True, - ) + if ( + self._ensure_runtime_symlink( + codex_link, + store_path, + self.codex_plugins_dir, + optional=True, + ) + and key not in restored + ): + restored.append(key) - entry = self._plugin_install_entry(record, claude_link) + install_path = claude_link + if install_path is None and codex_link_raw: + install_path = Path(str(codex_link_raw)).expanduser() + if install_path is None: + continue + entry = self._plugin_install_entry(record, install_path) entries = installed_map.get(key) if not isinstance(entries, list): entries = [] @@ -547,9 +557,14 @@ def _sync_local_plugin_marketplace(self, manifest_plugins: dict[str, Any]) -> No continue if record.get("marketplace") != "wegent": continue + if record.get("target_runtime") == "codex": + continue store_path = Path(str(record.get("store_path") or "")).expanduser() if not store_path.is_dir(): continue + runtime = record.get("runtime") or {} + if isinstance(runtime, dict) and not runtime.get("claude_link"): + continue plugin_name = record.get("name") if not plugin_name: continue @@ -1257,13 +1272,14 @@ def _sync_plugins( continue name = item.get("name") marketplace = self._plugin_marketplace(item) + runtime = self._plugin_runtime(item) if not name: results.append( {"name": None, "status": "failed", "error": "Invalid Plugin entry"} ) continue - key = self._plugin_key(name, marketplace) - runtime_link = self._plugin_runtime_path(item, name, marketplace) + key = self._plugin_key(name, marketplace, runtime) + runtime_link = self._plugin_runtime_path(item, name, marketplace, runtime) store_path = self._plugin_store_path(item, name, marketplace) expected_checksum = item.get("checksum") should_download = not store_path.exists() @@ -1315,7 +1331,14 @@ def _sync_plugins( continue if store_path != runtime_link: self._ensure_runtime_symlink(runtime_link, store_path) - codex_link = self._ensure_codex_plugin_link(key, store_path) + codex_link = None + if runtime == "all": + codex_link = self._ensure_codex_plugin_link( + self._plugin_key(name, marketplace), + store_path, + ) + elif runtime == "codex": + codex_link = runtime_link scope = item.get("scope", "user") existing_installs = [ @@ -1337,7 +1360,7 @@ def _sync_plugins( ) installed_map[key] = existing_installs records[key] = self._plugin_record( - item, key, marketplace, store_path, runtime_link, codex_link + item, key, marketplace, store_path, runtime_link, codex_link, runtime ) logger.info( "Configured global plugin: key=%s installed_plugin_id=%s path=%s", @@ -1469,22 +1492,39 @@ def _plugin_marketplace(self, item: dict[str, Any]) -> str | None: marketplace = item.get("marketplace") or source.get("marketplace") if marketplace: return marketplace - if source.get("type") == "upload": + if source.get("type") in {"upload", "system"}: return "wegent" return None - def _plugin_key(self, name: str, marketplace: str | None) -> str: - return f"{name}@{marketplace}" if marketplace else name + def _plugin_runtime(self, item: dict[str, Any]) -> str: + source = item.get("source") or {} + runtime = item.get("runtime") or source.get("runtime") + if runtime in {"claudecode", "codex"}: + return str(runtime) + return "all" + + def _plugin_key( + self, name: str, marketplace: str | None, runtime: str | None = None + ) -> str: + base_key = f"{name}@{marketplace}" if marketplace else name + if runtime in {"claudecode", "codex"}: + return f"{base_key}#{runtime}" + return base_key def _plugin_runtime_path( self, item: dict[str, Any], name: str, marketplace: str | None, + runtime: str = "all", ) -> Path: raw_path = item.get("installPath") or item.get("install_path") if raw_path: return Path(str(raw_path)).expanduser() + if runtime == "codex": + return self.codex_plugins_dir / self._safe_path_segment( + self._plugin_key(name, marketplace) + ) return ( self.plugins_dir / "cache" @@ -1551,8 +1591,13 @@ def _plugin_record( store_path: Path, runtime_link: Path, codex_link: Path | None, + target_runtime: str, ) -> dict[str, Any]: - runtime = {"claude_link": str(runtime_link)} + runtime: dict[str, str] = {} + if target_runtime in {"all", "claudecode"}: + runtime["claude_link"] = str(runtime_link) + if target_runtime == "codex": + runtime["codex_link"] = str(runtime_link) if codex_link: runtime["codex_link"] = str(codex_link) return { @@ -1569,6 +1614,7 @@ def _plugin_record( "components": item.get("components") or {}, "store_path": str(store_path), "runtime": runtime, + "target_runtime": target_runtime, "install_path": str(runtime_link), "managed": True, "updated_at": utc_now_iso(), diff --git a/executor/tests/test_local_capability_sync_handler.py b/executor/tests/test_local_capability_sync_handler.py index c660ebbd60..a99949e680 100644 --- a/executor/tests/test_local_capability_sync_handler.py +++ b/executor/tests/test_local_capability_sync_handler.py @@ -588,6 +588,105 @@ def test_apply_sync_downloads_uploaded_plugin_into_store(tmp_path, monkeypatch): ) +def test_apply_sync_installs_claudecode_and_codex_plugin_variants_separately( + tmp_path, monkeypatch +): + skills_dir = tmp_path / "skills" + plugins_dir = tmp_path / "plugins" + codex_plugins_dir = tmp_path / ".codex" / "plugins" + store_dir = tmp_path / "store" + manifest_path = tmp_path / "capabilities.json" + claudecode_package = create_nested_plugin_zip( + "superpowers", + "1.0.0", + {"commands/claude.md": "# Claude"}, + ) + codex_package = create_nested_plugin_zip( + "superpowers", + "1.0.0", + {"commands/codex.md": "# Codex"}, + ) + packages = { + "/api/plugins/installed/501/download": claudecode_package, + "/api/plugins/installed/502/download": codex_package, + } + + def fake_get(_self, path: str, **_kwargs): + return Response(packages[path]) + + monkeypatch.setattr( + "executor.modes.local.capabilities.ApiClient.get", + fake_get, + ) + + store = GlobalCapabilityStore( + manifest_path=manifest_path, + skills_dir=skills_dir, + plugins_dir=plugins_dir, + codex_plugins_dir=codex_plugins_dir, + store_dir=store_dir, + ) + handler = CapabilitySyncHandler( + auth_token="token", + store=store, + skills_dir=skills_dir, + plugins_dir=plugins_dir, + codex_plugins_dir=codex_plugins_dir, + ) + + result = handler.apply_sync( + { + "mode": "replace", + "skills": [], + "plugins": [ + { + "installed_plugin_id": 501, + "name": "superpowers", + "version": "1.0.0", + "runtime": "claudecode", + "source": { + "type": "system", + "providerKey": "claude-code", + "pluginKey": "superpowers", + "runtime": "claudecode", + }, + "download_path": "/api/plugins/installed/501/download", + "checksum": "sha256:" + + hashlib.sha256(claudecode_package).hexdigest(), + }, + { + "installed_plugin_id": 502, + "name": "superpowers", + "version": "1.0.0", + "runtime": "codex", + "source": { + "type": "system", + "providerKey": "codex", + "pluginKey": "superpowers", + "runtime": "codex", + }, + "download_path": "/api/plugins/installed/502/download", + "checksum": "sha256:" + hashlib.sha256(codex_package).hexdigest(), + }, + ], + "mcps": [], + } + ) + + assert result["success"] is True + claudecode_link = plugins_dir / "cache" / "wegent" / "superpowers" / "1.0.0" + codex_link = codex_plugins_dir / "superpowers-wegent" + assert (claudecode_link / "commands" / "claude.md").read_text() == "# Claude" + assert not (claudecode_link / "commands" / "codex.md").exists() + assert (codex_link / "commands" / "codex.md").read_text() == "# Codex" + assert not (codex_link / "commands" / "claude.md").exists() + manifest = json.loads(manifest_path.read_text()) + assert set(manifest["plugins"]) == { + "superpowers@wegent#claudecode", + "superpowers@wegent#codex", + } + + def test_extract_plugin_zip_keeps_existing_install_when_package_is_invalid(tmp_path): install_path = tmp_path / "plugins" / "context7" install_path.mkdir(parents=True) diff --git a/frontend/src/__tests__/features/admin/components/SystemPluginList.test.tsx b/frontend/src/__tests__/features/admin/components/SystemPluginList.test.tsx new file mode 100644 index 0000000000..2773339312 --- /dev/null +++ b/frontend/src/__tests__/features/admin/components/SystemPluginList.test.tsx @@ -0,0 +1,199 @@ +// SPDX-FileCopyrightText: 2026 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +import '@testing-library/jest-dom' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' + +import { adminApis, AdminSystemPlugin } from '@/apis/admin' +import SystemPluginList from '@/features/admin/components/SystemPluginList' + +const mockToast = jest.fn() +const mockTranslate = (key: string) => key + +jest.mock('@/hooks/use-toast', () => ({ + useToast: () => ({ + toast: mockToast, + }), +})) + +jest.mock('@/hooks/useTranslation', () => ({ + useTranslation: () => ({ + t: mockTranslate, + }), +})) + +jest.mock('@/components/ui/dialog', () => ({ + Dialog: ({ + open, + children, + }: { + open: boolean + onOpenChange?: (open: boolean) => void + children: React.ReactNode + }) => (open ?
{children}
: null), + DialogContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogDescription: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogFooter: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +jest.mock('@/components/ui/alert-dialog', () => ({ + AlertDialog: ({ + open, + children, + }: { + open: boolean + onOpenChange?: (open: boolean) => void + children: React.ReactNode + }) => (open ?
{children}
: null), + AlertDialogAction: ({ + children, + onClick, + }: { + children: React.ReactNode + onClick?: () => void + }) => , + AlertDialogCancel: ({ children }: { children: React.ReactNode }) => , + AlertDialogContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + AlertDialogDescription: ({ children }: { children: React.ReactNode }) =>
{children}
, + AlertDialogFooter: ({ children }: { children: React.ReactNode }) =>
{children}
, + AlertDialogHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, + AlertDialogTitle: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +jest.mock('@/components/ui/switch', () => ({ + Switch: ({ + checked, + onCheckedChange, + ...props + }: { + checked?: boolean + onCheckedChange?: (checked: boolean) => void + [key: string]: unknown + }) => ( + onCheckedChange?.(event.target.checked)} + {...props} + /> + ), +})) + +jest.mock('@/apis/admin', () => ({ + adminApis: { + getSystemPlugins: jest.fn(), + uploadSystemPlugin: jest.fn(), + updateSystemPlugin: jest.fn(), + replaceSystemPluginPackage: jest.fn(), + deleteSystemPlugin: jest.fn(), + }, +})) + +const mockedAdminApis = adminApis as jest.Mocked + +function makePlugin(overrides: Partial = {}): AdminSystemPlugin { + return { + apiVersion: 'agent.wecode.io/v1', + kind: 'Plugin', + metadata: { id: 7, name: 'superpowers', namespace: 'system' }, + spec: { + source: { + type: 'upload', + providerKey: 'claude-code', + pluginKey: 'superpowers', + runtime: 'claudecode', + }, + displayName: 'Superpowers', + description: 'Shared plugin', + version: '1.0.0', + runtime: 'claudecode', + installState: 'installed', + enabled: true, + componentStates: {}, + manifest: {}, + components: { + skills: [{ name: 'plan', description: 'Plan work', path: 'skills/plan/SKILL.md' }], + commands: [], + agents: [], + hooks: [], + mcps: [], + lsps: [], + monitors: [], + bins: [], + settings: null, + }, + packageRef: { + storageKey: 'plugins/superpowers.zip', + checksum: 'abc', + sizeBytes: 2048, + }, + sourcePayload: null, + }, + status: { state: 'Available' }, + ...overrides, + } +} + +describe('SystemPluginList', () => { + beforeEach(() => { + jest.clearAllMocks() + mockedAdminApis.getSystemPlugins.mockResolvedValue({ + total: 1, + items: [makePlugin()], + }) + mockedAdminApis.uploadSystemPlugin.mockResolvedValue(makePlugin()) + mockedAdminApis.updateSystemPlugin.mockResolvedValue(makePlugin()) + mockedAdminApis.replaceSystemPluginPackage.mockResolvedValue(makePlugin()) + mockedAdminApis.deleteSystemPlugin.mockResolvedValue(undefined) + }) + + test('shows system plugins and uploads a new plugin package', async () => { + render() + + expect(await screen.findByText('Superpowers')).toBeInTheDocument() + expect(screen.getByText('Shared plugin')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('system-plugin-upload-button')) + fireEvent.change(screen.getByTestId('system-plugin-runtime-select'), { + target: { value: 'codex' }, + }) + fireEvent.change(screen.getByTestId('system-plugin-file-input'), { + target: { files: [new File(['zip'], 'superpowers.zip', { type: 'application/zip' })] }, + }) + fireEvent.click(screen.getByTestId('system-plugin-upload-submit')) + + await waitFor(() => { + expect(mockedAdminApis.uploadSystemPlugin).toHaveBeenCalledWith( + expect.any(File), + true, + 'codex' + ) + }) + }) + + test('updates display metadata and enabled state', async () => { + render() + + await screen.findByText('Superpowers') + fireEvent.click(screen.getByTestId('system-plugin-edit-7')) + fireEvent.change(screen.getByTestId('system-plugin-display-name-input'), { + target: { value: 'Updated Superpowers' }, + }) + fireEvent.change(screen.getByTestId('system-plugin-description-input'), { + target: { value: 'Updated description' }, + }) + fireEvent.click(screen.getByTestId('system-plugin-edit-enabled-switch')) + fireEvent.click(screen.getByTestId('system-plugin-edit-submit')) + + await waitFor(() => { + expect(mockedAdminApis.updateSystemPlugin).toHaveBeenCalledWith(7, { + displayName: 'Updated Superpowers', + description: 'Updated description', + enabled: false, + }) + }) + }) +}) diff --git a/frontend/src/apis/admin.ts b/frontend/src/apis/admin.ts index e870a88790..90ec489690 100644 --- a/frontend/src/apis/admin.ts +++ b/frontend/src/apis/admin.ts @@ -3,12 +3,14 @@ // SPDX-License-Identifier: Apache-2.0 import { apiClient } from './client' +import { getToken } from './user' import { getQuickLaunchFunctionsConfig, updateQuickLaunchFunctionsConfig, } from './admin-quick-launch' import { outboundTokenAdminApis } from './outboundTokens' import { RetrieverCRD } from './retrievers' +import { getApiBaseUrl } from '@/lib/runtime-config' import type { SkillRefMeta } from '@/types/api' // Re-export RetrieverCRD for backward compatibility @@ -574,6 +576,124 @@ export interface AdminTemplateUpdate { resources?: TemplateResources } +export type PluginInstallState = + | 'not_installed' + | 'installed' + | 'update_available' + | 'unavailable' + | 'failed' + | 'uninstalled' + +export type PluginRuntime = 'claudecode' | 'codex' + +export interface AdminPluginPathComponent { + name: string + path: string +} + +export interface AdminPluginSkillComponent extends AdminPluginPathComponent { + description: string +} + +export interface AdminPluginMCPComponent { + name: string + server: Record +} + +export interface AdminPluginComponents { + skills: AdminPluginSkillComponent[] + commands: AdminPluginPathComponent[] + agents: AdminPluginPathComponent[] + hooks: AdminPluginPathComponent[] + mcps: AdminPluginMCPComponent[] + lsps: AdminPluginPathComponent[] + monitors: AdminPluginPathComponent[] + bins: AdminPluginPathComponent[] + settings?: Record | null +} + +export interface AdminPluginPackageRef { + storageKey: string + checksum: string + sizeBytes: number +} + +export interface AdminSystemPlugin { + apiVersion: string + kind: 'Plugin' + metadata: Record + spec: { + source: { + type: 'upload' | 'marketplace' | 'local' | 'system' + providerKey: string + pluginKey: string + catalogItemId?: string | null + marketplace?: string | null + systemPluginId?: number | null + runtime?: PluginRuntime | null + } + displayName: string + description: string + version?: string | null + author?: string | null + runtime: PluginRuntime + installState: PluginInstallState + enabled: boolean + componentStates?: Record + manifest: Record + components: AdminPluginComponents + packageRef?: AdminPluginPackageRef | null + sourcePayload?: Record | null + } + status: { + state: string + } +} + +export interface AdminSystemPluginListResponse { + total: number + items: AdminSystemPlugin[] +} + +export interface AdminSystemPluginUpdate { + displayName?: string + description?: string + enabled?: boolean +} + +async function uploadSystemPluginForm( + endpoint: string, + formData: FormData, + method: 'POST' | 'PUT' +): Promise { + const token = getToken() + if (!token) { + throw new Error('No authentication token') + } + + const response = await fetch(`${getApiBaseUrl()}${endpoint}`, { + method, + headers: { + Authorization: `Bearer ${token}`, + }, + body: formData, + }) + + if (!response.ok) { + const errorText = await response.text() + let message = errorText + try { + const error = JSON.parse(errorText) + message = typeof error.detail === 'string' ? error.detail : errorText + } catch { + message = errorText + } + throw new Error(message || 'Failed to upload system plugin') + } + + return response.json() +} + // Admin API Services export const adminApis = { ...outboundTokenAdminApis, @@ -688,6 +808,41 @@ export const adminApis = { return apiClient.delete(`/admin/public-models/${modelId}`) }, + // ==================== System Plugin Management ==================== + + async getSystemPlugins(): Promise { + return apiClient.get('/admin/plugins') + }, + + async uploadSystemPlugin( + file: File, + enabled: boolean = true, + runtime: PluginRuntime = 'claudecode' + ): Promise { + const formData = new FormData() + formData.append('file', file) + formData.append('enabled', String(enabled)) + formData.append('runtime', runtime) + return uploadSystemPluginForm('/admin/plugins', formData, 'POST') + }, + + async updateSystemPlugin( + pluginId: number, + pluginData: AdminSystemPluginUpdate + ): Promise { + return apiClient.put(`/admin/plugins/${pluginId}`, pluginData) + }, + + async replaceSystemPluginPackage(pluginId: number, file: File): Promise { + const formData = new FormData() + formData.append('file', file) + return uploadSystemPluginForm(`/admin/plugins/${pluginId}/package`, formData, 'PUT') + }, + + async deleteSystemPlugin(pluginId: number): Promise { + return apiClient.delete(`/admin/plugins/${pluginId}`) + }, + // ==================== System Stats ==================== /** diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index 0abf161d04..a158a331e4 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -37,6 +37,9 @@ const PublicRetrieverList = dynamic( const PublicSkillList = dynamic(() => import('@/features/admin/components/PublicSkillList'), { ssr: false, }) +const SystemPluginList = dynamic(() => import('@/features/admin/components/SystemPluginList'), { + ssr: false, +}) const PublicGhostList = dynamic(() => import('@/features/admin/components/PublicGhostList'), { ssr: false, }) @@ -110,6 +113,7 @@ function AdminContent() { 'public-models', 'public-retrievers', 'public-skills', + 'plugins', 'public-ghosts', 'public-shells', 'public-teams', @@ -171,6 +175,8 @@ function AdminContent() { return case 'public-skills': return + case 'plugins': + return case 'public-ghosts': return case 'public-shells': diff --git a/frontend/src/features/admin/components/AdminTabNav.tsx b/frontend/src/features/admin/components/AdminTabNav.tsx index df7a7fce64..5e6c6c338d 100644 --- a/frontend/src/features/admin/components/AdminTabNav.tsx +++ b/frontend/src/features/admin/components/AdminTabNav.tsx @@ -30,6 +30,7 @@ import { MessageSquare, Monitor, LayoutTemplate, + Boxes, } from 'lucide-react' export type AdminTabId = @@ -37,6 +38,7 @@ export type AdminTabId = | 'public-models' | 'public-retrievers' | 'public-skills' + | 'plugins' | 'public-ghosts' | 'public-shells' | 'public-teams' @@ -73,6 +75,7 @@ export function AdminTabNav({ activeTab, onTabChange }: AdminTabNavProps) { { id: 'public-models', label: t('admin:tabs.public_models'), icon: Cpu }, { id: 'public-retrievers', label: t('admin:tabs.public_retrievers'), icon: Database }, { id: 'public-skills', label: t('admin:tabs.public_skills'), icon: Sparkles }, + { id: 'plugins', label: t('admin:tabs.plugins'), icon: Boxes }, { id: 'public-ghosts', label: t('admin:tabs.public_ghosts'), icon: Ghost }, { id: 'public-shells', label: t('admin:tabs.public_shells'), icon: Terminal }, { id: 'public-teams', label: t('admin:tabs.public_teams'), icon: UsersRound }, diff --git a/frontend/src/features/admin/components/SystemPluginList.tsx b/frontend/src/features/admin/components/SystemPluginList.tsx new file mode 100644 index 0000000000..9dd405640c --- /dev/null +++ b/frontend/src/features/admin/components/SystemPluginList.tsx @@ -0,0 +1,591 @@ +// SPDX-FileCopyrightText: 2026 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +'use client' + +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { Button } from '@/components/ui/button' +import { Card } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Switch } from '@/components/ui/switch' +import { Tag } from '@/components/ui/tag' +import { Textarea } from '@/components/ui/textarea' +import { useToast } from '@/hooks/use-toast' +import { useTranslation } from '@/hooks/useTranslation' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import UnifiedAddButton from '@/components/common/UnifiedAddButton' +import { adminApis, type AdminSystemPlugin, type PluginRuntime } from '@/apis/admin' +import { Boxes, Loader2, PackageCheck, Pencil, RefreshCw, Trash2 } from 'lucide-react' + +type PluginFormData = { + displayName: string + description: string + enabled: boolean + runtime: PluginRuntime +} + +function metadataId(plugin: AdminSystemPlugin): number { + const rawId = plugin.metadata.id + return typeof rawId === 'number' ? rawId : Number(rawId) +} + +function pluginName(plugin: AdminSystemPlugin): string { + const rawName = plugin.metadata.name + return typeof rawName === 'string' ? rawName : String(rawName || '') +} + +function componentCount(plugin: AdminSystemPlugin): number { + const components = plugin.spec.components + return ( + components.skills.length + + components.commands.length + + components.agents.length + + components.hooks.length + + components.mcps.length + + components.lsps.length + + components.monitors.length + + components.bins.length + ) +} + +const SystemPluginList: React.FC = () => { + const { t } = useTranslation('admin') + const { toast } = useToast() + const [plugins, setPlugins] = useState([]) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [selectedPlugin, setSelectedPlugin] = useState(null) + const [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false) + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false) + const [isReplaceDialogOpen, setIsReplaceDialogOpen] = useState(false) + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) + const [selectedFile, setSelectedFile] = useState(null) + const [formData, setFormData] = useState({ + displayName: '', + description: '', + enabled: true, + runtime: 'claudecode', + }) + + const fetchPlugins = useCallback(async () => { + setLoading(true) + try { + const response = await adminApis.getSystemPlugins() + setPlugins(response.items) + } catch (error) { + toast({ + variant: 'destructive', + title: t('system_plugins.errors.load_failed'), + description: (error as Error).message, + }) + } finally { + setLoading(false) + } + }, [t, toast]) + + useEffect(() => { + fetchPlugins() + }, [fetchPlugins]) + + const selectedPluginId = useMemo(() => { + return selectedPlugin ? metadataId(selectedPlugin) : null + }, [selectedPlugin]) + + const resetUploadForm = () => { + setSelectedFile(null) + setFormData({ + displayName: '', + description: '', + enabled: true, + runtime: 'claudecode', + }) + } + + const openEditDialog = (plugin: AdminSystemPlugin) => { + setSelectedPlugin(plugin) + setFormData({ + displayName: plugin.spec.displayName, + description: plugin.spec.description, + enabled: plugin.spec.enabled, + runtime: plugin.spec.runtime, + }) + setIsEditDialogOpen(true) + } + + const openReplaceDialog = (plugin: AdminSystemPlugin) => { + setSelectedPlugin(plugin) + setSelectedFile(null) + setIsReplaceDialogOpen(true) + } + + const handleUpload = async () => { + if (!selectedFile) { + toast({ + variant: 'destructive', + title: t('system_plugins.errors.file_required'), + }) + return + } + + setSaving(true) + try { + await adminApis.uploadSystemPlugin(selectedFile, formData.enabled, formData.runtime) + toast({ title: t('system_plugins.success.uploaded') }) + setIsUploadDialogOpen(false) + resetUploadForm() + fetchPlugins() + } catch (error) { + toast({ + variant: 'destructive', + title: t('system_plugins.errors.upload_failed'), + description: (error as Error).message, + }) + } finally { + setSaving(false) + } + } + + const handleUpdateMetadata = async () => { + if (!selectedPlugin || selectedPluginId === null) return + + setSaving(true) + try { + await adminApis.updateSystemPlugin(selectedPluginId, { + displayName: formData.displayName.trim(), + description: formData.description.trim(), + enabled: formData.enabled, + }) + toast({ title: t('system_plugins.success.updated') }) + setIsEditDialogOpen(false) + setSelectedPlugin(null) + fetchPlugins() + } catch (error) { + toast({ + variant: 'destructive', + title: t('system_plugins.errors.update_failed'), + description: (error as Error).message, + }) + } finally { + setSaving(false) + } + } + + const handleToggleEnabled = async (plugin: AdminSystemPlugin, enabled: boolean) => { + const pluginId = metadataId(plugin) + setSaving(true) + try { + await adminApis.updateSystemPlugin(pluginId, { enabled }) + toast({ title: t('system_plugins.success.updated') }) + fetchPlugins() + } catch (error) { + toast({ + variant: 'destructive', + title: t('system_plugins.errors.update_failed'), + description: (error as Error).message, + }) + } finally { + setSaving(false) + } + } + + const handleReplacePackage = async () => { + if (!selectedPlugin || selectedPluginId === null || !selectedFile) { + toast({ + variant: 'destructive', + title: t('system_plugins.errors.file_required'), + }) + return + } + + setSaving(true) + try { + await adminApis.replaceSystemPluginPackage(selectedPluginId, selectedFile) + toast({ title: t('system_plugins.success.replaced') }) + setIsReplaceDialogOpen(false) + setSelectedPlugin(null) + setSelectedFile(null) + fetchPlugins() + } catch (error) { + toast({ + variant: 'destructive', + title: t('system_plugins.errors.replace_failed'), + description: (error as Error).message, + }) + } finally { + setSaving(false) + } + } + + const handleDelete = async () => { + if (!selectedPlugin || selectedPluginId === null) return + + setSaving(true) + try { + await adminApis.deleteSystemPlugin(selectedPluginId) + toast({ title: t('system_plugins.success.deleted') }) + setIsDeleteDialogOpen(false) + setSelectedPlugin(null) + fetchPlugins() + } catch (error) { + toast({ + variant: 'destructive', + title: t('system_plugins.errors.delete_failed'), + description: (error as Error).message, + }) + } finally { + setSaving(false) + } + } + + return ( +
+
+

+ {t('system_plugins.title')} +

+

{t('system_plugins.description')}

+
+ +
+ {loading && ( +
+ +
+ )} + + {!loading && plugins.length === 0 && ( +
+ +

{t('system_plugins.no_plugins')}

+
+ )} + + {!loading && plugins.length > 0 && ( +
+ {plugins.map(plugin => { + const pluginId = metadataId(plugin) + return ( + +
+
+ +
+
+

+ {plugin.spec.displayName || pluginName(plugin)} +

+ {plugin.spec.enabled ? ( + {t('system_plugins.status.enabled')} + ) : ( + {t('system_plugins.status.disabled')} + )} + + {t(`system_plugins.runtime.${plugin.spec.runtime}`)} + + {plugin.spec.version && ( + + {t('system_plugins.version', { version: plugin.spec.version })} + + )} +
+

+ {plugin.spec.description || t('system_plugins.no_description')} +

+
+ + {t('system_plugins.fields.name')}: {pluginName(plugin)} + + * + + {t('system_plugins.fields.components')}: {componentCount(plugin)} + + {plugin.spec.packageRef && ( + <> + * + + {t('system_plugins.fields.package_size')}:{' '} + {Math.ceil(plugin.spec.packageRef.sizeBytes / 1024)} KB + + + )} +
+
+
+
+ handleToggleEnabled(plugin, checked)} + aria-label={t('system_plugins.actions.toggle')} + data-testid={`system-plugin-enabled-${pluginId}`} + /> + + + +
+
+
+ ) + })} +
+ )} + + {!loading && ( +
+
+ { + resetUploadForm() + setIsUploadDialogOpen(true) + }} + data-testid="system-plugin-upload-button" + > + {t('system_plugins.actions.upload')} + +
+
+ )} +
+ + + + + {t('system_plugins.upload.title')} + {t('system_plugins.upload.description')} + +
+
+ + setSelectedFile(event.target.files?.[0] || null)} + data-testid="system-plugin-file-input" + /> +
+
+ + +
+
+ + setFormData(prev => ({ ...prev, enabled: checked }))} + data-testid="system-plugin-upload-enabled-switch" + /> +
+
+ + + + +
+
+ + + + + {t('system_plugins.edit.title')} + +
+
+ + + setFormData(prev => ({ ...prev, displayName: event.target.value })) + } + data-testid="system-plugin-display-name-input" + /> +
+
+ +