mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-24 10:58:37 +00:00
Add hosted catalog source profile validation
This commit is contained in:
@@ -6,10 +6,12 @@ import officialExternalPluginCatalog from "../../scripts/lib/official-external-p
|
||||
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
|
||||
import { createSqliteHostedOfficialExternalPluginCatalogSnapshotStore } from "./official-external-plugin-catalog-snapshot-store.js";
|
||||
import {
|
||||
DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_URL,
|
||||
type OfficialExternalPluginCatalogEntry,
|
||||
createInMemoryHostedOfficialExternalPluginCatalogSnapshotStore,
|
||||
getOfficialExternalPluginCatalogEntry,
|
||||
isOfficialExternalPluginCatalogFeed,
|
||||
filterOfficialExternalPluginCatalogEntriesBySourceRefs,
|
||||
listOfficialExternalPluginCatalogEntries,
|
||||
loadHostedOfficialExternalPluginCatalogEntries,
|
||||
parseOfficialExternalPluginCatalogEntries,
|
||||
@@ -19,6 +21,7 @@ import {
|
||||
resolveOfficialExternalWebProviderContractPluginIdsForEnv,
|
||||
resolveOfficialExternalPluginId,
|
||||
resolveOfficialExternalPluginInstall,
|
||||
validateOfficialExternalPluginCatalogEntrySourceRefs,
|
||||
} from "./official-external-plugin-catalog.js";
|
||||
|
||||
function expectCatalogEntry(id: string): OfficialExternalPluginCatalogEntry {
|
||||
@@ -139,6 +142,354 @@ describe("official external plugin catalog", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the default local feed profile for hosted catalog loading", async () => {
|
||||
const body = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: "openclaw-official-external-plugins",
|
||||
generatedAt: "2026-06-22T00:00:00.000Z",
|
||||
sequence: 8,
|
||||
entries: [
|
||||
{
|
||||
name: "@openclaw/default-profile-proof",
|
||||
kind: "plugin",
|
||||
openclaw: { plugin: { id: "default-profile-proof" } },
|
||||
},
|
||||
],
|
||||
});
|
||||
const fetchImpl = vi.fn(async (url: RequestInfo | URL) => {
|
||||
expect(String(url)).toBe(DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_URL);
|
||||
return new Response(body, { status: 200 });
|
||||
});
|
||||
|
||||
const result = await loadHostedOfficialExternalPluginCatalogEntries({
|
||||
feedProfile: "clawhub-public",
|
||||
catalogConfig: {
|
||||
sources: { "acme-npm": { type: "npm", registry: "https://packages.acme.example/npm/" } },
|
||||
},
|
||||
fetchImpl,
|
||||
snapshotStore: null,
|
||||
});
|
||||
|
||||
expect(result.source).toBe("hosted");
|
||||
expect(result.entries.map((entry) => entry.name)).toEqual(["@openclaw/default-profile-proof"]);
|
||||
});
|
||||
|
||||
it("accepts the live ClawHub feed source ref by default", async () => {
|
||||
const body = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: "clawhub-official",
|
||||
generatedAt: "2026-06-23T09:38:53.000Z",
|
||||
sequence: 4,
|
||||
entries: [
|
||||
{
|
||||
type: "plugin",
|
||||
id: "@openclaw/live-feed-proof",
|
||||
title: "Live Feed Proof",
|
||||
version: "1.0.0",
|
||||
state: "available",
|
||||
publisher: { id: "openclaw", trust: "official" },
|
||||
install: {
|
||||
candidates: [
|
||||
{
|
||||
sourceRef: "public-clawhub",
|
||||
package: "@openclaw/live-feed-proof",
|
||||
version: "1.0.0",
|
||||
integrity: "sha256:abc",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await loadHostedOfficialExternalPluginCatalogEntries({
|
||||
fetchImpl: vi.fn(async (url: RequestInfo | URL) => {
|
||||
expect(String(url)).toBe(DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_URL);
|
||||
return new Response(body, { status: 200 });
|
||||
}),
|
||||
snapshotStore: null,
|
||||
});
|
||||
|
||||
expect(result.source).toBe("hosted");
|
||||
expect(result.entries.map((entry) => entry.id)).toEqual(["@openclaw/live-feed-proof"]);
|
||||
expect(resolveOfficialExternalPluginInstall(result.entries[0])).toEqual({
|
||||
clawhubSpec: "clawhub:@openclaw/live-feed-proof@1.0.0",
|
||||
npmSpec: "@openclaw/live-feed-proof@1.0.0",
|
||||
defaultChoice: "clawhub",
|
||||
expectedIntegrity: "sha256:abc",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows named local feed profiles to authorize their configured HTTPS host", async () => {
|
||||
const body = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: "openclaw-official-external-plugins",
|
||||
generatedAt: "2026-06-22T00:00:00.000Z",
|
||||
sequence: 9,
|
||||
entries: [
|
||||
{
|
||||
name: "@acme/private-proof",
|
||||
kind: "plugin",
|
||||
install: {
|
||||
candidates: [
|
||||
{
|
||||
sourceRef: "acme-npm",
|
||||
package: "@acme/private-proof",
|
||||
version: "1.0.0",
|
||||
},
|
||||
],
|
||||
},
|
||||
openclaw: {
|
||||
plugin: { id: "private-proof" },
|
||||
install: { sourceRef: "acme-npm", npmSpec: "@acme/private-proof" },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const fetchImpl = vi.fn(async (url: RequestInfo | URL) => {
|
||||
expect(String(url)).toBe("https://packages.acme.example/openclaw/feed");
|
||||
return new Response(body, { status: 200 });
|
||||
});
|
||||
|
||||
const result = await loadHostedOfficialExternalPluginCatalogEntries({
|
||||
feedProfile: "acme",
|
||||
catalogConfig: {
|
||||
feeds: { acme: { url: "https://packages.acme.example/openclaw/feed" } },
|
||||
sources: { "acme-npm": { type: "npm", registry: "https://packages.acme.example/npm/" } },
|
||||
},
|
||||
fetchImpl,
|
||||
snapshotStore: null,
|
||||
});
|
||||
|
||||
expect(result.source).toBe("hosted");
|
||||
expect(result.entries.map((entry) => entry.name)).toEqual(["@acme/private-proof"]);
|
||||
});
|
||||
|
||||
it("keeps direct hosted feed URL overrides constrained to the public allowlist", async () => {
|
||||
const fetchImpl = vi.fn(async () => new Response("{}", { status: 200 }));
|
||||
|
||||
const result = await loadHostedOfficialExternalPluginCatalogEntries({
|
||||
feedUrl: "https://packages.acme.example/openclaw/feed",
|
||||
fetchImpl,
|
||||
snapshotStore: null,
|
||||
});
|
||||
|
||||
expect(result.source).toBe("bundled-fallback");
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
if (result.source === "bundled-fallback") {
|
||||
expect(result.error).toContain("hostname is not allowed");
|
||||
}
|
||||
});
|
||||
|
||||
it("requires manifest install source refs when the default feed profile URL is overridden", async () => {
|
||||
const body = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: "openclaw-official-external-plugins",
|
||||
generatedAt: "2026-06-22T00:00:00.000Z",
|
||||
sequence: 13,
|
||||
entries: [
|
||||
{
|
||||
name: "@acme/default-override-missing-source-ref",
|
||||
kind: "plugin",
|
||||
openclaw: {
|
||||
plugin: { id: "default-override-missing-source-ref" },
|
||||
install: { npmSpec: "@acme/default-override-missing-source-ref" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "@acme/default-override-known-source-ref",
|
||||
kind: "plugin",
|
||||
openclaw: {
|
||||
plugin: { id: "default-override-known-source-ref" },
|
||||
install: { sourceRef: "acme-npm", npmSpec: "@acme/default-override-known-source-ref" },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await loadHostedOfficialExternalPluginCatalogEntries({
|
||||
catalogConfig: {
|
||||
feeds: { "clawhub-public": { url: "https://packages.acme.example/openclaw/feed" } },
|
||||
sources: { "acme-npm": { type: "npm", registry: "https://packages.acme.example/npm/" } },
|
||||
},
|
||||
fetchImpl: vi.fn(async (url: RequestInfo | URL) => {
|
||||
expect(String(url)).toBe("https://packages.acme.example/openclaw/feed");
|
||||
return new Response(body, { status: 200 });
|
||||
}),
|
||||
snapshotStore: null,
|
||||
});
|
||||
|
||||
expect(result.source).toBe("hosted");
|
||||
expect(result.entries.map((entry) => entry.name)).toEqual([
|
||||
"@acme/default-override-known-source-ref",
|
||||
]);
|
||||
});
|
||||
|
||||
it("requires manifest install source refs for custom local feed profiles", async () => {
|
||||
const missingManifestSourceRef = {
|
||||
name: "@acme/missing-manifest-source-ref",
|
||||
kind: "plugin",
|
||||
openclaw: {
|
||||
plugin: { id: "missing-manifest-source-ref" },
|
||||
install: { npmSpec: "@acme/missing-manifest-source-ref" },
|
||||
},
|
||||
};
|
||||
const implicitNameInstall = {
|
||||
name: "@acme/implicit-name-install",
|
||||
kind: "plugin",
|
||||
openclaw: { plugin: { id: "implicit-name-install" } },
|
||||
};
|
||||
const topLevelCandidateOnly = {
|
||||
name: "@acme/top-level-candidate-only",
|
||||
kind: "plugin",
|
||||
install: {
|
||||
candidates: [{ sourceRef: "acme-npm", package: "@acme/top-level-candidate-only" }],
|
||||
},
|
||||
openclaw: {
|
||||
plugin: { id: "top-level-candidate-only" },
|
||||
install: { npmSpec: "@acme/top-level-candidate-only" },
|
||||
},
|
||||
};
|
||||
const knownManifestSourceRef = {
|
||||
name: "@acme/known-manifest-source-ref",
|
||||
kind: "plugin",
|
||||
openclaw: {
|
||||
plugin: { id: "known-manifest-source-ref" },
|
||||
install: { npmSpec: "@acme/known-manifest-source-ref", sourceRef: "acme-npm" },
|
||||
},
|
||||
};
|
||||
const body = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: "openclaw-official-external-plugins",
|
||||
generatedAt: "2026-06-22T00:00:00.000Z",
|
||||
sequence: 11,
|
||||
entries: [
|
||||
missingManifestSourceRef,
|
||||
implicitNameInstall,
|
||||
topLevelCandidateOnly,
|
||||
knownManifestSourceRef,
|
||||
],
|
||||
});
|
||||
|
||||
const catalogConfig = {
|
||||
feeds: { acme: { url: "https://packages.acme.example/openclaw/feed" } },
|
||||
sources: { "acme-npm": { type: "npm" as const } },
|
||||
};
|
||||
|
||||
expect(
|
||||
validateOfficialExternalPluginCatalogEntrySourceRefs(missingManifestSourceRef, {
|
||||
catalogConfig,
|
||||
requireManifestInstallSourceRef: true,
|
||||
}),
|
||||
).toEqual(["feed install candidate is missing sourceRef"]);
|
||||
expect(
|
||||
validateOfficialExternalPluginCatalogEntrySourceRefs(implicitNameInstall, {
|
||||
catalogConfig,
|
||||
requireManifestInstallSourceRef: true,
|
||||
}),
|
||||
).toEqual(["feed install candidate is missing sourceRef"]);
|
||||
expect(
|
||||
validateOfficialExternalPluginCatalogEntrySourceRefs(topLevelCandidateOnly, {
|
||||
catalogConfig,
|
||||
requireManifestInstallSourceRef: true,
|
||||
}),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
validateOfficialExternalPluginCatalogEntrySourceRefs(knownManifestSourceRef, {
|
||||
catalogConfig,
|
||||
requireManifestInstallSourceRef: true,
|
||||
}),
|
||||
).toEqual([]);
|
||||
|
||||
const result = await loadHostedOfficialExternalPluginCatalogEntries({
|
||||
feedProfile: "acme",
|
||||
catalogConfig,
|
||||
fetchImpl: vi.fn(async () => new Response(body, { status: 200 })),
|
||||
snapshotStore: null,
|
||||
});
|
||||
|
||||
expect(result.source).toBe("hosted");
|
||||
expect(result.entries.map((entry) => entry.name)).toEqual([
|
||||
"@acme/top-level-candidate-only",
|
||||
"@acme/known-manifest-source-ref",
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters hosted feed entries that reference unknown local source profiles", async () => {
|
||||
const knownEntry = {
|
||||
name: "@openclaw/source-ref-known",
|
||||
kind: "plugin",
|
||||
install: {
|
||||
candidates: [{ sourceRef: "public-clawhub", package: "@openclaw/source-ref-known" }],
|
||||
},
|
||||
openclaw: { plugin: { id: "source-ref-known" } },
|
||||
};
|
||||
const unknownEntry = {
|
||||
name: "@openclaw/source-ref-unknown",
|
||||
kind: "plugin",
|
||||
install: {
|
||||
candidates: [{ sourceRef: "attacker-npm", package: "@openclaw/source-ref-unknown" }],
|
||||
},
|
||||
openclaw: { plugin: { id: "source-ref-unknown" } },
|
||||
};
|
||||
const missingEntry = {
|
||||
name: "@openclaw/source-ref-missing",
|
||||
kind: "plugin",
|
||||
install: { candidates: [{ package: "@openclaw/source-ref-missing" }] },
|
||||
openclaw: { plugin: { id: "source-ref-missing" } },
|
||||
};
|
||||
const manifestInstallWithoutSourceRef = {
|
||||
name: "@openclaw/source-ref-manifest-missing",
|
||||
kind: "plugin",
|
||||
install: {
|
||||
candidates: [
|
||||
{ sourceRef: "public-clawhub", package: "@openclaw/source-ref-manifest-missing" },
|
||||
],
|
||||
},
|
||||
openclaw: {
|
||||
plugin: { id: "source-ref-manifest-missing" },
|
||||
install: { npmSpec: "@openclaw/source-ref-manifest-missing" },
|
||||
},
|
||||
};
|
||||
const body = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: "openclaw-official-external-plugins",
|
||||
generatedAt: "2026-06-22T00:00:00.000Z",
|
||||
sequence: 10,
|
||||
entries: [knownEntry, unknownEntry, missingEntry, manifestInstallWithoutSourceRef],
|
||||
});
|
||||
|
||||
expect(validateOfficialExternalPluginCatalogEntrySourceRefs(knownEntry)).toEqual([]);
|
||||
expect(validateOfficialExternalPluginCatalogEntrySourceRefs(unknownEntry)).toEqual([
|
||||
'feed install candidate references unknown sourceRef "attacker-npm"',
|
||||
]);
|
||||
expect(validateOfficialExternalPluginCatalogEntrySourceRefs(missingEntry)).toEqual([
|
||||
"feed install candidate is missing sourceRef",
|
||||
]);
|
||||
expect(
|
||||
validateOfficialExternalPluginCatalogEntrySourceRefs(manifestInstallWithoutSourceRef),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
filterOfficialExternalPluginCatalogEntriesBySourceRefs([
|
||||
knownEntry,
|
||||
unknownEntry,
|
||||
missingEntry,
|
||||
manifestInstallWithoutSourceRef,
|
||||
]).map((entry) => entry.name),
|
||||
).toEqual(["@openclaw/source-ref-known", "@openclaw/source-ref-manifest-missing"]);
|
||||
|
||||
const result = await loadHostedOfficialExternalPluginCatalogEntries({
|
||||
fetchImpl: vi.fn(async () => new Response(body, { status: 200 })),
|
||||
snapshotStore: null,
|
||||
});
|
||||
|
||||
expect(result.source).toBe("hosted");
|
||||
expect(result.entries.map((entry) => entry.name)).toEqual([
|
||||
"@openclaw/source-ref-known",
|
||||
"@openclaw/source-ref-manifest-missing",
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to the bundled catalog when hosted feed validation fails", async () => {
|
||||
const result = await loadHostedOfficialExternalPluginCatalogEntries({
|
||||
snapshotStore: null,
|
||||
@@ -282,7 +633,7 @@ describe("official external plugin catalog", () => {
|
||||
const stateDir = mkdtempSync(path.join(os.tmpdir(), "openclaw-hosted-store-"));
|
||||
try {
|
||||
const store = createSqliteHostedOfficialExternalPluginCatalogSnapshotStore({ stateDir });
|
||||
const url = "https://register.openclaw.ai/official-external-plugin-catalog.json";
|
||||
const url = "https://clawhub.ai/v1/feeds/plugins";
|
||||
|
||||
const firstBody = JSON.stringify({ entries: [] });
|
||||
const secondBody = JSON.stringify({ entries: [{}] });
|
||||
@@ -325,6 +676,59 @@ describe("official external plugin catalog", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("applies custom source-ref validation to exception snapshot fallback", async () => {
|
||||
const body = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: "openclaw-official-external-plugins",
|
||||
generatedAt: "2026-06-22T00:00:00.000Z",
|
||||
sequence: 12,
|
||||
entries: [
|
||||
{
|
||||
name: "@acme/snapshot-missing-source-ref",
|
||||
kind: "plugin",
|
||||
openclaw: {
|
||||
plugin: { id: "snapshot-missing-source-ref" },
|
||||
install: { npmSpec: "@acme/snapshot-missing-source-ref" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "@acme/snapshot-known-source-ref",
|
||||
kind: "plugin",
|
||||
openclaw: {
|
||||
plugin: { id: "snapshot-known-source-ref" },
|
||||
install: { sourceRef: "acme-npm", npmSpec: "@acme/snapshot-known-source-ref" },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const catalogConfig = {
|
||||
feeds: { acme: { url: "https://packages.acme.example/openclaw/feed" } },
|
||||
sources: { "acme-npm": { type: "npm" as const } },
|
||||
};
|
||||
const seeded = await loadHostedOfficialExternalPluginCatalogEntries({
|
||||
feedProfile: "acme",
|
||||
catalogConfig,
|
||||
snapshotStore: createInMemoryHostedOfficialExternalPluginCatalogSnapshotStore(),
|
||||
fetchImpl: vi.fn(async () => new Response(body, { status: 200 })),
|
||||
});
|
||||
if (seeded.source !== "hosted") {
|
||||
throw new Error("expected seeded hosted feed");
|
||||
}
|
||||
const snapshotStore = createInMemoryHostedOfficialExternalPluginCatalogSnapshotStore([
|
||||
{ body, metadata: seeded.metadata, savedAt: "2026-06-22T01:02:03.000Z" },
|
||||
]);
|
||||
|
||||
const result = await loadHostedOfficialExternalPluginCatalogEntries({
|
||||
feedProfile: "acme",
|
||||
catalogConfig,
|
||||
snapshotStore,
|
||||
fetchImpl: vi.fn(async () => new Response("{ nope", { status: 200 })),
|
||||
});
|
||||
|
||||
expect(result.source).toBe("hosted-snapshot");
|
||||
expect(result.entries.map((entry) => entry.name)).toEqual(["@acme/snapshot-known-source-ref"]);
|
||||
});
|
||||
|
||||
it("uses the last known good snapshot when the hosted feed returns HTTP 304", async () => {
|
||||
const body = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
@@ -418,7 +822,7 @@ describe("official external plugin catalog", () => {
|
||||
entries: [],
|
||||
}),
|
||||
metadata: {
|
||||
url: "https://register.openclaw.ai/official-external-plugin-catalog.json",
|
||||
url: "https://clawhub.ai/v1/feeds/plugins",
|
||||
status: 200,
|
||||
checksum: "sha256:not-current",
|
||||
},
|
||||
@@ -511,6 +915,73 @@ describe("official external plugin catalog", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("prefers feed install candidates before legacy install metadata", () => {
|
||||
expect(
|
||||
resolveOfficialExternalPluginInstall({
|
||||
name: "@legacy/plain-package",
|
||||
kind: "plugin",
|
||||
install: {
|
||||
candidates: [
|
||||
{
|
||||
sourceRef: "public-clawhub",
|
||||
package: "@openclaw/candidate-package",
|
||||
version: "1.2.3",
|
||||
integrity: "sha256:candidate",
|
||||
},
|
||||
],
|
||||
},
|
||||
openclaw: {
|
||||
plugin: { id: "candidate-package" },
|
||||
install: {
|
||||
npmSpec: "@legacy/plain-package",
|
||||
minHostVersion: ">=2026.6.1",
|
||||
expectedIntegrity: "sha256:manifest",
|
||||
allowInvalidConfigRecovery: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
clawhubSpec: "clawhub:@openclaw/candidate-package@1.2.3",
|
||||
npmSpec: "@openclaw/candidate-package@1.2.3",
|
||||
defaultChoice: "clawhub",
|
||||
expectedIntegrity: "sha256:candidate",
|
||||
minHostVersion: ">=2026.6.1",
|
||||
allowInvalidConfigRecovery: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveOfficialExternalPluginInstall(
|
||||
{
|
||||
name: "@acme/private-package",
|
||||
kind: "plugin",
|
||||
install: {
|
||||
candidates: [
|
||||
{ sourceRef: "acme-npm", package: "@acme/private-package", version: "4.5.6" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ catalogConfig: { sources: { "acme-npm": { type: "npm" } } } },
|
||||
),
|
||||
).toEqual({ npmSpec: "@acme/private-package@4.5.6", defaultChoice: "npm" });
|
||||
|
||||
expect(
|
||||
resolveOfficialExternalPluginInstall(
|
||||
{
|
||||
name: "git-only-package",
|
||||
kind: "plugin",
|
||||
install: {
|
||||
candidates: [{ sourceRef: "acme-git", package: "git@example.com:acme/plugin.git" }],
|
||||
},
|
||||
},
|
||||
{ catalogConfig: { sources: { "acme-git": { type: "git" } } } },
|
||||
),
|
||||
).toBeNull();
|
||||
|
||||
expect(
|
||||
resolveOfficialExternalPluginInstall({ id: "metadata-only", title: "Metadata only" }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("lists the externalized provider and capability plugins with install metadata", () => {
|
||||
const providers = [
|
||||
["arcee", "@openclaw/arcee-provider"],
|
||||
|
||||
@@ -71,20 +71,64 @@ export type OfficialExternalPluginCatalogManifest = {
|
||||
};
|
||||
providers?: readonly OfficialExternalProviderCatalogProvider[];
|
||||
webSearchProviders?: readonly OfficialExternalWebSearchProvider[];
|
||||
install?: PluginPackageInstall;
|
||||
install?: PluginPackageInstall & { sourceRef?: string };
|
||||
contracts?: PluginManifestContracts;
|
||||
channelConfigs?: Record<string, PluginManifestChannelConfig>;
|
||||
};
|
||||
|
||||
/** Raw official external catalog entry loaded from generated catalog JSON. */
|
||||
export type OfficialExternalPluginCatalogEntry = {
|
||||
id?: string;
|
||||
title?: string;
|
||||
type?: string;
|
||||
state?: string;
|
||||
publisher?: {
|
||||
id?: string;
|
||||
trust?: string;
|
||||
};
|
||||
name?: string;
|
||||
version?: string;
|
||||
description?: string;
|
||||
source?: string;
|
||||
kind?: string;
|
||||
install?: {
|
||||
candidates?: readonly OfficialExternalPluginCatalogInstallCandidate[];
|
||||
};
|
||||
} & Partial<Record<ManifestKey, OfficialExternalPluginCatalogManifest>>;
|
||||
|
||||
export type OfficialExternalPluginCatalogInstallCandidate = {
|
||||
sourceRef?: string;
|
||||
package?: string;
|
||||
version?: string;
|
||||
integrity?: string;
|
||||
repo?: string;
|
||||
path?: string;
|
||||
commit?: string;
|
||||
};
|
||||
|
||||
export type OfficialExternalPluginCatalogSourceProfile =
|
||||
| {
|
||||
type: "npm";
|
||||
registry?: string;
|
||||
}
|
||||
| {
|
||||
type: "clawhub";
|
||||
baseUrl?: string;
|
||||
}
|
||||
| {
|
||||
type: "git";
|
||||
baseUrl?: string;
|
||||
};
|
||||
|
||||
export type OfficialExternalPluginCatalogFeedProfile = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type OfficialExternalPluginCatalogProfileConfig = {
|
||||
feeds?: Record<string, OfficialExternalPluginCatalogFeedProfile>;
|
||||
sources?: Record<string, OfficialExternalPluginCatalogSourceProfile>;
|
||||
};
|
||||
|
||||
/** Feed-shaped wrapper used by the bundled external plugin catalog fallback. */
|
||||
export type OfficialExternalPluginCatalogFeed = {
|
||||
schemaVersion: 1;
|
||||
@@ -155,11 +199,35 @@ const OFFICIAL_CATALOG_SOURCES = [
|
||||
|
||||
const OFFICIAL_EXTERNAL_CATALOG_FEED_SCHEMA_VERSION = 1;
|
||||
export const DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_URL =
|
||||
"https://register.openclaw.ai/official-marketplace.json";
|
||||
"https://clawhub.ai/v1/feeds/plugins";
|
||||
export const DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_PROFILE = "clawhub-public";
|
||||
export const DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_CLAWHUB_SOURCE_REF = "public-clawhub";
|
||||
export const DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_NPM_SOURCE_REF = "public-npm";
|
||||
export const DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_PROFILE_CONFIG: OfficialExternalPluginCatalogProfileConfig =
|
||||
{
|
||||
feeds: {
|
||||
[DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_PROFILE]: {
|
||||
url: DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_URL,
|
||||
},
|
||||
},
|
||||
sources: {
|
||||
[DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_CLAWHUB_SOURCE_REF]: {
|
||||
type: "clawhub",
|
||||
baseUrl: "https://clawhub.ai",
|
||||
},
|
||||
[DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_NPM_SOURCE_REF]: {
|
||||
type: "npm",
|
||||
registry: "https://registry.npmjs.org/",
|
||||
},
|
||||
},
|
||||
};
|
||||
const DEFAULT_HOSTED_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_TIMEOUT_MS = 5000;
|
||||
const DEFAULT_HOSTED_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_MAX_BYTES = 1024 * 1024;
|
||||
const DEFAULT_HOSTED_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_CHUNK_TIMEOUT_MS = 5000;
|
||||
const OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_HOSTNAME_ALLOWLIST = ["register.openclaw.ai"];
|
||||
const OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_HOSTNAME_ALLOWLIST = [
|
||||
"clawhub.ai",
|
||||
"register.openclaw.ai",
|
||||
];
|
||||
|
||||
export function isOfficialExternalPluginCatalogFeed(
|
||||
raw: unknown,
|
||||
@@ -215,23 +283,166 @@ function sha256Hex(value: string): string {
|
||||
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
||||
}
|
||||
|
||||
function resolveHostedCatalogFeedUrl(feedUrl: string | undefined): URL {
|
||||
const raw = feedUrl?.trim() || DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_URL;
|
||||
function resolveHostedCatalogFeedUrl(raw: string): URL {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
parsed = new URL(raw.trim());
|
||||
} catch {
|
||||
throw new Error("hosted catalog feed URL is invalid");
|
||||
}
|
||||
if (parsed.protocol !== "https:") {
|
||||
throw new Error("hosted catalog feed URL must use HTTPS");
|
||||
}
|
||||
if (!OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_HOSTNAME_ALLOWLIST.includes(parsed.hostname)) {
|
||||
throw new Error("hosted catalog feed URL hostname is not allowed");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function resolveOfficialExternalPluginCatalogProfileConfig(
|
||||
config?: OfficialExternalPluginCatalogProfileConfig,
|
||||
): Required<OfficialExternalPluginCatalogProfileConfig> {
|
||||
return {
|
||||
feeds: {
|
||||
...DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_PROFILE_CONFIG.feeds,
|
||||
...config?.feeds,
|
||||
},
|
||||
sources: {
|
||||
...DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_PROFILE_CONFIG.sources,
|
||||
...config?.sources,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function resolveHostedCatalogFeedSource(params: {
|
||||
feedUrl?: string;
|
||||
feedProfile?: string;
|
||||
catalogConfig?: OfficialExternalPluginCatalogProfileConfig;
|
||||
}): { url: URL; hostnameAllowlist: string[] } {
|
||||
const profileConfig = resolveOfficialExternalPluginCatalogProfileConfig(params.catalogConfig);
|
||||
const explicitFeedUrl = normalizeOptionalString(params.feedUrl);
|
||||
if (explicitFeedUrl) {
|
||||
const url = resolveHostedCatalogFeedUrl(explicitFeedUrl);
|
||||
if (!OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_HOSTNAME_ALLOWLIST.includes(url.hostname)) {
|
||||
throw new Error("hosted catalog feed URL hostname is not allowed");
|
||||
}
|
||||
return { url, hostnameAllowlist: OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_HOSTNAME_ALLOWLIST };
|
||||
}
|
||||
const profileName =
|
||||
normalizeOptionalString(params.feedProfile) ??
|
||||
DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_PROFILE;
|
||||
const profile = profileConfig.feeds[profileName];
|
||||
if (!profile) {
|
||||
throw new Error(`hosted catalog feed profile "${profileName}" is not configured`);
|
||||
}
|
||||
const url = resolveHostedCatalogFeedUrl(profile.url);
|
||||
return {
|
||||
url,
|
||||
hostnameAllowlist: uniqueStrings([
|
||||
...OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_HOSTNAME_ALLOWLIST,
|
||||
url.hostname,
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
function getOfficialExternalPluginCatalogSourceRefs(
|
||||
config?: OfficialExternalPluginCatalogProfileConfig,
|
||||
): Set<string> {
|
||||
return new Set(Object.keys(resolveOfficialExternalPluginCatalogProfileConfig(config).sources));
|
||||
}
|
||||
|
||||
function getFeedEntryInstallCandidates(
|
||||
entry: OfficialExternalPluginCatalogEntry,
|
||||
): OfficialExternalPluginCatalogInstallCandidate[] {
|
||||
const install = isRecord(entry.install) ? entry.install : undefined;
|
||||
const candidates = install?.candidates;
|
||||
if (!Array.isArray(candidates)) {
|
||||
return [];
|
||||
}
|
||||
return candidates.filter(
|
||||
(candidate): candidate is OfficialExternalPluginCatalogInstallCandidate => isRecord(candidate),
|
||||
);
|
||||
}
|
||||
|
||||
function shouldRequireManifestInstallSourceRef(params: {
|
||||
feedProfile?: string;
|
||||
catalogConfig?: OfficialExternalPluginCatalogProfileConfig;
|
||||
}): boolean {
|
||||
const profileName =
|
||||
normalizeOptionalString(params.feedProfile) ??
|
||||
DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_PROFILE;
|
||||
if (profileName !== DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_PROFILE) {
|
||||
return true;
|
||||
}
|
||||
const profileConfig = resolveOfficialExternalPluginCatalogProfileConfig(params.catalogConfig);
|
||||
const profileUrl = normalizeOptionalString(profileConfig.feeds[profileName]?.url);
|
||||
try {
|
||||
return (
|
||||
resolveHostedCatalogFeedUrl(profileUrl ?? DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_URL)
|
||||
.href !==
|
||||
resolveHostedCatalogFeedUrl(DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_URL).href
|
||||
);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function getManifestInstallSourceRefCandidate(
|
||||
entry: OfficialExternalPluginCatalogEntry,
|
||||
): OfficialExternalPluginCatalogInstallCandidate | undefined {
|
||||
const install = getOfficialExternalPluginCatalogManifest(entry)?.install;
|
||||
if (!install) {
|
||||
return undefined;
|
||||
}
|
||||
const hasInstallSpec = Boolean(
|
||||
normalizeOptionalString(install.clawhubSpec) ||
|
||||
normalizeOptionalString(install.npmSpec) ||
|
||||
normalizeOptionalString(install.localPath),
|
||||
);
|
||||
if (!hasInstallSpec) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
sourceRef: normalizeOptionalString(install.sourceRef),
|
||||
package:
|
||||
normalizeOptionalString(install.npmSpec) ?? normalizeOptionalString(install.clawhubSpec),
|
||||
};
|
||||
}
|
||||
|
||||
export function validateOfficialExternalPluginCatalogEntrySourceRefs(
|
||||
entry: OfficialExternalPluginCatalogEntry,
|
||||
params?: {
|
||||
catalogConfig?: OfficialExternalPluginCatalogProfileConfig;
|
||||
requireManifestInstallSourceRef?: boolean;
|
||||
},
|
||||
): string[] {
|
||||
const configuredSourceRefs = getOfficialExternalPluginCatalogSourceRefs(params?.catalogConfig);
|
||||
const errors: string[] = [];
|
||||
let candidates = getFeedEntryInstallCandidates(entry);
|
||||
if (candidates.length === 0 && params?.requireManifestInstallSourceRef) {
|
||||
const manifestCandidate = getManifestInstallSourceRefCandidate(entry);
|
||||
candidates = manifestCandidate ? [manifestCandidate] : [{}];
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
const sourceRef = normalizeOptionalString(candidate.sourceRef);
|
||||
if (!sourceRef) {
|
||||
errors.push("feed install candidate is missing sourceRef");
|
||||
} else if (!configuredSourceRefs.has(sourceRef)) {
|
||||
errors.push(`feed install candidate references unknown sourceRef "${sourceRef}"`);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function filterOfficialExternalPluginCatalogEntriesBySourceRefs(
|
||||
entries: OfficialExternalPluginCatalogEntry[],
|
||||
params?: {
|
||||
catalogConfig?: OfficialExternalPluginCatalogProfileConfig;
|
||||
requireManifestInstallSourceRef?: boolean;
|
||||
},
|
||||
): OfficialExternalPluginCatalogEntry[] {
|
||||
return entries.filter(
|
||||
(entry) => validateOfficialExternalPluginCatalogEntrySourceRefs(entry, params).length === 0,
|
||||
);
|
||||
}
|
||||
|
||||
function parseHostedCatalogContentLength(raw: string | null, maxBytes: number): void {
|
||||
const normalized = normalizeOptionalString(raw);
|
||||
if (!normalized) {
|
||||
@@ -335,7 +546,9 @@ async function readHostedCatalogResponseText(params: {
|
||||
|
||||
function bundledOfficialExternalPluginCatalogEntries(): OfficialExternalPluginCatalogEntry[] {
|
||||
return OFFICIAL_CATALOG_SOURCES.flatMap((source) =>
|
||||
parseOfficialExternalPluginCatalogEntries(source),
|
||||
filterOfficialExternalPluginCatalogEntriesBySourceRefs(
|
||||
parseOfficialExternalPluginCatalogEntries(source),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -373,6 +586,8 @@ function loadHostedCatalogSnapshotResult(params: {
|
||||
snapshot: HostedOfficialExternalPluginCatalogSnapshot;
|
||||
error: unknown;
|
||||
expectedSha256?: string;
|
||||
catalogConfig?: OfficialExternalPluginCatalogProfileConfig;
|
||||
requireManifestInstallSourceRef?: boolean;
|
||||
}): HostedOfficialExternalPluginCatalogLoadResult {
|
||||
const checksum = sha256Hex(params.snapshot.body);
|
||||
if (checksum !== params.snapshot.metadata.checksum) {
|
||||
@@ -388,7 +603,13 @@ function loadHostedCatalogSnapshotResult(params: {
|
||||
return {
|
||||
source: "hosted-snapshot",
|
||||
entries: dedupeOfficialExternalPluginCatalogEntries(
|
||||
parseOfficialExternalPluginCatalogEntries(raw),
|
||||
filterOfficialExternalPluginCatalogEntriesBySourceRefs(
|
||||
parseOfficialExternalPluginCatalogEntries(raw),
|
||||
{
|
||||
catalogConfig: params.catalogConfig,
|
||||
requireManifestInstallSourceRef: params.requireManifestInstallSourceRef,
|
||||
},
|
||||
),
|
||||
),
|
||||
feed: raw,
|
||||
metadata: params.snapshot.metadata,
|
||||
@@ -403,6 +624,8 @@ async function snapshotOrBundledFallbackResult(params: {
|
||||
url: string;
|
||||
metadata?: HostedOfficialExternalPluginCatalogLoadResult["metadata"];
|
||||
expectedSha256?: string;
|
||||
catalogConfig?: OfficialExternalPluginCatalogProfileConfig;
|
||||
requireManifestInstallSourceRef?: boolean;
|
||||
}): Promise<HostedOfficialExternalPluginCatalogLoadResult> {
|
||||
if (params.snapshotStore) {
|
||||
try {
|
||||
@@ -412,6 +635,8 @@ async function snapshotOrBundledFallbackResult(params: {
|
||||
snapshot,
|
||||
error: params.error,
|
||||
expectedSha256: params.expectedSha256,
|
||||
catalogConfig: params.catalogConfig,
|
||||
requireManifestInstallSourceRef: params.requireManifestInstallSourceRef,
|
||||
});
|
||||
}
|
||||
} catch (snapshotErr) {
|
||||
@@ -461,6 +686,8 @@ async function resolveHostedCatalogSnapshotStore(params: {
|
||||
|
||||
export async function loadHostedOfficialExternalPluginCatalogEntries(params?: {
|
||||
feedUrl?: string;
|
||||
feedProfile?: string;
|
||||
catalogConfig?: OfficialExternalPluginCatalogProfileConfig;
|
||||
fetchImpl?: FetchLike;
|
||||
timeoutMs?: number;
|
||||
maxBytes?: number;
|
||||
@@ -474,12 +701,17 @@ export async function loadHostedOfficialExternalPluginCatalogEntries(params?: {
|
||||
stateDatabasePath?: string;
|
||||
now?: () => Date;
|
||||
}): Promise<HostedOfficialExternalPluginCatalogLoadResult> {
|
||||
let url: URL;
|
||||
let source: { url: URL; hostnameAllowlist: string[] };
|
||||
try {
|
||||
url = resolveHostedCatalogFeedUrl(params?.feedUrl);
|
||||
source = resolveHostedCatalogFeedSource({
|
||||
feedUrl: params?.feedUrl,
|
||||
feedProfile: params?.feedProfile,
|
||||
catalogConfig: params?.catalogConfig,
|
||||
});
|
||||
} catch (err) {
|
||||
return bundledFallbackResult(err);
|
||||
}
|
||||
const { url } = source;
|
||||
const snapshotStore = await resolveHostedCatalogSnapshotStore({
|
||||
snapshotStore: params?.snapshotStore,
|
||||
env: params?.env,
|
||||
@@ -517,7 +749,7 @@ export async function loadHostedOfficialExternalPluginCatalogEntries(params?: {
|
||||
requireHttps: true,
|
||||
maxRedirects: 2,
|
||||
timeoutMs: params?.timeoutMs ?? DEFAULT_HOSTED_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_TIMEOUT_MS,
|
||||
policy: { hostnameAllowlist: OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_HOSTNAME_ALLOWLIST },
|
||||
policy: { hostnameAllowlist: source.hostnameAllowlist },
|
||||
auditContext: "official-external-plugin-catalog-feed",
|
||||
});
|
||||
response = guarded.response;
|
||||
@@ -530,6 +762,11 @@ export async function loadHostedOfficialExternalPluginCatalogEntries(params?: {
|
||||
url: url.href,
|
||||
metadata: base,
|
||||
expectedSha256,
|
||||
catalogConfig: params?.catalogConfig,
|
||||
requireManifestInstallSourceRef: shouldRequireManifestInstallSourceRef({
|
||||
feedProfile: params?.feedProfile,
|
||||
catalogConfig: params?.catalogConfig,
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (!response.ok) {
|
||||
@@ -539,6 +776,11 @@ export async function loadHostedOfficialExternalPluginCatalogEntries(params?: {
|
||||
url: url.href,
|
||||
metadata: base,
|
||||
expectedSha256,
|
||||
catalogConfig: params?.catalogConfig,
|
||||
requireManifestInstallSourceRef: shouldRequireManifestInstallSourceRef({
|
||||
feedProfile: params?.feedProfile,
|
||||
catalogConfig: params?.catalogConfig,
|
||||
}),
|
||||
});
|
||||
}
|
||||
const body = await readHostedCatalogResponseText({
|
||||
@@ -556,6 +798,11 @@ export async function loadHostedOfficialExternalPluginCatalogEntries(params?: {
|
||||
url: url.href,
|
||||
metadata,
|
||||
expectedSha256,
|
||||
catalogConfig: params?.catalogConfig,
|
||||
requireManifestInstallSourceRef: shouldRequireManifestInstallSourceRef({
|
||||
feedProfile: params?.feedProfile,
|
||||
catalogConfig: params?.catalogConfig,
|
||||
}),
|
||||
});
|
||||
}
|
||||
const raw = JSON.parse(body) as unknown;
|
||||
@@ -566,8 +813,23 @@ export async function loadHostedOfficialExternalPluginCatalogEntries(params?: {
|
||||
url: url.href,
|
||||
metadata,
|
||||
expectedSha256,
|
||||
catalogConfig: params?.catalogConfig,
|
||||
requireManifestInstallSourceRef: shouldRequireManifestInstallSourceRef({
|
||||
feedProfile: params?.feedProfile,
|
||||
catalogConfig: params?.catalogConfig,
|
||||
}),
|
||||
});
|
||||
}
|
||||
const entries = filterOfficialExternalPluginCatalogEntriesBySourceRefs(
|
||||
parseOfficialExternalPluginCatalogEntries(raw),
|
||||
{
|
||||
catalogConfig: params?.catalogConfig,
|
||||
requireManifestInstallSourceRef: shouldRequireManifestInstallSourceRef({
|
||||
feedProfile: params?.feedProfile,
|
||||
catalogConfig: params?.catalogConfig,
|
||||
}),
|
||||
},
|
||||
);
|
||||
await snapshotStore
|
||||
?.write({
|
||||
body,
|
||||
@@ -577,9 +839,7 @@ export async function loadHostedOfficialExternalPluginCatalogEntries(params?: {
|
||||
.catch(() => undefined);
|
||||
return {
|
||||
source: "hosted",
|
||||
entries: dedupeOfficialExternalPluginCatalogEntries(
|
||||
parseOfficialExternalPluginCatalogEntries(raw),
|
||||
),
|
||||
entries: dedupeOfficialExternalPluginCatalogEntries(entries),
|
||||
feed: raw,
|
||||
metadata,
|
||||
};
|
||||
@@ -589,6 +849,11 @@ export async function loadHostedOfficialExternalPluginCatalogEntries(params?: {
|
||||
snapshotStore,
|
||||
url: url.href,
|
||||
expectedSha256,
|
||||
catalogConfig: params?.catalogConfig,
|
||||
requireManifestInstallSourceRef: shouldRequireManifestInstallSourceRef({
|
||||
feedProfile: params?.feedProfile,
|
||||
catalogConfig: params?.catalogConfig,
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
if (response?.bodyUsed !== true) {
|
||||
@@ -602,6 +867,87 @@ function normalizeDefaultChoice(value: unknown): PluginPackageInstall["defaultCh
|
||||
return value === "clawhub" || value === "npm" || value === "local" ? value : undefined;
|
||||
}
|
||||
|
||||
function formatFeedInstallCandidateSpec(
|
||||
candidate: OfficialExternalPluginCatalogInstallCandidate,
|
||||
): string | undefined {
|
||||
const packageName = normalizeOptionalString(candidate.package);
|
||||
if (!packageName) {
|
||||
return undefined;
|
||||
}
|
||||
const version = normalizeOptionalString(candidate.version);
|
||||
if (!version || packageName.endsWith(`@${version}`)) {
|
||||
return packageName;
|
||||
}
|
||||
return `${packageName}@${version}`;
|
||||
}
|
||||
|
||||
function getFeedEntryCandidateSourceType(
|
||||
candidate: OfficialExternalPluginCatalogInstallCandidate,
|
||||
config?: OfficialExternalPluginCatalogProfileConfig,
|
||||
): OfficialExternalPluginCatalogSourceProfile["type"] | undefined {
|
||||
const sourceRef = normalizeOptionalString(candidate.sourceRef);
|
||||
if (!sourceRef) {
|
||||
return undefined;
|
||||
}
|
||||
return resolveOfficialExternalPluginCatalogProfileConfig(config).sources[sourceRef]?.type;
|
||||
}
|
||||
|
||||
function getPreferredFeedEntryInstallCandidate(params: {
|
||||
entry: OfficialExternalPluginCatalogEntry;
|
||||
catalogConfig?: OfficialExternalPluginCatalogProfileConfig;
|
||||
}): OfficialExternalPluginCatalogInstallCandidate | undefined {
|
||||
const candidates = getFeedEntryInstallCandidates(params.entry).filter((candidate) =>
|
||||
Boolean(normalizeOptionalString(candidate.package)),
|
||||
);
|
||||
return (
|
||||
candidates.find(
|
||||
(candidate) =>
|
||||
normalizeOptionalString(candidate.sourceRef) ===
|
||||
DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_CLAWHUB_SOURCE_REF,
|
||||
) ??
|
||||
candidates.find(
|
||||
(candidate) =>
|
||||
normalizeOptionalString(candidate.sourceRef) ===
|
||||
DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_NPM_SOURCE_REF,
|
||||
) ??
|
||||
candidates.find((candidate) =>
|
||||
Boolean(getFeedEntryCandidateSourceType(candidate, params.catalogConfig)),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveFeedEntryInstallCandidate(params: {
|
||||
entry: OfficialExternalPluginCatalogEntry;
|
||||
catalogConfig?: OfficialExternalPluginCatalogProfileConfig;
|
||||
}): PluginPackageInstall | null {
|
||||
const candidate = getPreferredFeedEntryInstallCandidate(params);
|
||||
if (!candidate) {
|
||||
return null;
|
||||
}
|
||||
const spec = formatFeedInstallCandidateSpec(candidate);
|
||||
if (!spec) {
|
||||
return null;
|
||||
}
|
||||
const sourceType = getFeedEntryCandidateSourceType(candidate, params.catalogConfig);
|
||||
const expectedIntegrity = normalizeOptionalString(candidate.integrity);
|
||||
if (sourceType === "clawhub") {
|
||||
return {
|
||||
clawhubSpec: `clawhub:${spec}`,
|
||||
npmSpec: spec,
|
||||
defaultChoice: "clawhub",
|
||||
...(expectedIntegrity ? { expectedIntegrity } : {}),
|
||||
};
|
||||
}
|
||||
if (sourceType === "npm") {
|
||||
return {
|
||||
npmSpec: spec,
|
||||
defaultChoice: "npm",
|
||||
...(expectedIntegrity ? { expectedIntegrity } : {}),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Returns manifest metadata from an official external catalog entry when present. */
|
||||
export function getOfficialExternalPluginCatalogManifest(
|
||||
entry: OfficialExternalPluginCatalogEntry,
|
||||
@@ -617,7 +963,8 @@ export function resolveOfficialExternalPluginId(
|
||||
return (
|
||||
normalizeOptionalString(manifest?.plugin?.id) ??
|
||||
normalizeOptionalString(manifest?.channel?.id) ??
|
||||
normalizeOptionalString(manifest?.providers?.[0]?.id)
|
||||
normalizeOptionalString(manifest?.providers?.[0]?.id) ??
|
||||
normalizeOptionalString(entry.id)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -646,6 +993,7 @@ export function resolveOfficialExternalPluginLabel(
|
||||
normalizeOptionalString(manifest?.plugin?.label) ??
|
||||
normalizeOptionalString(manifest?.channel?.label) ??
|
||||
normalizeOptionalString(manifest?.providers?.[0]?.name) ??
|
||||
normalizeOptionalString(entry.title) ??
|
||||
normalizeOptionalString(entry.name) ??
|
||||
resolveOfficialExternalPluginId(entry) ??
|
||||
"plugin"
|
||||
@@ -654,18 +1002,36 @@ export function resolveOfficialExternalPluginLabel(
|
||||
|
||||
export function resolveOfficialExternalPluginInstall(
|
||||
entry: OfficialExternalPluginCatalogEntry,
|
||||
params?: { catalogConfig?: OfficialExternalPluginCatalogProfileConfig },
|
||||
): PluginPackageInstall | null {
|
||||
const manifest = getOfficialExternalPluginCatalogManifest(entry);
|
||||
const install = manifest?.install;
|
||||
const clawhubSpec = normalizeOptionalString(install?.clawhubSpec);
|
||||
const npmSpec = normalizeOptionalString(install?.npmSpec) ?? normalizeOptionalString(entry.name);
|
||||
const manifestNpmSpec = normalizeOptionalString(install?.npmSpec);
|
||||
const localPath = normalizeOptionalString(install?.localPath);
|
||||
if (!clawhubSpec && !npmSpec && !localPath) {
|
||||
return null;
|
||||
const candidateInstall = resolveFeedEntryInstallCandidate({
|
||||
entry,
|
||||
catalogConfig: params?.catalogConfig,
|
||||
});
|
||||
if (candidateInstall) {
|
||||
return {
|
||||
...candidateInstall,
|
||||
...(install?.minHostVersion ? { minHostVersion: install.minHostVersion } : {}),
|
||||
...(install?.expectedIntegrity && !candidateInstall.expectedIntegrity
|
||||
? { expectedIntegrity: install.expectedIntegrity }
|
||||
: {}),
|
||||
...(install?.allowInvalidConfigRecovery === true ? { allowInvalidConfigRecovery: true } : {}),
|
||||
};
|
||||
}
|
||||
const hasFeedInstallCandidates = getFeedEntryInstallCandidates(entry).length > 0;
|
||||
const npmSpec =
|
||||
manifestNpmSpec ?? (hasFeedInstallCandidates ? undefined : normalizeOptionalString(entry.name));
|
||||
const defaultChoice =
|
||||
normalizeDefaultChoice(install?.defaultChoice) ??
|
||||
(npmSpec ? "npm" : clawhubSpec ? "clawhub" : localPath ? "local" : undefined);
|
||||
if (!clawhubSpec && !npmSpec && !localPath) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...(clawhubSpec ? { clawhubSpec } : {}),
|
||||
...(npmSpec ? { npmSpec } : {}),
|
||||
|
||||
Reference in New Issue
Block a user