diff --git a/src/plugins/management-service-featured.test.ts b/src/plugins/management-service-featured.test.ts new file mode 100644 index 00000000000..6159c0f04c4 --- /dev/null +++ b/src/plugins/management-service-featured.test.ts @@ -0,0 +1,526 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + metadata: vi.fn(), + officialCatalog: vi.fn(), +})); + +vi.mock("./plugin-metadata-snapshot.js", () => ({ + loadPluginMetadataSnapshot: (...args: unknown[]) => mocks.metadata(...args), +})); + +vi.mock("./official-external-plugin-catalog.js", async (importOriginal) => ({ + ...(await importOriginal()), + loadConfiguredHostedOfficialExternalPluginCatalogEntries: (...args: unknown[]) => + mocks.officialCatalog(...args), +})); + +const { clearManagedPluginOfficialCatalogCache, listManagedPlugins } = + await import("./management-service.js"); + +function metadataSnapshot(params: { + id?: string; + name?: string; + origin?: "bundled" | "global"; + packageName?: string | null; + installRecord?: Record; + featured?: boolean; +}) { + const id = params.id ?? "workboard"; + const packageName = + params.packageName === null ? undefined : (params.packageName ?? `@openclaw/${id}`); + const manifest = { + id, + name: params.name ?? "Workboard", + description: "Coordinate agent work in a shared board.", + catalog: { featured: params.featured ?? true, order: 10 }, + channels: [], + providers: [], + cliBackends: [], + skills: [], + hooks: [], + origin: params.origin ?? "bundled", + rootDir: `/tmp/${id}`, + source: `/tmp/${id}/index.ts`, + manifestPath: `/tmp/${id}/openclaw.plugin.json`, + }; + return { + index: { + plugins: [ + { + pluginId: id, + ...(packageName ? { packageName } : {}), + origin: params.origin ?? "bundled", + enabled: true, + }, + ], + installRecords: params.installRecord ? { [id]: params.installRecord } : {}, + }, + byPluginId: new Map([[id, manifest]]), + plugins: [manifest], + diagnostics: [], + normalizePluginId: (pluginId: string) => pluginId, + }; +} + +function emptyMetadataSnapshot() { + return { + index: { plugins: [], installRecords: {} }, + byPluginId: new Map(), + plugins: [], + diagnostics: [], + normalizePluginId: (pluginId: string) => pluginId, + }; +} + +function hostedCatalog(entries: unknown[]) { + return { + source: "hosted", + entries, + feed: { schemaVersion: 1, id: "test", generatedAt: "now", sequence: 1, entries: [] }, + metadata: { url: "https://clawhub.ai/feed", status: 200, checksum: "hash" }, + }; +} + +function hostedFeedEntry(params: { + packageName: string; + title: string; + featured?: boolean; + pluginId?: string; + catalogFeatured?: boolean; + order?: number; +}) { + return { + id: params.packageName, + title: params.title, + state: "available", + ...(params.featured === undefined ? {} : { featured: params.featured }), + publisher: { id: "openclaw", trust: "official" }, + install: { + candidates: [ + { + sourceRef: "public-clawhub", + package: params.packageName, + version: "1.0.0", + integrity: `sha256:${"b".repeat(64)}`, + }, + ], + }, + ...(params.pluginId + ? { + openclaw: { + plugin: { id: params.pluginId, label: params.title }, + catalog: { + ...(params.catalogFeatured === undefined ? {} : { featured: params.catalogFeatured }), + ...(params.order === undefined ? {} : { order: params.order }), + }, + }, + } + : {}), + }; +} + +const hostedFeedDiffsEntry = hostedFeedEntry({ + packageName: "@openclaw/diffs", + title: "Diffs", + featured: true, +}); +const hostedImpostorEntry = hostedFeedEntry({ + packageName: "@community/impostor", + title: "Impostor", + featured: false, + pluginId: "workboard", + catalogFeatured: false, +}); + +describe("plugin management Featured authority", () => { + beforeEach(() => { + clearManagedPluginOfficialCatalogCache(); + mocks.metadata.mockReset(); + mocks.officialCatalog.mockReset(); + mocks.officialCatalog.mockResolvedValue(hostedCatalog([])); + }); + + it("lets a live unfeature override bundled metadata without removing installability", async () => { + mocks.metadata.mockReturnValue(emptyMetadataSnapshot()); + mocks.officialCatalog.mockResolvedValue( + hostedCatalog([{ ...hostedFeedDiffsEntry, featured: false }]), + ); + + const catalog = await listManagedPlugins({ config: {}, env: {} }); + + expect(catalog.plugins).toEqual([ + expect.objectContaining({ + id: "diffs", + featured: false, + order: 40, + install: { source: "official", pluginId: "diffs" }, + }), + ]); + }); + + it("treats a legacy hosted row without featured as unfeatured", async () => { + mocks.metadata.mockReturnValue(emptyMetadataSnapshot()); + mocks.officialCatalog.mockResolvedValue( + hostedCatalog([ + hostedFeedEntry({ + packageName: "@openclaw/diffs", + title: "Diffs", + }), + ]), + ); + + const catalog = await listManagedPlugins({ config: {}, env: {} }); + + expect(catalog.plugins[0]).toMatchObject({ + id: "diffs", + featured: false, + install: { source: "official", pluginId: "diffs" }, + }); + }); + + it("surfaces a newly featured live official package without static fallback metadata", async () => { + mocks.metadata.mockReturnValue(emptyMetadataSnapshot()); + mocks.officialCatalog.mockResolvedValue( + hostedCatalog([ + hostedFeedEntry({ + packageName: "@openclaw/new-tool", + title: "New Tool", + featured: true, + }), + ]), + ); + + const catalog = await listManagedPlugins({ config: {}, env: {} }); + + expect(catalog.plugins).toEqual([ + expect.objectContaining({ + id: "@openclaw/new-tool", + name: "New Tool", + featured: true, + install: { source: "official", pluginId: "@openclaw/new-tool" }, + }), + ]); + }); + + it("clears stale embedded curation on an unmatched live official package", async () => { + mocks.metadata.mockReturnValue(emptyMetadataSnapshot()); + mocks.officialCatalog.mockResolvedValue( + hostedCatalog([ + hostedFeedEntry({ + packageName: "@openclaw/new-tool", + title: "New Tool", + featured: false, + pluginId: "new-tool", + catalogFeatured: true, + order: 80, + }), + ]), + ); + + const catalog = await listManagedPlugins({ config: {}, env: {} }); + + expect(catalog.plugins).toEqual([ + expect.objectContaining({ + id: "new-tool", + featured: false, + order: 80, + }), + ]); + }); + + it("clears stale embedded curation on a matched package without bundled curation", async () => { + mocks.metadata.mockReturnValue(emptyMetadataSnapshot()); + mocks.officialCatalog.mockResolvedValue( + hostedCatalog([ + hostedFeedEntry({ + packageName: "@openclaw/copilot", + title: "Copilot", + featured: false, + pluginId: "copilot", + catalogFeatured: true, + order: 80, + }), + ]), + ); + + const catalog = await listManagedPlugins({ config: {}, env: {} }); + + expect(catalog.plugins).toEqual([ + expect.objectContaining({ + id: "copilot", + featured: false, + order: 80, + }), + ]); + }); + + it("lets a live unfeature override an installed published plugin manifest", async () => { + mocks.metadata.mockReturnValue( + metadataSnapshot({ + id: "diffs", + name: "Diffs", + origin: "global", + installRecord: { source: "npm", spec: "@openclaw/diffs" }, + }), + ); + mocks.officialCatalog.mockResolvedValue( + hostedCatalog([{ ...hostedFeedDiffsEntry, featured: false }]), + ); + + const catalog = await listManagedPlugins({ config: {}, env: {} }); + + expect(catalog.plugins).toEqual([ + expect.objectContaining({ + id: "diffs", + installed: true, + featured: false, + order: 40, + }), + ]); + }); + + it("applies live ClawHub curation to a bundled-known npm installation", async () => { + mocks.metadata.mockReturnValue( + metadataSnapshot({ + id: "diffs", + name: "Diffs", + origin: "global", + installRecord: { source: "npm", spec: "@openclaw/diffs" }, + featured: false, + }), + ); + mocks.officialCatalog.mockResolvedValue(hostedCatalog([hostedFeedDiffsEntry])); + + const catalog = await listManagedPlugins({ config: {}, env: {} }); + + expect(catalog.plugins).toEqual([ + expect.objectContaining({ + id: "diffs", + featured: true, + order: 40, + }), + ]); + }); + + it.each([ + { id: "workboard", name: "Workboard", packageName: "@openclaw/workboard" }, + { id: "open-prose", name: "OpenProse", packageName: "@openclaw/open-prose" }, + { id: "memory-wiki", name: "Memory Wiki", packageName: "@openclaw/memory-wiki" }, + ])("keeps local curation for private bundled-only $name", async (plugin) => { + mocks.metadata.mockReturnValue(metadataSnapshot(plugin)); + mocks.officialCatalog.mockResolvedValue( + hostedCatalog([ + hostedFeedEntry({ + packageName: `@community/${plugin.id}`, + title: "Impostor", + featured: false, + pluginId: plugin.id, + catalogFeatured: false, + }), + ]), + ); + + const catalog = await listManagedPlugins({ config: {}, env: {} }); + + expect(catalog.plugins).toEqual([ + expect.objectContaining({ + id: plugin.id, + name: plugin.name, + packageName: plugin.packageName, + featured: true, + order: 10, + }), + ]); + }); + + it("applies hosted curation to the exact published package for bundled FireCrawl", async () => { + mocks.metadata.mockReturnValue( + metadataSnapshot({ + id: "firecrawl", + name: "FireCrawl", + packageName: "@openclaw/firecrawl-plugin", + featured: false, + }), + ); + mocks.officialCatalog.mockResolvedValue( + hostedCatalog([ + hostedFeedEntry({ + packageName: "@openclaw/firecrawl-plugin", + title: "FireCrawl", + featured: true, + pluginId: "firecrawl", + }), + ]), + ); + + const catalog = await listManagedPlugins({ config: {}, env: {} }); + + expect(catalog.plugins).toEqual([ + expect.objectContaining({ + id: "firecrawl", + name: "FireCrawl", + packageName: "@openclaw/firecrawl-plugin", + featured: true, + order: 10, + }), + ]); + }); + + it("keeps local curation for an unproven global package identity", async () => { + mocks.metadata.mockReturnValue( + metadataSnapshot({ + id: "diffs", + name: "Private Diffs", + origin: "global", + packageName: "@openclaw/diffs", + }), + ); + mocks.officialCatalog.mockResolvedValue( + hostedCatalog([{ ...hostedFeedDiffsEntry, featured: false }]), + ); + + const catalog = await listManagedPlugins({ config: {}, env: {} }); + + expect(catalog.plugins).toEqual([ + expect.objectContaining({ + id: "diffs", + featured: true, + order: 10, + }), + ]); + }); + + it("does not identify a package-less private bundled plugin by hosted runtime id", async () => { + mocks.metadata.mockReturnValue(metadataSnapshot({ packageName: null })); + mocks.officialCatalog.mockResolvedValue(hostedCatalog([hostedImpostorEntry])); + + const catalog = await listManagedPlugins({ config: {}, env: {} }); + + expect(catalog.plugins).toEqual([ + expect.objectContaining({ + id: "workboard", + featured: true, + order: 10, + }), + ]); + }); + + it("does not identify a package-less global plugin by hosted runtime id alone", async () => { + mocks.metadata.mockReturnValue(metadataSnapshot({ origin: "global", packageName: null })); + mocks.officialCatalog.mockResolvedValue(hostedCatalog([hostedImpostorEntry])); + + const catalog = await listManagedPlugins({ config: {}, env: {} }); + + expect(catalog.plugins).toEqual([ + expect.objectContaining({ + id: "workboard", + featured: true, + order: 10, + }), + ]); + }); + + it("clears local curation when a known published plugin is omitted from a live feed", async () => { + mocks.metadata.mockReturnValue( + metadataSnapshot({ + id: "diffs", + name: "Diffs", + origin: "global", + installRecord: { source: "npm", spec: "@openclaw/diffs" }, + }), + ); + mocks.officialCatalog.mockResolvedValue(hostedCatalog([])); + + const catalog = await listManagedPlugins({ config: {}, env: {} }); + + expect(catalog.plugins).toEqual([ + expect.objectContaining({ + id: "diffs", + packageName: "@openclaw/diffs", + featured: false, + order: 10, + }), + ]); + }); + + it("preserves npm-only bundled curation outside the hosted producer identity", async () => { + mocks.metadata.mockReturnValue( + metadataSnapshot({ + id: "acpx", + name: "ACP Runtime", + origin: "global", + packageName: "@openclaw/acpx", + installRecord: { source: "npm", spec: "@openclaw/acpx" }, + }), + ); + mocks.officialCatalog.mockResolvedValue(hostedCatalog([])); + + const catalog = await listManagedPlugins({ config: {}, env: {} }); + + expect(catalog.plugins).toEqual([ + expect.objectContaining({ + id: "acpx", + featured: true, + order: 10, + }), + ]); + }); + + it("clears hosted-only curation using trusted official install provenance", async () => { + mocks.metadata.mockReturnValue( + metadataSnapshot({ + id: "new-tool", + name: "New Tool", + origin: "global", + packageName: "@openclaw/new-tool", + installRecord: { + source: "clawhub", + clawhubUrl: "https://clawhub.ai", + clawhubChannel: "official", + clawhubPackage: "@openclaw/new-tool", + }, + }), + ); + mocks.officialCatalog.mockResolvedValue(hostedCatalog([])); + + const catalog = await listManagedPlugins({ config: {}, env: {} }); + + expect(catalog.plugins).toEqual([ + expect.objectContaining({ + id: "new-tool", + packageName: "@openclaw/new-tool", + featured: false, + order: 10, + }), + ]); + }); + + it("accepts trusted official install provenance without discovered package metadata", async () => { + mocks.metadata.mockReturnValue( + metadataSnapshot({ + id: "new-tool", + name: "New Tool", + origin: "global", + packageName: null, + installRecord: { + source: "clawhub", + clawhubUrl: "https://clawhub.ai", + clawhubChannel: "official", + clawhubPackage: "@openclaw/new-tool", + }, + }), + ); + mocks.officialCatalog.mockResolvedValue(hostedCatalog([])); + + const catalog = await listManagedPlugins({ config: {}, env: {} }); + + expect(catalog.plugins).toEqual([ + expect.objectContaining({ + id: "new-tool", + featured: false, + order: 10, + }), + ]); + }); +}); diff --git a/src/plugins/management-service.test.ts b/src/plugins/management-service.test.ts index 17f7b7427c5..ea3358aa190 100644 --- a/src/plugins/management-service.test.ts +++ b/src/plugins/management-service.test.ts @@ -175,6 +175,7 @@ const hostedFeedDiffsEntry = { id: "@openclaw/diffs", title: "Diffs", state: "available", + featured: true, publisher: { id: "openclaw", trust: "official" }, install: { candidates: [ @@ -212,13 +213,12 @@ describe("plugin management service", () => { }); }); - it("overlays bundled curation through the hosted catalog load path", async () => { + it("keeps bundled curation when the hosted catalog falls back offline", async () => { mocks.metadata.mockReturnValue(emptyMetadataSnapshot()); mocks.officialCatalog.mockResolvedValue({ - source: "hosted", + source: "bundled-fallback", entries: [hostedDiffsEntry], - feed: { schemaVersion: 1, id: "test", generatedAt: "now", sequence: 1, entries: [] }, - metadata: { url: "https://clawhub.ai/feed", status: 200, checksum: "hash" }, + error: "offline", }); const catalog = await listManagedPlugins({ config: {}, env: {} }); diff --git a/src/plugins/management-service.ts b/src/plugins/management-service.ts index 91e424b4423..1cd82af7853 100644 --- a/src/plugins/management-service.ts +++ b/src/plugins/management-service.ts @@ -39,6 +39,11 @@ import { import { buildNpmResolutionInstallFields } from "./installs.js"; import type { PluginManifestRecord } from "./manifest-registry.js"; import type { PluginDiagnostic } from "./manifest-types.js"; +import { + resolveTrustedOfficialClawHubPackageName, + resolveTrustedSourceLinkedOfficialClawHubSpec, + resolveTrustedSourceLinkedOfficialNpmSpec, +} from "./official-external-install-records.js"; import { getOfficialExternalPluginCatalogManifest, listOfficialExternalPluginCatalogEntries, @@ -120,6 +125,7 @@ export class ManagedPluginLifecycleError extends Error { type OfficialCatalogResult = Pick & { error?: string; + hostedFeaturedAuthoritative?: boolean; }; let officialCatalogCache: @@ -138,6 +144,7 @@ export function clearManagedPluginOfficialCatalogCache(): void { function mergeCatalogMetadata( hosted: OfficialExternalPluginCatalogEntry, bundled: OfficialExternalPluginCatalogEntry, + options: { hostedFeaturedAuthoritative: boolean }, ): OfficialExternalPluginCatalogEntry { const hostedManifest = getOfficialExternalPluginCatalogManifest(hosted); const bundledManifest = getOfficialExternalPluginCatalogManifest(bundled); @@ -147,7 +154,18 @@ function mergeCatalogMetadata( const bundledDescription = normalizeOptionalString(bundled.description); const bundledKind = normalizeOptionalString(bundled.kind); const bundledSource = normalizeOptionalString(bundled.source); - if (!bundledCatalog && !bundledPlugin) { + const hostedFeatured = typeof hosted.featured === "boolean" ? hosted.featured : false; + const mergedCatalog = + bundledCatalog || + hostedManifest?.catalog || + (options.hostedFeaturedAuthoritative && hostedFeatured) + ? { + ...hostedManifest?.catalog, + ...bundledCatalog, + ...(options.hostedFeaturedAuthoritative ? { featured: hostedFeatured } : {}), + } + : undefined; + if (!mergedCatalog && !bundledPlugin) { return hosted; } return { @@ -161,23 +179,28 @@ function mergeCatalogMetadata( [MANIFEST_KEY]: { ...hostedManifest, ...(bundledPlugin ? { plugin: { ...hostedManifest?.plugin, ...bundledPlugin } } : {}), - ...(bundledCatalog ? { catalog: { ...hostedManifest?.catalog, ...bundledCatalog } } : {}), + ...(mergedCatalog ? { catalog: mergedCatalog } : {}), }, }; } +type CatalogPackageSourceIdentity = { + source: "clawhub" | "npm"; + packageName: string; +}; + function resolveCatalogPackageSourceIdentities( entry: OfficialExternalPluginCatalogEntry, -): Set { +): CatalogPackageSourceIdentity[] { const install = resolveOfficialExternalPluginInstall(entry); const clawhubPackage = install?.clawhubSpec ? parseClawHubPluginSpec(install.clawhubSpec)?.name : undefined; const npmPackage = install?.npmSpec ? parseRegistryNpmSpec(install.npmSpec)?.name : undefined; - return new Set([ - ...(clawhubPackage ? [`clawhub:${clawhubPackage}`] : []), - ...(npmPackage ? [`npm:${npmPackage}`] : []), - ]); + return [ + ...(clawhubPackage ? [{ source: "clawhub" as const, packageName: clawhubPackage }] : []), + ...(npmPackage ? [{ source: "npm" as const, packageName: npmPackage }] : []), + ]; } function matchesBundledCatalogIdentity(params: { @@ -186,20 +209,49 @@ function matchesBundledCatalogIdentity(params: { }): boolean { const hostedSources = resolveCatalogPackageSourceIdentities(params.hosted); const bundledSources = resolveCatalogPackageSourceIdentities(params.bundled); - return [...hostedSources].some((identity) => bundledSources.has(identity)); + return hostedSources.some((hosted) => + bundledSources.some( + (bundled) => bundled.source === hosted.source && bundled.packageName === hosted.packageName, + ), + ); } -/** Overlay local runtime identity and editorial hints after an exact package/source match. */ +/** + * Overlay local runtime identity and ordering after an exact package/source match. + * Hosted curation wins; bundled Featured state survives only in fallback mode. + */ function overlayBundledOfficialPluginCatalogMetadata( entries: readonly OfficialExternalPluginCatalogEntry[], bundledEntries: readonly OfficialExternalPluginCatalogEntry[] = listOfficialExternalPluginCatalogEntries(), + options: { hostedFeaturedAuthoritative: boolean } = { + hostedFeaturedAuthoritative: false, + }, ): OfficialExternalPluginCatalogEntry[] { return entries.map((entry) => { const matches = bundledEntries.filter((bundled) => matchesBundledCatalogIdentity({ hosted: entry, bundled }), ); const bundled = matches.length === 1 ? matches[0] : undefined; - return bundled ? mergeCatalogMetadata(entry, bundled) : entry; + if (bundled) { + return mergeCatalogMetadata(entry, bundled, options); + } + if (!options.hostedFeaturedAuthoritative) { + return entry; + } + const hostedManifest = getOfficialExternalPluginCatalogManifest(entry); + if (entry.featured !== true && !hostedManifest?.catalog) { + return entry; + } + return { + ...entry, + [MANIFEST_KEY]: { + ...hostedManifest, + catalog: { + ...hostedManifest?.catalog, + featured: entry.featured === true, + }, + }, + }; }); } @@ -212,8 +264,13 @@ async function loadOfficialCatalog(config: OpenClawConfig): Promise + resolveCatalogPackageSourceIdentities(entry).some( + (identity) => + identity.source === params.source && identity.packageName === params.packageName, + ), + ); + return matches.length === 1 ? matches[0] : undefined; +} + /** Build cold installed state merged with the hosted official catalog and bundled curation. */ export async function listManagedPlugins(params: { config: OpenClawConfig; @@ -326,9 +400,91 @@ export async function listManagedPlugins(params: { const env = params.env ?? process.env; const metadata = loadPluginMetadataSnapshot({ config: params.config, env }); const officialCatalog = params.officialCatalog ?? (await loadOfficialCatalog(params.config)); + const bundledOfficialEntries = listOfficialExternalPluginCatalogEntries(); const plugins = metadata.index.plugins.map((record): ManagedPluginCatalogEntry => { const manifest = metadata.byPluginId.get(record.pluginId); - const catalog = normalizeCatalogMetadata(manifest?.catalog); + const localCatalog = normalizeCatalogMetadata(manifest?.catalog); + const installRecord = metadata.index.installRecords[record.pluginId]; + const trustedOfficialClawHubSpec = installRecord + ? resolveTrustedSourceLinkedOfficialClawHubSpec({ + pluginId: record.pluginId, + record: installRecord, + }) + : undefined; + const trustedOfficialNpmSpec = installRecord + ? resolveTrustedSourceLinkedOfficialNpmSpec({ + pluginId: record.pluginId, + record: installRecord, + }) + : undefined; + const sourceLinkedOfficialClawHubPackage = trustedOfficialClawHubSpec + ? parseClawHubPluginSpec(trustedOfficialClawHubSpec)?.name + : undefined; + const currentOfficialClawHubPackage = installRecord + ? resolveTrustedOfficialClawHubPackageName(installRecord) + : undefined; + const trustedOfficialNpmPackage = trustedOfficialNpmSpec + ? parseRegistryNpmSpec(trustedOfficialNpmSpec)?.name + : undefined; + const bundledPublishedEntry = + record.origin === "bundled" + ? resolveInstalledOfficialCatalogEntry({ + entries: bundledOfficialEntries, + packageName: record.packageName, + source: "npm", + }) + : undefined; + const installedOfficialIdentity = sourceLinkedOfficialClawHubPackage + ? { source: "clawhub" as const, packageName: sourceLinkedOfficialClawHubPackage } + : trustedOfficialNpmPackage + ? { source: "npm" as const, packageName: trustedOfficialNpmPackage } + : currentOfficialClawHubPackage && + (!record.packageName || record.packageName === currentOfficialClawHubPackage) + ? { source: "clawhub" as const, packageName: currentOfficialClawHubPackage } + : bundledPublishedEntry && record.packageName + ? { source: "npm" as const, packageName: record.packageName } + : undefined; + const hasInstalledOfficialProvenance = Boolean( + installedOfficialIdentity && + (!record.packageName || record.packageName === installedOfficialIdentity.packageName), + ); + const bundledOfficialEntry = + bundledPublishedEntry ?? + resolveInstalledOfficialCatalogEntry({ + entries: bundledOfficialEntries, + packageName: hasInstalledOfficialProvenance + ? installedOfficialIdentity?.packageName + : undefined, + source: installedOfficialIdentity?.source ?? "clawhub", + }); + const hostedPackageName = + installedOfficialIdentity?.source === "npm" + ? (bundledOfficialEntry + ? resolveCatalogPackageSourceIdentities(bundledOfficialEntry) + : [] + ).find((identity) => identity.source === "clawhub")?.packageName + : installedOfficialIdentity?.packageName; + const officialEntry = resolveInstalledOfficialCatalogEntry({ + entries: officialCatalog.entries, + packageName: hasInstalledOfficialProvenance ? hostedPackageName : undefined, + source: "clawhub", + }); + const hasHostedOfficialIdentity = Boolean(hasInstalledOfficialProvenance && hostedPackageName); + const officialCatalogMetadata = officialEntry + ? normalizeCatalogMetadata(getOfficialExternalPluginCatalogManifest(officialEntry)?.catalog) + : undefined; + // Published plugin curation follows the live feed even after install, including + // omission. Private bundled plugins without an exact package/source match stay local. + const catalog = + hasHostedOfficialIdentity && officialCatalog.hostedFeaturedAuthoritative + ? { + ...localCatalog, + ...officialCatalogMetadata, + featured: officialEntry?.featured === true, + } + : officialCatalogMetadata + ? { ...localCatalog, ...officialCatalogMetadata } + : localCatalog; const error = firstPluginError(metadata.diagnostics, record.pluginId); const kind = normalizeKinds(manifest?.kind); const category = derivePluginCategory(manifest); @@ -370,8 +526,8 @@ export async function listManagedPlugins(params: { // Hosted rows without a declared runtime id fall back to their package name, // so id matching alone would keep them visible after a successful install. const entryPackageInstalled = (entry: OfficialExternalPluginCatalogEntry) => - [...resolveCatalogPackageSourceIdentities(entry)].some((identity) => - installedPackageNames.has(identity.slice(identity.indexOf(":") + 1)), + resolveCatalogPackageSourceIdentities(entry).some((identity) => + installedPackageNames.has(identity.packageName), ); for (const entry of officialCatalog.entries) { const pluginId = resolveOfficialExternalPluginId(entry); diff --git a/src/plugins/official-external-install-records.ts b/src/plugins/official-external-install-records.ts index 4f104857646..3110f18c50d 100644 --- a/src/plugins/official-external-install-records.ts +++ b/src/plugins/official-external-install-records.ts @@ -77,6 +77,24 @@ function isOfficialClawHubInstallRecord(record: PluginInstallRecord): boolean { return (record.clawhubUrl ?? "").trim().replace(/\/+$/, "") === "https://clawhub.ai"; } +/** Resolves one package identity from a current trusted official ClawHub install record. */ +export function resolveTrustedOfficialClawHubPackageName( + record: PluginInstallRecord, +): string | undefined { + if ( + record.source !== "clawhub" || + record.clawhubChannel !== "official" || + (record.clawhubUrl ?? "").trim().replace(/\/+$/, "") !== "https://clawhub.ai" + ) { + return undefined; + } + const packageNames = resolveRecordedClawHubPackageNames(record); + if (!packageNames || packageNames.length === 0 || new Set(packageNames).size !== 1) { + return undefined; + } + return packageNames[0]; +} + function hasTrustedClawHubSourceAuthority( record: PluginInstallRecord, officialClawHubSpec: string | undefined, diff --git a/src/plugins/official-external-plugin-catalog.test.ts b/src/plugins/official-external-plugin-catalog.test.ts index 5dcad9a3271..0ea2d3b80b3 100644 --- a/src/plugins/official-external-plugin-catalog.test.ts +++ b/src/plugins/official-external-plugin-catalog.test.ts @@ -269,6 +269,7 @@ describe("official external plugin catalog", () => { title: "Trusted", version: "1.2.3", state: "available", + featured: true, publisher: { id: "acme", trust: "official" }, install: { candidates: [ @@ -336,6 +337,8 @@ describe("official external plugin catalog", () => { defaultChoice: "clawhub", expectedIntegrity: "sha256-s1XdoEQDvsqri7qwaf0eewV4Ji50WeWYzFsZYVtb2rk=", }); + expect(trusted.featured).toBe(true); + expect(disabled).not.toHaveProperty("featured"); expect(resolveOfficialExternalPluginInstall(disabled)).toBeNull(); expect(resolveOfficialExternalPluginInstall(community)).toBeNull(); }); diff --git a/src/plugins/official-external-plugin-catalog.ts b/src/plugins/official-external-plugin-catalog.ts index fadaf5481dc..c70e47d2738 100644 --- a/src/plugins/official-external-plugin-catalog.ts +++ b/src/plugins/official-external-plugin-catalog.ts @@ -109,6 +109,7 @@ export type OfficialExternalPluginCatalogEntry = { description?: string; source?: string; kind?: string; + featured?: boolean; install?: { candidates?: readonly OfficialExternalPluginCatalogInstallCandidate[]; };