mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
feat: carry Featured state into Control (#109418)
* feat: consume live featured catalog state * fix(plugins): honor hosted curation for published bundled plugins * fix(plugins): accept package-less trusted ClawHub provenance
This commit is contained in:
@@ -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<typeof import("./official-external-plugin-catalog.js")>()),
|
||||
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<string, unknown>;
|
||||
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,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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: {} });
|
||||
|
||||
@@ -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<HostedOfficialExternalPluginCatalogLoadResult, "entries"> & {
|
||||
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<string> {
|
||||
): 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<OfficialCata
|
||||
};
|
||||
}
|
||||
const result = await officialCatalogCache.result;
|
||||
const hostedFeaturedAuthoritative =
|
||||
result.source === "hosted" || result.source === "hosted-snapshot";
|
||||
return {
|
||||
entries: overlayBundledOfficialPluginCatalogMetadata(result.entries),
|
||||
entries: overlayBundledOfficialPluginCatalogMetadata(result.entries, undefined, {
|
||||
hostedFeaturedAuthoritative,
|
||||
}),
|
||||
hostedFeaturedAuthoritative,
|
||||
...("error" in result ? { error: result.error } : {}),
|
||||
};
|
||||
}
|
||||
@@ -317,6 +374,23 @@ function compareCatalogEntries(
|
||||
return order !== 0 ? order : left.name.localeCompare(right.name);
|
||||
}
|
||||
|
||||
function resolveInstalledOfficialCatalogEntry(params: {
|
||||
entries: readonly OfficialExternalPluginCatalogEntry[];
|
||||
packageName?: string;
|
||||
source: CatalogPackageSourceIdentity["source"];
|
||||
}): OfficialExternalPluginCatalogEntry | undefined {
|
||||
if (!params.packageName) {
|
||||
return undefined;
|
||||
}
|
||||
const matches = params.entries.filter((entry) =>
|
||||
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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -109,6 +109,7 @@ export type OfficialExternalPluginCatalogEntry = {
|
||||
description?: string;
|
||||
source?: string;
|
||||
kind?: string;
|
||||
featured?: boolean;
|
||||
install?: {
|
||||
candidates?: readonly OfficialExternalPluginCatalogInstallCandidate[];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user