From fc1ac421f8026465da18fe3c6fe79e95c265d355 Mon Sep 17 00:00:00 2001 From: kapelame Date: Thu, 6 Aug 2026 15:42:02 +0800 Subject: [PATCH 1/4] Add Skills.sh skill install flow --- apps/web/src/i18n/locales/en-US/admin.json | 18 + apps/web/src/i18n/locales/zh-CN/admin.json | 18 + apps/web/src/lib/api-skills.ts | 149 ++++++ .../admin/capabilities/MarketplaceTab.tsx | 7 +- .../admin/capabilities/SkillsDirectory.tsx | 226 +++++++++ server/internal/dev/routes.go | 19 + server/internal/dev/skills_install_routes.go | 457 ++++++++++++++++++ .../dev/skills_install_routes_test.go | 208 ++++++++ 8 files changed, 1101 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/lib/api-skills.ts create mode 100644 apps/web/src/pages/admin/capabilities/SkillsDirectory.tsx create mode 100644 server/internal/dev/skills_install_routes.go create mode 100644 server/internal/dev/skills_install_routes_test.go diff --git a/apps/web/src/i18n/locales/en-US/admin.json b/apps/web/src/i18n/locales/en-US/admin.json index a76fbe7..16ea430 100644 --- a/apps/web/src/i18n/locales/en-US/admin.json +++ b/apps/web/src/i18n/locales/en-US/admin.json @@ -888,6 +888,24 @@ "description": "Backend returned an error." } }, + "skillsDirectory": { + "installs": "{{count}} installs", + "loadError": { + "title": "Couldn't load the Skills catalog", + "description": "Retry without leaving the Capability Marketplace." + }, + "empty": { + "title": "No skills match these filters", + "description": "Try another search term." + }, + "install": { + "action": "Install", + "installing": "Installing...", + "installed": "Installed", + "success": "{{name}} was imported as a workspace Skill Capability.", + "failed": "The Skill could not be installed." + } + }, "mcpDirectory": { "title": "Connectors", "description": "Browse curated connectors and MCP capabilities published by workspaces in one marketplace.", diff --git a/apps/web/src/i18n/locales/zh-CN/admin.json b/apps/web/src/i18n/locales/zh-CN/admin.json index 258b35b..5b9563e 100644 --- a/apps/web/src/i18n/locales/zh-CN/admin.json +++ b/apps/web/src/i18n/locales/zh-CN/admin.json @@ -888,6 +888,24 @@ "description": "后端返回错误。" } }, + "skillsDirectory": { + "installs": "{{count}} 次安装", + "loadError": { + "title": "无法加载 Skills 目录", + "description": "可以直接重试,不会离开能力市场。" + }, + "empty": { + "title": "没有符合筛选条件的 Skill", + "description": "请更换搜索词。" + }, + "install": { + "action": "Install", + "installing": "安装中...", + "installed": "已安装", + "success": "已将 {{name}} 导入为工作区 Skill Capability。", + "failed": "无法安装该 Skill。" + } + }, "mcpDirectory": { "title": "连接器", "description": "在同一个能力市场中浏览精选连接器和各工作区发布的 MCP 能力。", diff --git a/apps/web/src/lib/api-skills.ts b/apps/web/src/lib/api-skills.ts new file mode 100644 index 0000000..e15c23c --- /dev/null +++ b/apps/web/src/lib/api-skills.ts @@ -0,0 +1,149 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" + +import { apiRequest, noUnreachableRetry } from "./api-client" +import { KEY_CAPABILITIES_WORKSPACE, KEY_CAPABILITY_VERSIONS } from "./api-capabilities" +import type { Capability, CapabilityVersion } from "./api-types" + +// Browser-safe equivalent of: +// curl -L https://agent-skill-index.vercel.app/api/skills +// The API endpoint redirects here, but the redirect response itself does not +// include CORS headers, so browser fetch fails before reaching this JSON. +const SKILLS_CATALOG_URL = "https://agent-skill-index.vercel.app/data/latest/skills.json" +const SKILLS_REGISTRY = "skills.sh" +const SAFE_SKILL_REF_PART = /^[A-Za-z0-9._-]+$/ + +export interface SkillsCatalogItem { + rank?: number + id: string + slug: string + source: string + name: string + installs?: number + sourceType?: string + source_type?: string + installUrl?: string | null + install_url?: string | null + url?: string | null +} + +export interface SkillsCatalogResponse { + items: SkillsCatalogItem[] + generatedAt?: string + count?: number +} + +export interface InstallSkillResponse { + capability: Capability + capability_version: CapabilityVersion + created_secret_ids: string[] +} + +export const KEY_SKILLS_CATALOG = ["admin", "skillsCatalog", "v2"] as const + +async function listSkillsCatalog(): Promise { + const res = await fetch(SKILLS_CATALOG_URL, { headers: { Accept: "application/json" } }) + if (!res.ok) throw new Error(`Skills catalog request failed: HTTP ${res.status}`) + const payload = await res.json() + const rawItems: unknown[] = Array.isArray(payload) + ? payload + : Array.isArray(payload?.data) + ? payload.data + : Array.isArray(payload?.items) + ? payload.items + : [] + return { + generatedAt: typeof payload?.generatedAt === "string" ? payload.generatedAt : undefined, + count: typeof payload?.count === "number" ? payload.count : rawItems.length, + items: rawItems.map(normalizeSkillItem).filter(isInstallableCatalogItem), + } +} + +function normalizeSkillItem(raw: unknown): SkillsCatalogItem { + const item = raw && typeof raw === "object" ? (raw as Record) : {} + const source = stringField(item.source) + const slug = stringField(item.slug) + const id = stringField(item.id) || [source, slug].filter(Boolean).join("/") + const installUrl = nullableStringField(item.installUrl) ?? nullableStringField(item.install_url) + return { + rank: numberField(item.rank), + id, + slug, + source, + name: stringField(item.name) || slug || id, + installs: numberField(item.installs), + sourceType: stringField(item.sourceType) || stringField(item.source_type), + installUrl, + url: nullableStringField(item.url), + } +} + +function isInstallableCatalogItem(item: SkillsCatalogItem): boolean { + if (!item.source || !item.slug) return false + const sourceParts = item.source.split("/") + if (sourceParts.length !== 2 || !sourceParts.every(isSafeSkillRefPart)) return false + if (!isSafeSkillRefPart(item.slug)) return false + return true +} + +function isSafeSkillRefPart(value: string): boolean { + return value !== "." && value !== ".." && SAFE_SKILL_REF_PART.test(value) +} + +function stringField(value: unknown): string { + return typeof value === "string" ? value.trim() : "" +} + +function nullableStringField(value: unknown): string | null { + const text = stringField(value) + return text || null +} + +function numberField(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined +} + +async function installSkill( + workspaceID: string, + skill: SkillsCatalogItem, +): Promise { + return apiRequest( + `/api/v1/workspaces/${encodeURIComponent(workspaceID)}/skills/install`, + { + method: "POST", + body: { + source: skill.source, + slug: skill.slug, + registry_id: skill.id || `${skill.source}/${skill.slug}`, + registry: SKILLS_REGISTRY, + }, + }, + ) +} + +export function useSkillsCatalog() { + return useQuery({ + queryKey: KEY_SKILLS_CATALOG, + queryFn: listSkillsCatalog, + retry: noUnreachableRetry, + staleTime: 60_000, + }) +} + +export function useInstallSkill(workspaceID: string | null) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (skill: SkillsCatalogItem) => { + if (!workspaceID) throw new Error("workspace is required") + return installSkill(workspaceID, skill) + }, + retry: noUnreachableRetry, + onSuccess: (result) => { + if (!workspaceID) return + void qc.invalidateQueries({ queryKey: KEY_CAPABILITIES_WORKSPACE(workspaceID) }) + void qc.invalidateQueries({ queryKey: ["admin", "capability"] }) + void qc.invalidateQueries({ + queryKey: KEY_CAPABILITY_VERSIONS(workspaceID, result.capability.id), + }) + }, + }) +} diff --git a/apps/web/src/pages/admin/capabilities/MarketplaceTab.tsx b/apps/web/src/pages/admin/capabilities/MarketplaceTab.tsx index fa871ab..9fdfc5e 100644 --- a/apps/web/src/pages/admin/capabilities/MarketplaceTab.tsx +++ b/apps/web/src/pages/admin/capabilities/MarketplaceTab.tsx @@ -12,6 +12,7 @@ import { useWorkspaceId } from "../../../lib/workspace" import { requiredCredentialsLabel } from "../capability-ui" import type { Capability } from "../../../lib/api-types" import { MCPDirectory } from "./mcp-directory/MCPDirectory" +import { SkillsDirectory } from "./SkillsDirectory" interface MarketplaceTabProps { itemID: string | null @@ -41,7 +42,11 @@ export function MarketplaceTab(props: MarketplaceTabProps) { /> } - if (props.itemID || props.typeFilter === "skill") { + if (props.typeFilter === "skill") { + return + } + + if (props.itemID) { return } diff --git a/apps/web/src/pages/admin/capabilities/SkillsDirectory.tsx b/apps/web/src/pages/admin/capabilities/SkillsDirectory.tsx new file mode 100644 index 0000000..a2574ff --- /dev/null +++ b/apps/web/src/pages/admin/capabilities/SkillsDirectory.tsx @@ -0,0 +1,226 @@ +import { useMemo, useState } from "react" +import { useTranslation } from "react-i18next" +import { Check, Code2, Download, Loader2, PackageCheck } from "lucide-react" + +import { Badge } from "../../../components/ui/badge" +import { Button } from "../../../components/ui/button" +import { EmptyState } from "../../../components/ui/empty-state" +import { ErrorState } from "../../../components/ui/error-state" +import { Skeleton } from "../../../components/ui/skeleton" +import { useInstallSkill, useSkillsCatalog, type SkillsCatalogItem } from "../../../lib/api-skills" +import { useWorkspaceId } from "../../../lib/workspace" + +interface SkillsDirectoryProps { + query: string + canImport: boolean + onViewCapability: (capabilityID: string) => void +} + +export function SkillsDirectory({ query, canImport, onViewCapability }: SkillsDirectoryProps) { + const { t, i18n } = useTranslation("admin") + const workspaceID = useWorkspaceId() + const catalogQ = useSkillsCatalog() + const installMut = useInstallSkill(workspaceID) + const [installed, setInstalled] = useState>({}) + const [success, setSuccess] = useState<{ name: string; capabilityID: string } | null>(null) + const numberFormatter = useMemo(() => new Intl.NumberFormat(i18n.language), [i18n.language]) + + const filtered = useMemo(() => { + const needle = query.trim().toLowerCase() + const items = catalogQ.data?.items ?? [] + if (!needle) return items + return items.filter((item) => + [item.name, item.slug, item.source, item.id].some((value) => + value.toLowerCase().includes(needle), + ), + ) + }, [catalogQ.data?.items, query]) + const pendingID = installMut.isPending ? (installMut.variables?.id ?? null) : null + + const install = (skill: SkillsCatalogItem) => { + if (!canImport || installed[skill.id]) return + installMut.mutate(skill, { + onSuccess: (result) => { + setInstalled((current) => ({ ...current, [skill.id]: result.capability.id })) + setSuccess({ name: result.capability.name, capabilityID: result.capability.id }) + }, + }) + } + + return ( +
+ {success ? : null} + + {catalogQ.error ? ( + void catalogQ.refetch()} + /> + ) : null} + {installMut.error ? ( + installMut.reset()} + /> + ) : null} + + {catalogQ.isLoading ? ( +
+ {Array.from({ length: 6 }).map((_, index) => ( + + ))} +
+ ) : filtered.length === 0 && !catalogQ.error ? ( + + ) : filtered.length > 0 ? ( +
+ {filtered.map((skill) => ( + install(skill)} + onViewCapability={onViewCapability} + /> + ))} +
+ ) : null} +
+ ) +} + +function SkillCatalogCard({ + skill, + canImport, + installing, + installedCapabilityID, + numberFormatter, + onInstall, + onViewCapability, +}: { + skill: SkillsCatalogItem + canImport: boolean + installing: boolean + installedCapabilityID?: string + numberFormatter: Intl.NumberFormat + onInstall: () => void + onViewCapability: (capabilityID: string) => void +}) { + const { t } = useTranslation("admin") + return ( +
+
+
+ + + +
+
+

+ {skill.name || skill.slug} +

+ {skill.rank ? ( + #{skill.rank} + ) : null} +
+

{skill.source}

+
+
+

{skill.id}

+
+ Skill + {typeof skill.installs === "number" ? ( + + {t("capabilities.skillsDirectory.installs", { + count: numberFormatter.format(skill.installs), + })} + + ) : null} + {(skill.sourceType ?? skill.source_type) ? ( + {skill.sourceType ?? skill.source_type} + ) : null} +
+
+
+ {installedCapabilityID ? ( + + ) : ( + + )} +
+
+ ) +} + +function SuccessBanner({ + success, + onViewCapability, +}: { + success: { name: string; capabilityID: string } + onViewCapability: (capabilityID: string) => void +}) { + const { t } = useTranslation("admin") + return ( +
+ + + +

+ {t("capabilities.skillsDirectory.install.success", { name: success.name })} +

+ +
+ ) +} diff --git a/server/internal/dev/routes.go b/server/internal/dev/routes.go index 75afd77..1ca8023 100644 --- a/server/internal/dev/routes.go +++ b/server/internal/dev/routes.go @@ -265,6 +265,10 @@ type routerConfig struct { // Nil means the selected backend is unavailable — upload presign + // import 503 rather than failing boot. blobStore blob.Store + // skillInstallRunner downloads Skills.sh skills via the skills CLI. + // Nil uses npx at request time; tests inject a fake runner. + skillInstallRunner skillInstallCommandRunner + skillInstallHTTPClient skillInstallHTTPDoer // feishuJoinURLBuilder is invoked by the visibility=workspace // rejection card so the Feishu rejection surfaces a markdown @@ -415,6 +419,20 @@ func WithAuditIngester(ingester AuditIngester) RouterOption { } } +// WithSkillInstallRunner lets tests drive /skills/install without executing +// npx. Production leaves this unset and uses the real skills CLI via npx. +func WithSkillInstallRunner(runner skillInstallCommandRunner) RouterOption { + return func(cfg *routerConfig) { + cfg.skillInstallRunner = runner + } +} + +func WithSkillInstallHTTPClient(client skillInstallHTTPDoer) RouterOption { + return func(cfg *routerConfig) { + cfg.skillInstallHTTPClient = client + } +} + // AgentDaemonSandboxAcquirer is the subset of // connagentdaemon.SandboxProvider the dev router needs for eager // sandbox acquisition on agent creation and the manual /acquire @@ -628,6 +646,7 @@ func RegisterRoutesWithStore(r chi.Router, runtimeStore RuntimeStore, opts ...Ro // the all-or-nothing materialization. r.Post("/workspaces/{workspaceID}/capabilities/import/preview", previewCapabilityImport(runtimeStore, cfg.blobStore)) r.Post("/workspaces/{workspaceID}/capabilities/import/commit", commitCapabilityImport(runtimeStore, cfg.blobStore)) + r.Post("/workspaces/{workspaceID}/skills/install", installSkillFromRegistry(runtimeStore, cfg.blobStore, cfg.skillInstallRunner, cfg.skillInstallHTTPClient)) // Plugin upload presign — browser PUTs the zip directly to // the blob backend, then calls import/commit with the returned // ossKey. presign-download checks ossKey belongs to the calling diff --git a/server/internal/dev/skills_install_routes.go b/server/internal/dev/skills_install_routes.go new file mode 100644 index 0000000..e9ac4a2 --- /dev/null +++ b/server/internal/dev/skills_install_routes.go @@ -0,0 +1,457 @@ +package dev + +import ( + "archive/zip" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/server/internal/capability/canonical" + "github.com/MiniMax-AI-Dev/parsar/server/internal/storage/blob" +) + +const ( + skillsRegistryName = "skills.sh" + skillInstallAgentName = "claude-code" + skillInstallTmpPrefix = "teamgent-skill-*" +) + +type installSkillRequest struct { + Source string `json:"source"` + Slug string `json:"slug"` + RegistryID string `json:"registry_id"` + Registry string `json:"registry"` +} + +type skillInstallCommandRunner interface { + Run(ctx context.Context, dir string, name string, args ...string) ([]byte, error) +} + +type defaultSkillInstallRunner struct{} + +func (defaultSkillInstallRunner) Run(ctx context.Context, dir string, name string, args ...string) ([]byte, error) { + cmd := exec.CommandContext(ctx, name, args...) + cmd.Dir = dir + return cmd.CombinedOutput() +} + +type skillInstallHTTPDoer interface { + Do(*http.Request) (*http.Response, error) +} + +// installSkillFromRegistry downloads a Skills.sh skill into a temp directory, +// packages the downloaded Skill folder as a zip, stores that zip in the same +// blob backend used by manual Skill zip imports, and then calls the existing +// capability import path. It intentionally does not parse SKILL.md itself. +// +// @Summary Install a Skills.sh Skill as a capability +// @Description Downloads a Skills.sh Skill with npx skills add, stores the Skill folder as a zip, and reuses the existing Skill zip import flow. Owner/admin only. +// @Tags capabilities +// @ID installSkillsShSkill +// @Accept json +// @Produce json +// @Param workspaceID path string true "Workspace UUID" +// @Param body body installSkillRequest true "Skills.sh install payload" +// @Success 201 {object} commitCapabilityImportResponse +// @Failure 400 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Failure 422 {object} map[string]string +// @Failure 502 {object} map[string]string +// @Failure 503 {object} map[string]string +// @Router /api/v1/workspaces/{workspaceID}/skills/install [post] +func installSkillFromRegistry(runtimeStore RuntimeStore, blobStore blob.Store, runner skillInstallCommandRunner, httpClient skillInstallHTTPDoer) http.HandlerFunc { + if runner == nil { + runner = defaultSkillInstallRunner{} + } + if httpClient == nil { + httpClient = http.DefaultClient + } + return func(w http.ResponseWriter, r *http.Request) { + workspaceID, ok := requireWorkspaceCapabilityAdmin(w, r, runtimeStore) + if !ok { + return + } + if blobStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "object storage is not configured on this deployment", "code": "OSS_NOT_CONFIGURED"}) + return + } + + var body installSkillRequest + if err := decodeBody(r, &body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + if msg := validateInstallSkillRequest(body); msg != "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": msg}) + return + } + + tmpDir, err := os.MkdirTemp("", skillInstallTmpPrefix) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not create temporary skill directory"}) + return + } + defer os.RemoveAll(tmpDir) + + skillDir, err := downloadSkillToTemp(r.Context(), runner, tmpDir, body.Source, body.Slug) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + zipBytes, err := zipSkillDirectory(skillDir) + if err != nil { + writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()}) + return + } + ossKey, httpErr := storeSkillZipBytes(r.Context(), blobStore, httpClient, workspaceID, body.Slug+".zip", zipBytes) + if httpErr != nil { + writeJSON(w, httpErr.status, map[string]string{"error": httpErr.message}) + return + } + + // Preview-equivalent parse through the existing zip import code so the + // capability name/description come from the uploaded zip, not the catalog. + spec, _, httpErr := rebuildSkillSpecFromOSS(r.Context(), workspaceID, ossKey, blobStore) + if httpErr != nil { + writeJSON(w, httpErr.status, map[string]string{"error": httpErr.message}) + return + } + + sourcePayload, err := json.Marshal(map[string]any{ + "registry": skillsRegistryName, + "registry_id": strings.TrimSpace(body.RegistryID), + "source": strings.TrimSpace(body.Source), + "slug": strings.TrimSpace(body.Slug), + }) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not encode skill source payload"}) + return + } + + commitBody := commitCapabilityImportBody{ + Kind: string(canonical.KindSkill), + Name: skillImportName(spec, body), + Description: skillImportDescription(spec), + Type: string(canonical.KindSkill), + SourcePayload: sourcePayload, + CanonicalSpec: spec, + OssKey: ossKey, + UploadSource: string(canonical.UploadSourceZip), + } + relayCapabilityImportCommit(w, r, runtimeStore, blobStore, workspaceID, commitBody) + } +} + +func relayCapabilityImportCommit(w http.ResponseWriter, r *http.Request, runtimeStore RuntimeStore, blobStore blob.Store, workspaceID string, body commitCapabilityImportBody) { + payload, err := json.Marshal(body) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not encode skill import commit payload"}) + return + } + req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, "/api/v1/workspaces/"+workspaceID+"/capabilities/import/commit", bytes.NewReader(payload)) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not build skill import commit request"}) + return + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + recorder := newSkillInstallCommitRecorder() + commitCapabilityImport(runtimeStore, blobStore).ServeHTTP(recorder, req) + for key, values := range recorder.Header() { + for _, value := range values { + w.Header().Add(key, value) + } + } + status := recorder.status + if status == 0 { + status = http.StatusOK + } + w.WriteHeader(status) + _, _ = w.Write(recorder.Body.Bytes()) +} + +type skillInstallCommitRecorder struct { + header http.Header + status int + Body bytes.Buffer +} + +func newSkillInstallCommitRecorder() *skillInstallCommitRecorder { + return &skillInstallCommitRecorder{header: http.Header{}} +} + +func (r *skillInstallCommitRecorder) Header() http.Header { + return r.header +} + +func (r *skillInstallCommitRecorder) WriteHeader(status int) { + if r.status == 0 { + r.status = status + } +} + +func (r *skillInstallCommitRecorder) Write(p []byte) (int, error) { + if r.status == 0 { + r.status = http.StatusOK + } + return r.Body.Write(p) +} + +func validateInstallSkillRequest(body installSkillRequest) string { + if strings.TrimSpace(body.Registry) != skillsRegistryName { + return "registry must be skills.sh" + } + if !validSkillSourceRef(body.Source) { + return "source must be an owner/repo GitHub reference" + } + if !validSkillSlug(body.Slug) { + return "slug is required and may only contain letters, numbers, dot, underscore, and hyphen" + } + if strings.TrimSpace(body.RegistryID) == "" { + return "registry_id is required" + } + return "" +} + +func validSkillSourceRef(source string) bool { + parts := strings.Split(strings.TrimSpace(strings.Trim(source, "/")), "/") + return len(parts) == 2 && validSkillRefPart(parts[0]) && validSkillRefPart(parts[1]) +} + +func validSkillSlug(slug string) bool { + return validSkillRefPart(slug) +} + +func validSkillRefPart(part string) bool { + if part == "" || part != strings.TrimSpace(part) || part == "." || part == ".." { + return false + } + for _, r := range part { + switch { + case r >= 'a' && r <= 'z': + case r >= 'A' && r <= 'Z': + case r >= '0' && r <= '9': + case r == '.', r == '_', r == '-': + default: + return false + } + } + return true +} + +func downloadSkillToTemp(ctx context.Context, runner skillInstallCommandRunner, tmpDir, source, slug string) (string, error) { + output, err := runner.Run(ctx, tmpDir, "npx", "--yes", "skills", "add", strings.TrimSpace(source), "--skill", strings.TrimSpace(slug), "--agent", skillInstallAgentName, "--copy", "--yes") + if err != nil { + return "", fmt.Errorf("skills add failed: %s", compactCommandOutput(output, err)) + } + skillDir, err := findDownloadedSkillDir(tmpDir, slug) + if err != nil { + if len(output) > 0 { + return "", fmt.Errorf("%w; skills output: %s", err, compactCommandOutput(output, nil)) + } + return "", err + } + return skillDir, nil +} + +func findDownloadedSkillDir(tmpDir, slug string) (string, error) { + primary := filepath.Join(tmpDir, ".claude", "skills", slug) + if containsSkillMD(primary) { + return primary, nil + } + var candidates []string + if err := filepath.WalkDir(tmpDir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() { + return nil + } + if strings.EqualFold(d.Name(), slug) && containsSkillMD(path) { + candidates = append(candidates, path) + } + return nil + }); err != nil { + return "", fmt.Errorf("could not inspect downloaded skill directory: %w", err) + } + if len(candidates) > 0 { + return preferClaudeSkillDir(candidates), nil + } + return "", fmt.Errorf("downloaded skill %q did not contain .claude/skills/%s/SKILL.md", slug, slug) +} + +func containsSkillMD(dir string) bool { + entries, err := os.ReadDir(dir) + if err != nil { + return false + } + for _, entry := range entries { + if !entry.IsDir() && strings.EqualFold(entry.Name(), "SKILL.md") { + return true + } + } + return false +} + +func preferClaudeSkillDir(candidates []string) string { + for _, candidate := range candidates { + clean := filepath.ToSlash(filepath.Clean(candidate)) + if strings.Contains(clean, "/.claude/skills/") { + return candidate + } + } + return candidates[0] +} + +func zipSkillDirectory(skillDir string) ([]byte, error) { + if !containsSkillMD(skillDir) { + return nil, fmt.Errorf("skill directory %q is missing SKILL.md", skillDir) + } + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + err := filepath.WalkDir(skillDir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + if d.Type()&os.ModeSymlink != 0 { + return nil + } + rel, err := filepath.Rel(skillDir, path) + if err != nil { + return err + } + rel = filepath.ToSlash(rel) + if rel == "." || rel == "" { + return nil + } + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + entry, err := zw.Create(rel) + if err != nil { + return err + } + _, err = io.Copy(entry, file) + return err + }) + if closeErr := zw.Close(); err == nil { + err = closeErr + } + if err != nil { + return nil, fmt.Errorf("could not package skill directory: %w", err) + } + return buf.Bytes(), nil +} + +func storeSkillZipBytes(ctx context.Context, blobStore blob.Store, httpClient skillInstallHTTPDoer, workspaceID, filename string, data []byte) (string, *importHTTPError) { + if blobStore == nil { + return "", &importHTTPError{status: http.StatusServiceUnavailable, message: "object storage is not configured on this deployment"} + } + if int64(len(data)) > blob.MaxBlobBytes { + return "", &importHTTPError{status: http.StatusRequestEntityTooLarge, message: "skill zip exceeds max blob size"} + } + ref, err := blobStore.NewRef("skill", workspaceID, filename) + if err != nil { + return "", &importHTTPError{status: http.StatusInternalServerError, message: "could not allocate storage reference"} + } + if putter, ok := blobStore.(interface { + PutBytes(context.Context, string, string, []byte) error + }); ok { + if err := putter.PutBytes(ctx, ref, workspaceID, data); err != nil { + return "", blobPutHTTPError(err) + } + return ref, nil + } + spec, err := blobStore.UploadURL(ctx, ref, workspaceID, 0) + if err != nil { + return "", &importHTTPError{status: http.StatusInternalServerError, message: "could not generate upload URL"} + } + method := strings.TrimSpace(spec.Method) + if method == "" { + method = http.MethodPut + } + req, err := http.NewRequestWithContext(ctx, method, spec.URL, bytes.NewReader(data)) + if err != nil { + return "", &importHTTPError{status: http.StatusInternalServerError, message: "could not build upload request"} + } + if len(spec.Headers) == 0 { + req.Header.Set("Content-Type", "application/octet-stream") + } else { + for key, value := range spec.Headers { + req.Header.Set(key, value) + } + } + resp, err := httpClient.Do(req) + if err != nil { + return "", &importHTTPError{status: http.StatusBadGateway, message: "could not upload skill zip to object storage: " + err.Error()} + } + defer resp.Body.Close() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + detail, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + msg := fmt.Sprintf("object storage upload failed: HTTP %d", resp.StatusCode) + if trimmed := strings.TrimSpace(string(detail)); trimmed != "" { + msg += ": " + trimmed + } + return "", &importHTTPError{status: http.StatusBadGateway, message: msg} + } + return ref, nil +} + +func blobPutHTTPError(err error) *importHTTPError { + switch { + case errors.Is(err, blob.ErrTooLarge): + return &importHTTPError{status: http.StatusRequestEntityTooLarge, message: "skill zip exceeds max blob size"} + case errors.Is(err, blob.ErrInvalidRef): + return &importHTTPError{status: http.StatusInternalServerError, message: "invalid storage reference"} + default: + return &importHTTPError{status: http.StatusInternalServerError, message: "could not store skill zip"} + } +} + +func skillImportName(spec canonical.Spec, body installSkillRequest) string { + if spec.Skill != nil { + if title := strings.TrimSpace(spec.Skill.Title); title != "" { + return title + } + if slug := strings.TrimSpace(spec.Skill.Slug); slug != "" { + return slug + } + } + if name := strings.TrimSpace(body.Slug); name != "" { + return name + } + return strings.TrimSpace(body.RegistryID) +} + +func skillImportDescription(spec canonical.Spec) string { + if spec.Skill == nil { + return "" + } + return strings.TrimSpace(spec.Skill.Description) +} + +func compactCommandOutput(output []byte, err error) string { + text := strings.TrimSpace(string(output)) + if text == "" && err != nil { + text = err.Error() + } + text = strings.Join(strings.Fields(text), " ") + if len(text) > 600 { + return text[:600] + "..." + } + return text +} diff --git a/server/internal/dev/skills_install_routes_test.go b/server/internal/dev/skills_install_routes_test.go new file mode 100644 index 0000000..127b449 --- /dev/null +++ b/server/internal/dev/skills_install_routes_test.go @@ -0,0 +1,208 @@ +package dev + +import ( + "context" + "encoding/json" + "net/http" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/MiniMax-AI-Dev/parsar/server/internal/capability/canonical" + "github.com/MiniMax-AI-Dev/parsar/server/internal/storage/blob" + "github.com/MiniMax-AI-Dev/parsar/server/internal/store" +) + +type fakeSkillInstallRunner struct { + t *testing.T + called bool + lastDir string + lastName string + lastArgs []string + writeSkill bool + returnOutput []byte + returnError error + writtenRefPath string +} + +func (r *fakeSkillInstallRunner) Run(ctx context.Context, dir string, name string, args ...string) ([]byte, error) { + r.t.Helper() + r.called = true + r.lastDir = dir + r.lastName = name + r.lastArgs = append([]string(nil), args...) + if r.writeSkill { + skillDir := filepath.Join(dir, ".claude", "skills", "gws-gmail-triage") + if err := os.MkdirAll(filepath.Join(skillDir, "references"), 0o755); err != nil { + r.t.Fatalf("create fake skill references dir: %v", err) + } + if err := os.MkdirAll(filepath.Join(skillDir, "scripts"), 0o755); err != nil { + r.t.Fatalf("create fake skill scripts dir: %v", err) + } + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: Gmail Triage\ndescription: Triage Gmail with Workspace CLI\n---\nUse Gmail labels and search operators to triage mail.\n"), 0o644); err != nil { + r.t.Fatalf("write fake SKILL.md: %v", err) + } + r.writtenRefPath = filepath.Join(skillDir, "references", "triage.md") + if err := os.WriteFile(r.writtenRefPath, []byte("# Triage\nCheck labels first.\n"), 0o644); err != nil { + r.t.Fatalf("write fake reference: %v", err) + } + if err := os.WriteFile(filepath.Join(skillDir, "scripts", "triage.sh"), []byte("#!/bin/sh\necho triage\n"), 0o755); err != nil { + r.t.Fatalf("write fake script: %v", err) + } + } + return r.returnOutput, r.returnError +} + +func skillsInstallTestRouter(t *testing.T, runner skillInstallCommandRunner) (http.Handler, *pgxpool.Pool, *blob.MemoryStore) { + t.Helper() + db := openDevRouteTestDB(t) + s := store.New(db) + if _, err := s.SeedDevFixture(context.Background()); err != nil { + t.Fatal(err) + } + rbac := &capabilityRBACStore{RuntimeStore: s, workspaceRoles: map[string]string{store.DefaultDevFixtureIDs().UserID: "admin"}} + bs := blob.NewMemoryStore("https://api.test") + r := chi.NewRouter() + RegisterRoutesWithStore(r, rbac, WithBlobStore(bs), WithSkillInstallRunner(runner)) + return r, db, bs +} + +func TestSkillInstallFromRegistry_HappyPathReusesSkillZipImport(t *testing.T) { + runner := &fakeSkillInstallRunner{t: t, writeSkill: true, returnOutput: []byte("added gws-gmail-triage")} + r, db, bs := skillsInstallTestRouter(t, runner) + ids := store.DefaultDevFixtureIDs() + + body := mustJSON(t, map[string]any{ + "source": "googleworkspace/cli", + "slug": "gws-gmail-triage", + "registry_id": "googleworkspace/cli/gws-gmail-triage", + "registry": "skills.sh", + }) + res := serveCapabilityRoute(t, r, http.MethodPost, "/api/v1/workspaces/"+ids.WorkspaceID+"/skills/install", body, ids.UserID) + if res.Code != http.StatusCreated { + t.Fatalf("install expected 201, got %d: %s", res.Code, res.Body.String()) + } + if !runner.called { + t.Fatal("skills runner was not called") + } + wantArgs := []string{"--yes", "skills", "add", "googleworkspace/cli", "--skill", "gws-gmail-triage", "--agent", "claude-code", "--copy", "--yes"} + if runner.lastName != "npx" || !reflect.DeepEqual(runner.lastArgs, wantArgs) { + t.Fatalf("runner command = %s %v, want npx %v", runner.lastName, runner.lastArgs, wantArgs) + } + + var parsed commitCapabilityImportResponse + if err := json.Unmarshal(res.Body.Bytes(), &parsed); err != nil { + t.Fatalf("decode install response: %v\nbody=%s", err, res.Body.String()) + } + if parsed.Capability.Type != string(canonical.KindSkill) { + t.Fatalf("capability type = %q, want skill", parsed.Capability.Type) + } + if parsed.Capability.Name != "Gmail Triage" { + t.Fatalf("capability name = %q, want Gmail Triage", parsed.Capability.Name) + } + if parsed.CapabilityVersion.OssKey == "" || !strings.HasPrefix(parsed.CapabilityVersion.OssKey, "pg:") { + t.Fatalf("oss_key = %q, want pg ref", parsed.CapabilityVersion.OssKey) + } + if len(parsed.CapabilityVersion.SHA256) != 64 { + t.Fatalf("sha256 = %q, want 64-char hex", parsed.CapabilityVersion.SHA256) + } + if _, err := bs.Download(context.Background(), parsed.CapabilityVersion.OssKey); err != nil { + t.Fatalf("stored zip not downloadable from memory blob: %v", err) + } + + var spec canonical.Spec + if err := json.Unmarshal(lookupCanonicalSpec(t, db, parsed.CapabilityVersion.ID), &spec); err != nil { + t.Fatalf("decode canonical_spec: %v", err) + } + if spec.Kind != canonical.KindSkill || spec.Skill == nil { + t.Fatalf("canonical spec should be a skill, got %+v", spec) + } + if spec.Skill.Slug != "gmail-triage" { + t.Fatalf("skill slug = %q, want gmail-triage", spec.Skill.Slug) + } + filesByPath := map[string]canonical.SkillFile{} + for _, file := range spec.Skill.Files { + filesByPath[file.Path] = file + } + if filesByPath["references/triage.md"].Kind != canonical.SkillFileKindMarkdown { + t.Fatalf("references/triage.md missing or wrong kind: %+v", filesByPath["references/triage.md"]) + } + if filesByPath["scripts/triage.sh"].Kind != canonical.SkillFileKindScript { + t.Fatalf("scripts/triage.sh missing or wrong kind: %+v", filesByPath["scripts/triage.sh"]) + } + + var sourcePayload []byte + if err := db.QueryRow(context.Background(), "select source_payload from capability_version where id = $1", parsed.CapabilityVersion.ID).Scan(&sourcePayload); err != nil { + t.Fatalf("lookup source_payload: %v", err) + } + var source map[string]string + if err := json.Unmarshal(sourcePayload, &source); err != nil { + t.Fatalf("decode source_payload: %v", err) + } + for key, want := range map[string]string{ + "registry": "skills.sh", + "registry_id": "googleworkspace/cli/gws-gmail-triage", + "source": "googleworkspace/cli", + "slug": "gws-gmail-triage", + } { + if source[key] != want { + t.Fatalf("source_payload[%s] = %q, want %q (payload=%s)", key, source[key], want, string(sourcePayload)) + } + } + if _, err := os.Stat(runner.lastDir); !os.IsNotExist(err) { + t.Fatalf("temp dir should be removed after install, stat err=%v", err) + } +} + +func TestSkillInstallFromRegistry_MissingSkillMarkdownFails(t *testing.T) { + runner := &fakeSkillInstallRunner{t: t, writeSkill: false, returnOutput: []byte("added nothing")} + r, db, _ := skillsInstallTestRouter(t, runner) + ids := store.DefaultDevFixtureIDs() + body := mustJSON(t, map[string]any{ + "source": "googleworkspace/cli", + "slug": "gws-gmail-triage", + "registry_id": "googleworkspace/cli/gws-gmail-triage", + "registry": "skills.sh", + }) + res := serveCapabilityRoute(t, r, http.MethodPost, "/api/v1/workspaces/"+ids.WorkspaceID+"/skills/install", body, ids.UserID) + if res.Code != http.StatusBadGateway { + t.Fatalf("install expected 502 for missing SKILL.md, got %d: %s", res.Code, res.Body.String()) + } + if !strings.Contains(res.Body.String(), "SKILL.md") { + t.Fatalf("response should mention missing SKILL.md, got: %s", res.Body.String()) + } + var count int + if err := db.QueryRow(context.Background(), "select count(*) from capability where workspace_id = $1 and type = 'skill'", ids.WorkspaceID).Scan(&count); err != nil { + t.Fatalf("count skill capabilities: %v", err) + } + if count != 0 { + t.Fatalf("expected no skill capability to be created, got %d", count) + } + if _, err := os.Stat(runner.lastDir); !os.IsNotExist(err) { + t.Fatalf("temp dir should be removed after failed install, stat err=%v", err) + } +} + +func TestSkillInstallFromRegistry_InvalidRegistryRejected(t *testing.T) { + runner := &fakeSkillInstallRunner{t: t, writeSkill: true} + r, _, _ := skillsInstallTestRouter(t, runner) + ids := store.DefaultDevFixtureIDs() + body := mustJSON(t, map[string]any{ + "source": "googleworkspace/cli", + "slug": "gws-gmail-triage", + "registry_id": "googleworkspace/cli/gws-gmail-triage", + "registry": "other", + }) + res := serveCapabilityRoute(t, r, http.MethodPost, "/api/v1/workspaces/"+ids.WorkspaceID+"/skills/install", body, ids.UserID) + if res.Code != http.StatusBadRequest { + t.Fatalf("install expected 400 for invalid registry, got %d: %s", res.Code, res.Body.String()) + } + if runner.called { + t.Fatal("runner should not be called for invalid registry") + } +} From b049edea571d072958ceb7141ecf7f25dc52f617 Mon Sep 17 00:00:00 2001 From: kapelame Date: Thu, 6 Aug 2026 15:46:42 +0800 Subject: [PATCH 2/4] Update OpenAPI for Skills install route --- docs/openapi/openapi.yaml | 72 ++++++++++++++++++++ server/internal/dev/skills_install_routes.go | 2 +- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/docs/openapi/openapi.yaml b/docs/openapi/openapi.yaml index ab70186..181d523 100644 --- a/docs/openapi/openapi.yaml +++ b/docs/openapi/openapi.yaml @@ -631,6 +631,17 @@ definitions: type: string type: array type: object + dev.installSkillRequest: + properties: + registry: + type: string + registry_id: + type: string + slug: + type: string + source: + type: string + type: object dev.listAgentInteractionsResponse: properties: interactions: @@ -8399,6 +8410,67 @@ paths: summary: Update workspace settings tags: - workspaces + /api/v1/workspaces/{workspaceID}/skills/install: + post: + consumes: + - application/json + description: Downloads a Skills.sh Skill with npx skills add, stores the Skill + folder as a zip, and reuses the existing Skill zip import flow. Owner/admin + only. + operationId: installSkillsShSkill + parameters: + - description: Workspace UUID + in: path + name: workspaceID + required: true + type: string + - description: Skills.sh install payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/dev.installSkillRequest' + produces: + - application/json + responses: + "201": + description: Created capability, version, and secret ids + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "403": + description: Forbidden + schema: + additionalProperties: + type: string + type: object + "422": + description: Unprocessable Entity + schema: + additionalProperties: + type: string + type: object + "502": + description: Bad Gateway + schema: + additionalProperties: + type: string + type: object + "503": + description: Service Unavailable + schema: + additionalProperties: + type: string + type: object + summary: Install a Skills.sh Skill as a capability + tags: + - capabilities /api/v1/workspaces/{workspaceID}/spec/fragments: get: description: Returns spec fragments visible to the workspace. Optional source/tag diff --git a/server/internal/dev/skills_install_routes.go b/server/internal/dev/skills_install_routes.go index e9ac4a2..0b355a0 100644 --- a/server/internal/dev/skills_install_routes.go +++ b/server/internal/dev/skills_install_routes.go @@ -60,7 +60,7 @@ type skillInstallHTTPDoer interface { // @Produce json // @Param workspaceID path string true "Workspace UUID" // @Param body body installSkillRequest true "Skills.sh install payload" -// @Success 201 {object} commitCapabilityImportResponse +// @Success 201 {object} map[string]interface{} "Created capability, version, and secret ids" // @Failure 400 {object} map[string]string // @Failure 403 {object} map[string]string // @Failure 422 {object} map[string]string From 6d79d2ae2a9c72231b6d4bf2ff9f0dd9a4e58b86 Mon Sep 17 00:00:00 2001 From: kapelame Date: Thu, 6 Aug 2026 16:29:42 +0800 Subject: [PATCH 3/4] Scope capability marketplace to workspace --- apps/web/src/lib/api-marketplace.ts | 6 +- docs/openapi/openapi.yaml | 169 +++++++++--------- server/internal/db/queries/store.sql | 2 +- server/internal/db/sqlc/store.sql.go | 2 +- server/internal/dev/capability_routes.go | 54 ++---- server/internal/dev/routes.go | 4 +- server/internal/dev/routes_agents.go | 7 +- server/internal/dev/routes_capability_test.go | 85 +++++---- 8 files changed, 162 insertions(+), 167 deletions(-) diff --git a/apps/web/src/lib/api-marketplace.ts b/apps/web/src/lib/api-marketplace.ts index e30a991..db665b9 100644 --- a/apps/web/src/lib/api-marketplace.ts +++ b/apps/web/src/lib/api-marketplace.ts @@ -159,8 +159,7 @@ export const KEY_MCP_DIRECTORY_DETAIL = (workspaceID: string, catalogID: string) async function listMarketplace(workspaceID: string | null): Promise { if (!workspaceID) return [] const data = await apiRequest( - `/api/v1/capabilities/marketplace`, - { query: { workspace_id: workspaceID } }, + `/api/v1/workspaces/${encodeURIComponent(workspaceID)}/marketplace/capabilities`, ) if (Array.isArray(data)) return data return (data.capabilities ?? data.marketplace ?? data.items ?? []).map(normalizeMarketplaceCapability) @@ -172,8 +171,7 @@ async function getMarketplaceDetail( ): Promise { if (!workspaceID || !capabilityID) throw new Error("workspace and capability are required") const data = await apiRequest( - `/api/v1/capabilities/marketplace/${encodeURIComponent(capabilityID)}`, - { query: { workspace_id: workspaceID } }, + `/api/v1/workspaces/${encodeURIComponent(workspaceID)}/marketplace/capabilities/${encodeURIComponent(capabilityID)}`, ) return data.capability } diff --git a/docs/openapi/openapi.yaml b/docs/openapi/openapi.yaml index 181d523..5e4df97 100644 --- a/docs/openapi/openapi.yaml +++ b/docs/openapi/openapi.yaml @@ -3102,90 +3102,6 @@ paths: summary: Bootstrap status tags: - bootstrap - /api/v1/capabilities/marketplace: - get: - description: Lists public MCP and Skill capabilities offered on the marketplace, - filtered to items visible to the caller's workspace. Workspace is taken from - ?workspace_id or the X-Parsar-Workspace-ID header. - operationId: listDevMarketplaceCapabilities - parameters: - - description: Workspace UUID (falls back to X-Parsar-Workspace-ID header) - in: query - name: workspace_id - type: string - produces: - - application/json - responses: - "200": - description: Marketplace capabilities visible to the workspace - schema: - additionalProperties: true - type: object - "400": - description: workspace_id must be a valid uuid - schema: - additionalProperties: - type: string - type: object - "403": - description: Caller is not an active workspace member - schema: - additionalProperties: - type: string - type: object - "503": - description: Database-backed capability APIs are disabled - schema: - additionalProperties: - type: string - type: object - summary: List marketplace capabilities - tags: - - capabilities - /api/v1/capabilities/marketplace/{capabilityID}: - get: - description: Returns the latest public MCP or Skill definition. Inline secret - IDs are redacted. - operationId: getDevMarketplaceCapabilityDetail - parameters: - - description: Capability UUID - in: path - name: capabilityID - required: true - type: string - - description: Workspace UUID (falls back to X-Parsar-Workspace-ID header) - in: query - name: workspace_id - type: string - produces: - - application/json - responses: - "200": - description: Marketplace capability detail - schema: - additionalProperties: true - type: object - "400": - description: workspace_id or capabilityID is invalid - schema: - additionalProperties: - type: string - type: object - "403": - description: Caller is not an active workspace member - schema: - additionalProperties: - type: string - type: object - "404": - description: Capability is not available in the Marketplace - schema: - additionalProperties: - type: string - type: object - summary: Get marketplace capability detail - tags: - - capabilities /api/v1/connections/github/callback: get: description: Verifies CSRF state, exchanges the code for a GitHub access token, @@ -7009,6 +6925,91 @@ paths: summary: Resolve a pending approval or user question tags: - interactions + /api/v1/workspaces/{workspaceID}/marketplace/capabilities: + get: + description: Lists active MCP and Skill capabilities in the requested workspace + marketplace. + operationId: listDevMarketplaceCapabilities + parameters: + - description: Workspace UUID + in: path + name: workspaceID + required: true + type: string + produces: + - application/json + responses: + "200": + description: Marketplace capabilities visible to the workspace + schema: + additionalProperties: true + type: object + "400": + description: workspace_id must be a valid uuid + schema: + additionalProperties: + type: string + type: object + "403": + description: Caller is not an active workspace member + schema: + additionalProperties: + type: string + type: object + "503": + description: Database-backed capability APIs are disabled + schema: + additionalProperties: + type: string + type: object + summary: List marketplace capabilities + tags: + - capabilities + /api/v1/workspaces/{workspaceID}/marketplace/capabilities/{capabilityID}: + get: + description: Returns the latest workspace-scoped MCP or Skill definition. Inline + secret IDs are redacted. + operationId: getDevMarketplaceCapabilityDetail + parameters: + - description: Workspace UUID + in: path + name: workspaceID + required: true + type: string + - description: Capability UUID + in: path + name: capabilityID + required: true + type: string + produces: + - application/json + responses: + "200": + description: Marketplace capability detail + schema: + additionalProperties: true + type: object + "400": + description: workspace_id or capabilityID is invalid + schema: + additionalProperties: + type: string + type: object + "403": + description: Caller is not an active workspace member + schema: + additionalProperties: + type: string + type: object + "404": + description: Capability is not available in the Marketplace + schema: + additionalProperties: + type: string + type: object + summary: Get marketplace capability detail + tags: + - capabilities /api/v1/workspaces/{workspaceID}/mcp-directory: get: parameters: diff --git a/server/internal/db/queries/store.sql b/server/internal/db/queries/store.sql index 3d891dd..af6d6b2 100644 --- a/server/internal/db/queries/store.sql +++ b/server/internal/db/queries/store.sql @@ -3689,7 +3689,7 @@ join lateral ( limit 1 ) cv on true left join installed on installed.capability_id = c.id -where c.visibility = 'public' +where c.workspace_id = @target_workspace_id::uuid and c.status = 'active' and c.deprecated_at is null and c.deleted_at is null diff --git a/server/internal/db/sqlc/store.sql.go b/server/internal/db/sqlc/store.sql.go index 6f73a0a..f912c2e 100644 --- a/server/internal/db/sqlc/store.sql.go +++ b/server/internal/db/sqlc/store.sql.go @@ -8356,7 +8356,7 @@ join lateral ( limit 1 ) cv on true left join installed on installed.capability_id = c.id -where c.visibility = 'public' +where c.workspace_id = $1::uuid and c.status = 'active' and c.deprecated_at is null and c.deleted_at is null diff --git a/server/internal/dev/capability_routes.go b/server/internal/dev/capability_routes.go index 6233308..4e95b60 100644 --- a/server/internal/dev/capability_routes.go +++ b/server/internal/dev/capability_routes.go @@ -349,36 +349,24 @@ func listWorkspaceCapabilities(runtimeStore RuntimeStore) http.HandlerFunc { } } -// listMarketplaceCapabilities lists all public capabilities offered on the -// marketplace, filtered to items the caller's workspace can install. +// listMarketplaceCapabilities lists active capabilities in the caller's +// workspace marketplace. // // @Summary List marketplace capabilities -// @Description Lists public MCP and Skill capabilities offered on the marketplace, filtered to items visible to the caller's workspace. Workspace is taken from ?workspace_id or the X-Parsar-Workspace-ID header. +// @Description Lists active MCP and Skill capabilities in the requested workspace marketplace. // @Tags capabilities // @ID listDevMarketplaceCapabilities // @Produce json -// @Param workspace_id query string false "Workspace UUID (falls back to X-Parsar-Workspace-ID header)" +// @Param workspaceID path string true "Workspace UUID" // @Success 200 {object} map[string]interface{} "Marketplace capabilities visible to the workspace" // @Failure 400 {object} map[string]string "workspace_id must be a valid uuid" // @Failure 403 {object} map[string]string "Caller is not an active workspace member" // @Failure 503 {object} map[string]string "Database-backed capability APIs are disabled" -// @Router /api/v1/capabilities/marketplace [get] +// @Router /api/v1/workspaces/{workspaceID}/marketplace/capabilities [get] func listMarketplaceCapabilities(runtimeStore RuntimeStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - workspaceID := strings.TrimSpace(r.URL.Query().Get("workspace_id")) - if workspaceID == "" { - workspaceID = strings.TrimSpace(r.Header.Get("X-Parsar-Workspace-ID")) - } - if !isUUID(workspaceID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) - return - } - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed capability APIs are disabled"}) - return - } - if err := requireWorkspaceMember(r, runtimeStore, workspaceID); err != nil { - writeRBACError(w, err) + workspaceID, ok := requireWorkspaceCapabilityRead(w, r, runtimeStore) + if !ok { return } capabilities, err := runtimeStore.ListMarketplaceCapabilities(r.Context(), workspaceID) @@ -405,29 +393,25 @@ func isListedCapabilityType(capabilityType string) bool { } } -// getMarketplaceCapabilityDetail returns the latest public MCP/Skill body on -// demand so list responses stay lightweight. +// getMarketplaceCapabilityDetail returns the latest workspace marketplace +// MCP/Skill body on demand so list responses stay lightweight. // // @Summary Get marketplace capability detail -// @Description Returns the latest public MCP or Skill definition. Inline secret IDs are redacted. +// @Description Returns the latest workspace-scoped MCP or Skill definition. Inline secret IDs are redacted. // @Tags capabilities // @ID getDevMarketplaceCapabilityDetail // @Produce json +// @Param workspaceID path string true "Workspace UUID" // @Param capabilityID path string true "Capability UUID" -// @Param workspace_id query string false "Workspace UUID (falls back to X-Parsar-Workspace-ID header)" // @Success 200 {object} map[string]interface{} "Marketplace capability detail" // @Failure 400 {object} map[string]string "workspace_id or capabilityID is invalid" // @Failure 403 {object} map[string]string "Caller is not an active workspace member" // @Failure 404 {object} map[string]string "Capability is not available in the Marketplace" -// @Router /api/v1/capabilities/marketplace/{capabilityID} [get] +// @Router /api/v1/workspaces/{workspaceID}/marketplace/capabilities/{capabilityID} [get] func getMarketplaceCapabilityDetail(runtimeStore RuntimeStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - workspaceID := strings.TrimSpace(r.URL.Query().Get("workspace_id")) - if workspaceID == "" { - workspaceID = strings.TrimSpace(r.Header.Get("X-Parsar-Workspace-ID")) - } - if !isUUID(workspaceID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) + workspaceID, ok := requireWorkspaceCapabilityRead(w, r, runtimeStore) + if !ok { return } capabilityID := strings.TrimSpace(chi.URLParam(r, "capabilityID")) @@ -435,14 +419,6 @@ func getMarketplaceCapabilityDetail(runtimeStore RuntimeStore) http.HandlerFunc writeJSON(w, http.StatusBadRequest, map[string]string{"error": "capabilityID must be a valid uuid"}) return } - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed capability APIs are disabled"}) - return - } - if err := requireWorkspaceMember(r, runtimeStore, workspaceID); err != nil { - writeRBACError(w, err) - return - } capabilities, err := runtimeStore.ListMarketplaceCapabilities(r.Context(), workspaceID) if err != nil { @@ -456,7 +432,7 @@ func getMarketplaceCapabilityDetail(runtimeStore RuntimeStore) http.HandlerFunc break } } - if capability == nil || capability.Visibility != "public" || capability.Status != "active" || capability.DeprecatedAt != nil || !isPreviewableMarketplaceType(capability.Type) { + if capability == nil || capability.Status != "active" || capability.DeprecatedAt != nil || !isPreviewableMarketplaceType(capability.Type) { writeCapabilityError(w, fmt.Errorf("%w: %s", store.ErrUnknownCapability, capabilityID), "failed to load marketplace capability") return } diff --git a/server/internal/dev/routes.go b/server/internal/dev/routes.go index 1ca8023..ee3963a 100644 --- a/server/internal/dev/routes.go +++ b/server/internal/dev/routes.go @@ -638,9 +638,9 @@ func RegisterRoutesWithStore(r chi.Router, runtimeStore RuntimeStore, opts ...Ro // mode this server is wired for and (for sandbox mode) // whether the provider is reachable. r.Get("/workspaces/{workspaceID}/runtime/status", gateWorkspaceMember(runtimeStore, runtimeStatus(cfg.runtimeStatus))) - r.Get("/capabilities/marketplace", listMarketplaceCapabilities(runtimeStore)) - r.Get("/capabilities/marketplace/{capabilityID}", getMarketplaceCapabilityDetail(runtimeStore)) r.Get("/workspaces/{workspaceID}/capabilities", listWorkspaceCapabilities(runtimeStore)) + r.Get("/workspaces/{workspaceID}/marketplace/capabilities", listMarketplaceCapabilities(runtimeStore)) + r.Get("/workspaces/{workspaceID}/marketplace/capabilities/{capabilityID}", getMarketplaceCapabilityDetail(runtimeStore)) r.Post("/workspaces/{workspaceID}/capabilities", createWorkspaceCapability(runtimeStore)) // Capability import — preview is a pure parse; commit runs // the all-or-nothing materialization. diff --git a/server/internal/dev/routes_agents.go b/server/internal/dev/routes_agents.go index 7cfcb6f..993d8cb 100644 --- a/server/internal/dev/routes_agents.go +++ b/server/internal/dev/routes_agents.go @@ -579,10 +579,9 @@ func syncAgentCapabilities( } // 2. Resolve desired names. A name can come from this workspace's own - // capabilities, OR from the marketplace (a public capability published - // by another workspace and surfaced in the agent picker's marketplace - // section). Local capabilities win on name collision: a user shadowing - // a marketplace name with a private one should keep using their own. + // capabilities, OR from the workspace-scoped marketplace pool surfaced + // in the agent picker. Local capabilities win on name collision: a user + // shadowing a marketplace name with a private one should keep using their own. allCaps, err := rs.ListCapabilities(ctx, workspaceID, store.ListCapabilityFilter{}) if err != nil { return fmt.Errorf("syncAgentCapabilities: list capabilities: %w", err) diff --git a/server/internal/dev/routes_capability_test.go b/server/internal/dev/routes_capability_test.go index de26227..9f1699c 100644 --- a/server/internal/dev/routes_capability_test.go +++ b/server/internal/dev/routes_capability_test.go @@ -398,6 +398,36 @@ func TestCapabilityMarketplacePublishLifecycleSecretCheckAndDeleteRollback(t *te } } +func TestWorkspaceMarketplaceListIsWorkspaceScopedAndIgnoresVisibility(t *testing.T) { + foreignWorkspaceID := "00000000-0000-0000-0000-000000000099" + r, db := capabilityTestRouter(t, map[string]string{store.DefaultDevFixtureIDs().UserID: "admin", testUserAID: "member"}, nil) + workspaceCapID, _, _ := insertCapabilityVersions(t, db, store.DefaultDevFixtureIDs().WorkspaceID, "Workspace Private MCP") + insertForeignWorkspace(t, db, foreignWorkspaceID) + foreignCapID, _, _ := insertCapabilityVersions(t, db, foreignWorkspaceID, "Foreign Public MCP") + publishForeignCapability(t, db, foreignCapID) + + list := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/marketplace/capabilities", ``, testUserAID) + if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), "Workspace Private MCP") || strings.Contains(list.Body.String(), "Foreign Public MCP") || strings.Contains(list.Body.String(), foreignWorkspaceID) { + t.Fatalf("workspace marketplace list expected only local capabilities regardless of visibility, got %d: %s", list.Code, list.Body.String()) + } + + detail := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/marketplace/capabilities/"+workspaceCapID, ``, testUserAID) + if detail.Code != http.StatusOK { + t.Fatalf("workspace marketplace detail expected private local capability, got %d: %s", detail.Code, detail.Body.String()) + } + + foreignDetail := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/marketplace/capabilities/"+foreignCapID, ``, testUserAID) + if foreignDetail.Code != http.StatusNotFound { + t.Fatalf("foreign marketplace detail expected 404, got %d: %s", foreignDetail.Code, foreignDetail.Body.String()) + } + + nonMemberID := "00000000-0000-0000-0000-000000000777" + blocked := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/marketplace/capabilities", ``, nonMemberID) + if blocked.Code != http.StatusForbidden { + t.Fatalf("non-member marketplace list expected 403, got %d: %s", blocked.Code, blocked.Body.String()) + } +} + func TestCapabilityMarketplaceCrossWorkspaceEnableUpgradeUninstallAndReverseQueries(t *testing.T) { foreignWorkspaceID := "00000000-0000-0000-0000-000000000099" r, db := capabilityTestRouter(t, map[string]string{store.DefaultDevFixtureIDs().UserID: "admin", testUserAID: "admin"}, map[string]string{testUserAID: "member"}) @@ -424,16 +454,16 @@ func TestCapabilityMarketplaceCrossWorkspaceEnableUpgradeUninstallAndReverseQuer if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), `"enabled_agent_count":2`) || !strings.Contains(list.Body.String(), `"from_marketplace":true`) { t.Fatalf("reverse marketplace list expected count=2, got %d: %s", list.Code, list.Body.String()) } - market := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/capabilities/marketplace?workspace_id="+store.DefaultDevFixtureIDs().WorkspaceID, ``, testUserAID) - if market.Code != http.StatusOK || !strings.Contains(market.Body.String(), `"installed":true`) || strings.Contains(market.Body.String(), foreignWorkspaceID) || strings.Contains(market.Body.String(), "Foreign Public Plugin") { - t.Fatalf("marketplace list expected installed without source workspace id leak, got %d: %s", market.Code, market.Body.String()) + market := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/marketplace/capabilities", ``, testUserAID) + if market.Code != http.StatusOK || strings.Contains(market.Body.String(), "Foreign Public MCP") || strings.Contains(market.Body.String(), foreignWorkspaceID) || strings.Contains(market.Body.String(), "Foreign Public Plugin") { + t.Fatalf("workspace marketplace list expected no foreign capabilities, got %d: %s", market.Code, market.Body.String()) } if _, err := db.Exec(context.Background(), `update capability_version set creator_id = null where id = $1`, v2); err != nil { t.Fatal(err) } - detail := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/capabilities/marketplace/"+capID+"?workspace_id="+store.DefaultDevFixtureIDs().WorkspaceID, ``, testUserAID) - if detail.Code != http.StatusOK || !strings.Contains(detail.Body.String(), `"version":"v2"`) { - t.Fatalf("marketplace detail with nullable creator expected 200/v2, got %d: %s", detail.Code, detail.Body.String()) + detail := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/marketplace/capabilities/"+capID, ``, testUserAID) + if detail.Code != http.StatusNotFound { + t.Fatalf("workspace marketplace detail for foreign capability expected 404, got %d: %s", detail.Code, detail.Body.String()) } upgraded := serveCapabilityRoute(t, r, http.MethodPost, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/agents/"+agentA+"/capabilities/"+capID+"/upgrade", `{"new_version_id":"`+v2+`"}`, testUserAID) if upgraded.Code != http.StatusOK || !strings.Contains(upgraded.Body.String(), v2) { @@ -496,7 +526,7 @@ func TestMarketplaceCapabilityDetailShowsSkillAndRedactsMCPSecret(t *testing.T) } r := chi.NewRouter() RegisterRoutesWithStore(r, runtimeStore) - res := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/capabilities/marketplace/"+capabilityID+"?workspace_id="+workspaceID, "", store.DefaultDevFixtureIDs().UserID) + res := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/workspaces/"+workspaceID+"/marketplace/capabilities/"+capabilityID, "", store.DefaultDevFixtureIDs().UserID) if res.Code != http.StatusOK || !strings.Contains(res.Body.String(), "Render a clear SVG") || !strings.Contains(res.Body.String(), "references/svg.md") { t.Fatalf("skill detail expected content, got %d: %s", res.Code, res.Body.String()) } @@ -526,7 +556,7 @@ func TestMarketplaceCapabilityDetailShowsSkillAndRedactsMCPSecret(t *testing.T) } r := chi.NewRouter() RegisterRoutesWithStore(r, runtimeStore) - res := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/capabilities/marketplace/"+capabilityID+"?workspace_id="+workspaceID, "", store.DefaultDevFixtureIDs().UserID) + res := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/workspaces/"+workspaceID+"/marketplace/capabilities/"+capabilityID, "", store.DefaultDevFixtureIDs().UserID) body := res.Body.String() if res.Code != http.StatusOK || !strings.Contains(body, `"redacted":true`) || !strings.Contains(body, `"credential_kind_code":"github_pat"`) || !strings.Contains(body, "https://api.example.com") { t.Fatalf("MCP detail expected sanitized config, got %d: %s", res.Code, body) @@ -536,13 +566,16 @@ func TestMarketplaceCapabilityDetailShowsSkillAndRedactsMCPSecret(t *testing.T) } }) - t.Run("private capability is hidden", func(t *testing.T) { - runtimeStore := marketplaceDetailStore{capability: store.MarketplaceCapabilityRead{CapabilityID: capabilityID, Type: "skill", Visibility: "workspace", Status: "active", LatestVersionID: versionID}} + t.Run("workspace visibility capability is visible", func(t *testing.T) { + runtimeStore := marketplaceDetailStore{ + capability: store.MarketplaceCapabilityRead{CapabilityID: capabilityID, Type: "skill", Visibility: "workspace", Status: "active", LatestVersionID: versionID}, + version: store.CapabilityVersionRead{ID: versionID, CapabilityID: capabilityID, Version: "v1"}, + } r := chi.NewRouter() RegisterRoutesWithStore(r, runtimeStore) - res := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/capabilities/marketplace/"+capabilityID+"?workspace_id="+workspaceID, "", store.DefaultDevFixtureIDs().UserID) - if res.Code != http.StatusNotFound { - t.Fatalf("private capability expected 404, got %d: %s", res.Code, res.Body.String()) + res := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/workspaces/"+workspaceID+"/marketplace/capabilities/"+capabilityID, "", store.DefaultDevFixtureIDs().UserID) + if res.Code != http.StatusOK { + t.Fatalf("workspace visibility capability expected 200, got %d: %s", res.Code, res.Body.String()) } }) @@ -553,7 +586,7 @@ func TestMarketplaceCapabilityDetailShowsSkillAndRedactsMCPSecret(t *testing.T) } r := chi.NewRouter() RegisterRoutesWithStore(r, runtimeStore) - res := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/capabilities/marketplace/"+capabilityID+"?workspace_id="+workspaceID, "", store.DefaultDevFixtureIDs().UserID) + res := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/workspaces/"+workspaceID+"/marketplace/capabilities/"+capabilityID, "", store.DefaultDevFixtureIDs().UserID) if res.Code != http.StatusOK || !strings.Contains(res.Body.String(), `"git_repo_url":"https://example.com/legacy"`) { t.Fatalf("legacy detail expected source metadata, got %d: %s", res.Code, res.Body.String()) } @@ -928,18 +961,14 @@ func insertAgentCapability(t *testing.T, db *pgxpool.Pool, agentID, capabilityID } } -// TestSyncAgentCapabilitiesBindsMarketplaceByName covers the edit-dialog -// path where the user checks a marketplace capability that isn't installed -// in their workspace yet. The agent payload only carries names — so -// syncAgentCapabilities has to resolve the name against the marketplace -// pool, not just ListCapabilities of the local workspace. Without this -// the checkbox would appear to succeed in the UI but silently no-op -// on save. -func TestSyncAgentCapabilitiesBindsMarketplaceByName(t *testing.T) { +// TestSyncAgentCapabilitiesDoesNotBindForeignMarketplaceByName covers the +// workspace-scoped marketplace model: a public capability from another +// workspace is no longer part of this workspace's marketplace pool. +func TestSyncAgentCapabilitiesDoesNotBindForeignMarketplaceByName(t *testing.T) { foreignWorkspaceID := "00000000-0000-0000-0000-000000000099" _, db := capabilityTestRouter(t, map[string]string{store.DefaultDevFixtureIDs().UserID: "admin"}, nil) insertForeignWorkspace(t, db, foreignWorkspaceID) - capID, v1, _ := insertCapabilityVersions(t, db, foreignWorkspaceID, "Foreign Marketplace MCP") + capID, _, _ := insertCapabilityVersions(t, db, foreignWorkspaceID, "Foreign Marketplace MCP") publishForeignCapability(t, db, capID) wid := store.DefaultDevFixtureIDs().WorkspaceID @@ -955,19 +984,11 @@ func TestSyncAgentCapabilitiesBindsMarketplaceByName(t *testing.T) { if err != nil { t.Fatalf("list bindings: %v", err) } - var matched store.AgentCapabilityRead for _, b := range bindings { if b.CapabilityID == capID { - matched = b - break + t.Fatalf("foreign marketplace capability binding should not be created (capID=%s); bindings=%+v", capID, bindings) } } - if matched.CapabilityID == "" { - t.Fatalf("marketplace capability binding was dropped silently (capID=%s); bindings=%+v", capID, bindings) - } - if matched.CapabilityVersionID != v1 { - t.Fatalf("expected binding to source version %s, got %s", v1, matched.CapabilityVersionID) - } } // TestCapabilityDeleteFreesUniqueNameSlot reproduces the bug: a "deprecated" From 54ed193bca452431f433b3528ae96d6b872d8c87 Mon Sep 17 00:00:00 2001 From: kapelame Date: Thu, 6 Aug 2026 16:33:15 +0800 Subject: [PATCH 4/4] Revert "Scope capability marketplace to workspace" This reverts commit 6d79d2ae2a9c72231b6d4bf2ff9f0dd9a4e58b86. --- apps/web/src/lib/api-marketplace.ts | 6 +- docs/openapi/openapi.yaml | 169 +++++++++--------- server/internal/db/queries/store.sql | 2 +- server/internal/db/sqlc/store.sql.go | 2 +- server/internal/dev/capability_routes.go | 54 ++++-- server/internal/dev/routes.go | 4 +- server/internal/dev/routes_agents.go | 7 +- server/internal/dev/routes_capability_test.go | 85 ++++----- 8 files changed, 167 insertions(+), 162 deletions(-) diff --git a/apps/web/src/lib/api-marketplace.ts b/apps/web/src/lib/api-marketplace.ts index db665b9..e30a991 100644 --- a/apps/web/src/lib/api-marketplace.ts +++ b/apps/web/src/lib/api-marketplace.ts @@ -159,7 +159,8 @@ export const KEY_MCP_DIRECTORY_DETAIL = (workspaceID: string, catalogID: string) async function listMarketplace(workspaceID: string | null): Promise { if (!workspaceID) return [] const data = await apiRequest( - `/api/v1/workspaces/${encodeURIComponent(workspaceID)}/marketplace/capabilities`, + `/api/v1/capabilities/marketplace`, + { query: { workspace_id: workspaceID } }, ) if (Array.isArray(data)) return data return (data.capabilities ?? data.marketplace ?? data.items ?? []).map(normalizeMarketplaceCapability) @@ -171,7 +172,8 @@ async function getMarketplaceDetail( ): Promise { if (!workspaceID || !capabilityID) throw new Error("workspace and capability are required") const data = await apiRequest( - `/api/v1/workspaces/${encodeURIComponent(workspaceID)}/marketplace/capabilities/${encodeURIComponent(capabilityID)}`, + `/api/v1/capabilities/marketplace/${encodeURIComponent(capabilityID)}`, + { query: { workspace_id: workspaceID } }, ) return data.capability } diff --git a/docs/openapi/openapi.yaml b/docs/openapi/openapi.yaml index 5e4df97..181d523 100644 --- a/docs/openapi/openapi.yaml +++ b/docs/openapi/openapi.yaml @@ -3102,6 +3102,90 @@ paths: summary: Bootstrap status tags: - bootstrap + /api/v1/capabilities/marketplace: + get: + description: Lists public MCP and Skill capabilities offered on the marketplace, + filtered to items visible to the caller's workspace. Workspace is taken from + ?workspace_id or the X-Parsar-Workspace-ID header. + operationId: listDevMarketplaceCapabilities + parameters: + - description: Workspace UUID (falls back to X-Parsar-Workspace-ID header) + in: query + name: workspace_id + type: string + produces: + - application/json + responses: + "200": + description: Marketplace capabilities visible to the workspace + schema: + additionalProperties: true + type: object + "400": + description: workspace_id must be a valid uuid + schema: + additionalProperties: + type: string + type: object + "403": + description: Caller is not an active workspace member + schema: + additionalProperties: + type: string + type: object + "503": + description: Database-backed capability APIs are disabled + schema: + additionalProperties: + type: string + type: object + summary: List marketplace capabilities + tags: + - capabilities + /api/v1/capabilities/marketplace/{capabilityID}: + get: + description: Returns the latest public MCP or Skill definition. Inline secret + IDs are redacted. + operationId: getDevMarketplaceCapabilityDetail + parameters: + - description: Capability UUID + in: path + name: capabilityID + required: true + type: string + - description: Workspace UUID (falls back to X-Parsar-Workspace-ID header) + in: query + name: workspace_id + type: string + produces: + - application/json + responses: + "200": + description: Marketplace capability detail + schema: + additionalProperties: true + type: object + "400": + description: workspace_id or capabilityID is invalid + schema: + additionalProperties: + type: string + type: object + "403": + description: Caller is not an active workspace member + schema: + additionalProperties: + type: string + type: object + "404": + description: Capability is not available in the Marketplace + schema: + additionalProperties: + type: string + type: object + summary: Get marketplace capability detail + tags: + - capabilities /api/v1/connections/github/callback: get: description: Verifies CSRF state, exchanges the code for a GitHub access token, @@ -6925,91 +7009,6 @@ paths: summary: Resolve a pending approval or user question tags: - interactions - /api/v1/workspaces/{workspaceID}/marketplace/capabilities: - get: - description: Lists active MCP and Skill capabilities in the requested workspace - marketplace. - operationId: listDevMarketplaceCapabilities - parameters: - - description: Workspace UUID - in: path - name: workspaceID - required: true - type: string - produces: - - application/json - responses: - "200": - description: Marketplace capabilities visible to the workspace - schema: - additionalProperties: true - type: object - "400": - description: workspace_id must be a valid uuid - schema: - additionalProperties: - type: string - type: object - "403": - description: Caller is not an active workspace member - schema: - additionalProperties: - type: string - type: object - "503": - description: Database-backed capability APIs are disabled - schema: - additionalProperties: - type: string - type: object - summary: List marketplace capabilities - tags: - - capabilities - /api/v1/workspaces/{workspaceID}/marketplace/capabilities/{capabilityID}: - get: - description: Returns the latest workspace-scoped MCP or Skill definition. Inline - secret IDs are redacted. - operationId: getDevMarketplaceCapabilityDetail - parameters: - - description: Workspace UUID - in: path - name: workspaceID - required: true - type: string - - description: Capability UUID - in: path - name: capabilityID - required: true - type: string - produces: - - application/json - responses: - "200": - description: Marketplace capability detail - schema: - additionalProperties: true - type: object - "400": - description: workspace_id or capabilityID is invalid - schema: - additionalProperties: - type: string - type: object - "403": - description: Caller is not an active workspace member - schema: - additionalProperties: - type: string - type: object - "404": - description: Capability is not available in the Marketplace - schema: - additionalProperties: - type: string - type: object - summary: Get marketplace capability detail - tags: - - capabilities /api/v1/workspaces/{workspaceID}/mcp-directory: get: parameters: diff --git a/server/internal/db/queries/store.sql b/server/internal/db/queries/store.sql index af6d6b2..3d891dd 100644 --- a/server/internal/db/queries/store.sql +++ b/server/internal/db/queries/store.sql @@ -3689,7 +3689,7 @@ join lateral ( limit 1 ) cv on true left join installed on installed.capability_id = c.id -where c.workspace_id = @target_workspace_id::uuid +where c.visibility = 'public' and c.status = 'active' and c.deprecated_at is null and c.deleted_at is null diff --git a/server/internal/db/sqlc/store.sql.go b/server/internal/db/sqlc/store.sql.go index f912c2e..6f73a0a 100644 --- a/server/internal/db/sqlc/store.sql.go +++ b/server/internal/db/sqlc/store.sql.go @@ -8356,7 +8356,7 @@ join lateral ( limit 1 ) cv on true left join installed on installed.capability_id = c.id -where c.workspace_id = $1::uuid +where c.visibility = 'public' and c.status = 'active' and c.deprecated_at is null and c.deleted_at is null diff --git a/server/internal/dev/capability_routes.go b/server/internal/dev/capability_routes.go index 4e95b60..6233308 100644 --- a/server/internal/dev/capability_routes.go +++ b/server/internal/dev/capability_routes.go @@ -349,24 +349,36 @@ func listWorkspaceCapabilities(runtimeStore RuntimeStore) http.HandlerFunc { } } -// listMarketplaceCapabilities lists active capabilities in the caller's -// workspace marketplace. +// listMarketplaceCapabilities lists all public capabilities offered on the +// marketplace, filtered to items the caller's workspace can install. // // @Summary List marketplace capabilities -// @Description Lists active MCP and Skill capabilities in the requested workspace marketplace. +// @Description Lists public MCP and Skill capabilities offered on the marketplace, filtered to items visible to the caller's workspace. Workspace is taken from ?workspace_id or the X-Parsar-Workspace-ID header. // @Tags capabilities // @ID listDevMarketplaceCapabilities // @Produce json -// @Param workspaceID path string true "Workspace UUID" +// @Param workspace_id query string false "Workspace UUID (falls back to X-Parsar-Workspace-ID header)" // @Success 200 {object} map[string]interface{} "Marketplace capabilities visible to the workspace" // @Failure 400 {object} map[string]string "workspace_id must be a valid uuid" // @Failure 403 {object} map[string]string "Caller is not an active workspace member" // @Failure 503 {object} map[string]string "Database-backed capability APIs are disabled" -// @Router /api/v1/workspaces/{workspaceID}/marketplace/capabilities [get] +// @Router /api/v1/capabilities/marketplace [get] func listMarketplaceCapabilities(runtimeStore RuntimeStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - workspaceID, ok := requireWorkspaceCapabilityRead(w, r, runtimeStore) - if !ok { + workspaceID := strings.TrimSpace(r.URL.Query().Get("workspace_id")) + if workspaceID == "" { + workspaceID = strings.TrimSpace(r.Header.Get("X-Parsar-Workspace-ID")) + } + if !isUUID(workspaceID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) + return + } + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed capability APIs are disabled"}) + return + } + if err := requireWorkspaceMember(r, runtimeStore, workspaceID); err != nil { + writeRBACError(w, err) return } capabilities, err := runtimeStore.ListMarketplaceCapabilities(r.Context(), workspaceID) @@ -393,25 +405,29 @@ func isListedCapabilityType(capabilityType string) bool { } } -// getMarketplaceCapabilityDetail returns the latest workspace marketplace -// MCP/Skill body on demand so list responses stay lightweight. +// getMarketplaceCapabilityDetail returns the latest public MCP/Skill body on +// demand so list responses stay lightweight. // // @Summary Get marketplace capability detail -// @Description Returns the latest workspace-scoped MCP or Skill definition. Inline secret IDs are redacted. +// @Description Returns the latest public MCP or Skill definition. Inline secret IDs are redacted. // @Tags capabilities // @ID getDevMarketplaceCapabilityDetail // @Produce json -// @Param workspaceID path string true "Workspace UUID" // @Param capabilityID path string true "Capability UUID" +// @Param workspace_id query string false "Workspace UUID (falls back to X-Parsar-Workspace-ID header)" // @Success 200 {object} map[string]interface{} "Marketplace capability detail" // @Failure 400 {object} map[string]string "workspace_id or capabilityID is invalid" // @Failure 403 {object} map[string]string "Caller is not an active workspace member" // @Failure 404 {object} map[string]string "Capability is not available in the Marketplace" -// @Router /api/v1/workspaces/{workspaceID}/marketplace/capabilities/{capabilityID} [get] +// @Router /api/v1/capabilities/marketplace/{capabilityID} [get] func getMarketplaceCapabilityDetail(runtimeStore RuntimeStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - workspaceID, ok := requireWorkspaceCapabilityRead(w, r, runtimeStore) - if !ok { + workspaceID := strings.TrimSpace(r.URL.Query().Get("workspace_id")) + if workspaceID == "" { + workspaceID = strings.TrimSpace(r.Header.Get("X-Parsar-Workspace-ID")) + } + if !isUUID(workspaceID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) return } capabilityID := strings.TrimSpace(chi.URLParam(r, "capabilityID")) @@ -419,6 +435,14 @@ func getMarketplaceCapabilityDetail(runtimeStore RuntimeStore) http.HandlerFunc writeJSON(w, http.StatusBadRequest, map[string]string{"error": "capabilityID must be a valid uuid"}) return } + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed capability APIs are disabled"}) + return + } + if err := requireWorkspaceMember(r, runtimeStore, workspaceID); err != nil { + writeRBACError(w, err) + return + } capabilities, err := runtimeStore.ListMarketplaceCapabilities(r.Context(), workspaceID) if err != nil { @@ -432,7 +456,7 @@ func getMarketplaceCapabilityDetail(runtimeStore RuntimeStore) http.HandlerFunc break } } - if capability == nil || capability.Status != "active" || capability.DeprecatedAt != nil || !isPreviewableMarketplaceType(capability.Type) { + if capability == nil || capability.Visibility != "public" || capability.Status != "active" || capability.DeprecatedAt != nil || !isPreviewableMarketplaceType(capability.Type) { writeCapabilityError(w, fmt.Errorf("%w: %s", store.ErrUnknownCapability, capabilityID), "failed to load marketplace capability") return } diff --git a/server/internal/dev/routes.go b/server/internal/dev/routes.go index ee3963a..1ca8023 100644 --- a/server/internal/dev/routes.go +++ b/server/internal/dev/routes.go @@ -638,9 +638,9 @@ func RegisterRoutesWithStore(r chi.Router, runtimeStore RuntimeStore, opts ...Ro // mode this server is wired for and (for sandbox mode) // whether the provider is reachable. r.Get("/workspaces/{workspaceID}/runtime/status", gateWorkspaceMember(runtimeStore, runtimeStatus(cfg.runtimeStatus))) + r.Get("/capabilities/marketplace", listMarketplaceCapabilities(runtimeStore)) + r.Get("/capabilities/marketplace/{capabilityID}", getMarketplaceCapabilityDetail(runtimeStore)) r.Get("/workspaces/{workspaceID}/capabilities", listWorkspaceCapabilities(runtimeStore)) - r.Get("/workspaces/{workspaceID}/marketplace/capabilities", listMarketplaceCapabilities(runtimeStore)) - r.Get("/workspaces/{workspaceID}/marketplace/capabilities/{capabilityID}", getMarketplaceCapabilityDetail(runtimeStore)) r.Post("/workspaces/{workspaceID}/capabilities", createWorkspaceCapability(runtimeStore)) // Capability import — preview is a pure parse; commit runs // the all-or-nothing materialization. diff --git a/server/internal/dev/routes_agents.go b/server/internal/dev/routes_agents.go index 993d8cb..7cfcb6f 100644 --- a/server/internal/dev/routes_agents.go +++ b/server/internal/dev/routes_agents.go @@ -579,9 +579,10 @@ func syncAgentCapabilities( } // 2. Resolve desired names. A name can come from this workspace's own - // capabilities, OR from the workspace-scoped marketplace pool surfaced - // in the agent picker. Local capabilities win on name collision: a user - // shadowing a marketplace name with a private one should keep using their own. + // capabilities, OR from the marketplace (a public capability published + // by another workspace and surfaced in the agent picker's marketplace + // section). Local capabilities win on name collision: a user shadowing + // a marketplace name with a private one should keep using their own. allCaps, err := rs.ListCapabilities(ctx, workspaceID, store.ListCapabilityFilter{}) if err != nil { return fmt.Errorf("syncAgentCapabilities: list capabilities: %w", err) diff --git a/server/internal/dev/routes_capability_test.go b/server/internal/dev/routes_capability_test.go index 9f1699c..de26227 100644 --- a/server/internal/dev/routes_capability_test.go +++ b/server/internal/dev/routes_capability_test.go @@ -398,36 +398,6 @@ func TestCapabilityMarketplacePublishLifecycleSecretCheckAndDeleteRollback(t *te } } -func TestWorkspaceMarketplaceListIsWorkspaceScopedAndIgnoresVisibility(t *testing.T) { - foreignWorkspaceID := "00000000-0000-0000-0000-000000000099" - r, db := capabilityTestRouter(t, map[string]string{store.DefaultDevFixtureIDs().UserID: "admin", testUserAID: "member"}, nil) - workspaceCapID, _, _ := insertCapabilityVersions(t, db, store.DefaultDevFixtureIDs().WorkspaceID, "Workspace Private MCP") - insertForeignWorkspace(t, db, foreignWorkspaceID) - foreignCapID, _, _ := insertCapabilityVersions(t, db, foreignWorkspaceID, "Foreign Public MCP") - publishForeignCapability(t, db, foreignCapID) - - list := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/marketplace/capabilities", ``, testUserAID) - if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), "Workspace Private MCP") || strings.Contains(list.Body.String(), "Foreign Public MCP") || strings.Contains(list.Body.String(), foreignWorkspaceID) { - t.Fatalf("workspace marketplace list expected only local capabilities regardless of visibility, got %d: %s", list.Code, list.Body.String()) - } - - detail := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/marketplace/capabilities/"+workspaceCapID, ``, testUserAID) - if detail.Code != http.StatusOK { - t.Fatalf("workspace marketplace detail expected private local capability, got %d: %s", detail.Code, detail.Body.String()) - } - - foreignDetail := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/marketplace/capabilities/"+foreignCapID, ``, testUserAID) - if foreignDetail.Code != http.StatusNotFound { - t.Fatalf("foreign marketplace detail expected 404, got %d: %s", foreignDetail.Code, foreignDetail.Body.String()) - } - - nonMemberID := "00000000-0000-0000-0000-000000000777" - blocked := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/marketplace/capabilities", ``, nonMemberID) - if blocked.Code != http.StatusForbidden { - t.Fatalf("non-member marketplace list expected 403, got %d: %s", blocked.Code, blocked.Body.String()) - } -} - func TestCapabilityMarketplaceCrossWorkspaceEnableUpgradeUninstallAndReverseQueries(t *testing.T) { foreignWorkspaceID := "00000000-0000-0000-0000-000000000099" r, db := capabilityTestRouter(t, map[string]string{store.DefaultDevFixtureIDs().UserID: "admin", testUserAID: "admin"}, map[string]string{testUserAID: "member"}) @@ -454,16 +424,16 @@ func TestCapabilityMarketplaceCrossWorkspaceEnableUpgradeUninstallAndReverseQuer if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), `"enabled_agent_count":2`) || !strings.Contains(list.Body.String(), `"from_marketplace":true`) { t.Fatalf("reverse marketplace list expected count=2, got %d: %s", list.Code, list.Body.String()) } - market := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/marketplace/capabilities", ``, testUserAID) - if market.Code != http.StatusOK || strings.Contains(market.Body.String(), "Foreign Public MCP") || strings.Contains(market.Body.String(), foreignWorkspaceID) || strings.Contains(market.Body.String(), "Foreign Public Plugin") { - t.Fatalf("workspace marketplace list expected no foreign capabilities, got %d: %s", market.Code, market.Body.String()) + market := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/capabilities/marketplace?workspace_id="+store.DefaultDevFixtureIDs().WorkspaceID, ``, testUserAID) + if market.Code != http.StatusOK || !strings.Contains(market.Body.String(), `"installed":true`) || strings.Contains(market.Body.String(), foreignWorkspaceID) || strings.Contains(market.Body.String(), "Foreign Public Plugin") { + t.Fatalf("marketplace list expected installed without source workspace id leak, got %d: %s", market.Code, market.Body.String()) } if _, err := db.Exec(context.Background(), `update capability_version set creator_id = null where id = $1`, v2); err != nil { t.Fatal(err) } - detail := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/marketplace/capabilities/"+capID, ``, testUserAID) - if detail.Code != http.StatusNotFound { - t.Fatalf("workspace marketplace detail for foreign capability expected 404, got %d: %s", detail.Code, detail.Body.String()) + detail := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/capabilities/marketplace/"+capID+"?workspace_id="+store.DefaultDevFixtureIDs().WorkspaceID, ``, testUserAID) + if detail.Code != http.StatusOK || !strings.Contains(detail.Body.String(), `"version":"v2"`) { + t.Fatalf("marketplace detail with nullable creator expected 200/v2, got %d: %s", detail.Code, detail.Body.String()) } upgraded := serveCapabilityRoute(t, r, http.MethodPost, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/agents/"+agentA+"/capabilities/"+capID+"/upgrade", `{"new_version_id":"`+v2+`"}`, testUserAID) if upgraded.Code != http.StatusOK || !strings.Contains(upgraded.Body.String(), v2) { @@ -526,7 +496,7 @@ func TestMarketplaceCapabilityDetailShowsSkillAndRedactsMCPSecret(t *testing.T) } r := chi.NewRouter() RegisterRoutesWithStore(r, runtimeStore) - res := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/workspaces/"+workspaceID+"/marketplace/capabilities/"+capabilityID, "", store.DefaultDevFixtureIDs().UserID) + res := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/capabilities/marketplace/"+capabilityID+"?workspace_id="+workspaceID, "", store.DefaultDevFixtureIDs().UserID) if res.Code != http.StatusOK || !strings.Contains(res.Body.String(), "Render a clear SVG") || !strings.Contains(res.Body.String(), "references/svg.md") { t.Fatalf("skill detail expected content, got %d: %s", res.Code, res.Body.String()) } @@ -556,7 +526,7 @@ func TestMarketplaceCapabilityDetailShowsSkillAndRedactsMCPSecret(t *testing.T) } r := chi.NewRouter() RegisterRoutesWithStore(r, runtimeStore) - res := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/workspaces/"+workspaceID+"/marketplace/capabilities/"+capabilityID, "", store.DefaultDevFixtureIDs().UserID) + res := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/capabilities/marketplace/"+capabilityID+"?workspace_id="+workspaceID, "", store.DefaultDevFixtureIDs().UserID) body := res.Body.String() if res.Code != http.StatusOK || !strings.Contains(body, `"redacted":true`) || !strings.Contains(body, `"credential_kind_code":"github_pat"`) || !strings.Contains(body, "https://api.example.com") { t.Fatalf("MCP detail expected sanitized config, got %d: %s", res.Code, body) @@ -566,16 +536,13 @@ func TestMarketplaceCapabilityDetailShowsSkillAndRedactsMCPSecret(t *testing.T) } }) - t.Run("workspace visibility capability is visible", func(t *testing.T) { - runtimeStore := marketplaceDetailStore{ - capability: store.MarketplaceCapabilityRead{CapabilityID: capabilityID, Type: "skill", Visibility: "workspace", Status: "active", LatestVersionID: versionID}, - version: store.CapabilityVersionRead{ID: versionID, CapabilityID: capabilityID, Version: "v1"}, - } + t.Run("private capability is hidden", func(t *testing.T) { + runtimeStore := marketplaceDetailStore{capability: store.MarketplaceCapabilityRead{CapabilityID: capabilityID, Type: "skill", Visibility: "workspace", Status: "active", LatestVersionID: versionID}} r := chi.NewRouter() RegisterRoutesWithStore(r, runtimeStore) - res := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/workspaces/"+workspaceID+"/marketplace/capabilities/"+capabilityID, "", store.DefaultDevFixtureIDs().UserID) - if res.Code != http.StatusOK { - t.Fatalf("workspace visibility capability expected 200, got %d: %s", res.Code, res.Body.String()) + res := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/capabilities/marketplace/"+capabilityID+"?workspace_id="+workspaceID, "", store.DefaultDevFixtureIDs().UserID) + if res.Code != http.StatusNotFound { + t.Fatalf("private capability expected 404, got %d: %s", res.Code, res.Body.String()) } }) @@ -586,7 +553,7 @@ func TestMarketplaceCapabilityDetailShowsSkillAndRedactsMCPSecret(t *testing.T) } r := chi.NewRouter() RegisterRoutesWithStore(r, runtimeStore) - res := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/workspaces/"+workspaceID+"/marketplace/capabilities/"+capabilityID, "", store.DefaultDevFixtureIDs().UserID) + res := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/capabilities/marketplace/"+capabilityID+"?workspace_id="+workspaceID, "", store.DefaultDevFixtureIDs().UserID) if res.Code != http.StatusOK || !strings.Contains(res.Body.String(), `"git_repo_url":"https://example.com/legacy"`) { t.Fatalf("legacy detail expected source metadata, got %d: %s", res.Code, res.Body.String()) } @@ -961,14 +928,18 @@ func insertAgentCapability(t *testing.T, db *pgxpool.Pool, agentID, capabilityID } } -// TestSyncAgentCapabilitiesDoesNotBindForeignMarketplaceByName covers the -// workspace-scoped marketplace model: a public capability from another -// workspace is no longer part of this workspace's marketplace pool. -func TestSyncAgentCapabilitiesDoesNotBindForeignMarketplaceByName(t *testing.T) { +// TestSyncAgentCapabilitiesBindsMarketplaceByName covers the edit-dialog +// path where the user checks a marketplace capability that isn't installed +// in their workspace yet. The agent payload only carries names — so +// syncAgentCapabilities has to resolve the name against the marketplace +// pool, not just ListCapabilities of the local workspace. Without this +// the checkbox would appear to succeed in the UI but silently no-op +// on save. +func TestSyncAgentCapabilitiesBindsMarketplaceByName(t *testing.T) { foreignWorkspaceID := "00000000-0000-0000-0000-000000000099" _, db := capabilityTestRouter(t, map[string]string{store.DefaultDevFixtureIDs().UserID: "admin"}, nil) insertForeignWorkspace(t, db, foreignWorkspaceID) - capID, _, _ := insertCapabilityVersions(t, db, foreignWorkspaceID, "Foreign Marketplace MCP") + capID, v1, _ := insertCapabilityVersions(t, db, foreignWorkspaceID, "Foreign Marketplace MCP") publishForeignCapability(t, db, capID) wid := store.DefaultDevFixtureIDs().WorkspaceID @@ -984,11 +955,19 @@ func TestSyncAgentCapabilitiesDoesNotBindForeignMarketplaceByName(t *testing.T) if err != nil { t.Fatalf("list bindings: %v", err) } + var matched store.AgentCapabilityRead for _, b := range bindings { if b.CapabilityID == capID { - t.Fatalf("foreign marketplace capability binding should not be created (capID=%s); bindings=%+v", capID, bindings) + matched = b + break } } + if matched.CapabilityID == "" { + t.Fatalf("marketplace capability binding was dropped silently (capID=%s); bindings=%+v", capID, bindings) + } + if matched.CapabilityVersionID != v1 { + t.Fatalf("expected binding to source version %s, got %s", v1, matched.CapabilityVersionID) + } } // TestCapabilityDeleteFreesUniqueNameSlot reproduces the bug: a "deprecated"