mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
Add hosted catalog source profile validation (#95969)
Merged via squash.
Prepared head SHA: 6dc6ca154c
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Reviewed-by: @giodl73-repo
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
createInMemoryHostedOfficialExternalPluginCatalogSnapshotStore,
|
||||
getOfficialExternalPluginCatalogEntry,
|
||||
isOfficialExternalPluginCatalogFeed,
|
||||
filterOfficialExternalPluginCatalogEntriesBySourceRefs,
|
||||
listOfficialExternalPluginCatalogEntries,
|
||||
loadHostedOfficialExternalPluginCatalogEntries,
|
||||
parseOfficialExternalPluginCatalogEntries,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
resolveOfficialExternalWebProviderContractPluginIdsForEnv,
|
||||
resolveOfficialExternalPluginId,
|
||||
resolveOfficialExternalPluginInstall,
|
||||
validateOfficialExternalPluginCatalogEntrySourceRefs,
|
||||
} from "./official-external-plugin-catalog.js";
|
||||
|
||||
function expectCatalogEntry(id: string): OfficialExternalPluginCatalogEntry {
|
||||
@@ -30,6 +32,10 @@ function expectCatalogEntry(id: string): OfficialExternalPluginCatalogEntry {
|
||||
return entry;
|
||||
}
|
||||
|
||||
function expectRequestUrl(input: RequestInfo | URL): string {
|
||||
return typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
||||
}
|
||||
|
||||
describe("official external plugin catalog", () => {
|
||||
it("keeps hosted fetch guard loading lazy for bundled catalog import paths", () => {
|
||||
const source = readFileSync(
|
||||
@@ -61,6 +67,15 @@ describe("official external plugin catalog", () => {
|
||||
entries: [],
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isOfficialExternalPluginCatalogFeed({
|
||||
schemaVersion: 2,
|
||||
id: "openclaw-official-external-plugins",
|
||||
generatedAt: "2026-06-22T00:00:00.000Z",
|
||||
sequence: 1,
|
||||
entries: [],
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isOfficialExternalPluginCatalogFeed({
|
||||
schemaVersion: 3,
|
||||
@@ -84,7 +99,7 @@ describe("official external plugin catalog", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps live ClawHub marketplace entries as metadata-only feed entries", () => {
|
||||
it("accepts live ClawHub marketplace entries with trusted install candidates", () => {
|
||||
const [entry] = parseOfficialExternalPluginCatalogEntries({
|
||||
schemaVersion: 2,
|
||||
id: "clawhub-official",
|
||||
@@ -125,8 +140,12 @@ describe("official external plugin catalog", () => {
|
||||
title: "Expedia Travel",
|
||||
version: "1.0.4",
|
||||
});
|
||||
expect(resolveOfficialExternalPluginId(entry)).toBeUndefined();
|
||||
expect(resolveOfficialExternalPluginInstall(entry)).toBeNull();
|
||||
expect(resolveOfficialExternalPluginId(entry)).toBe("@expediagroup/expedia-openclaw");
|
||||
expect(resolveOfficialExternalPluginInstall(entry)).toEqual({
|
||||
clawhubSpec: "clawhub:@expediagroup/expedia-openclaw@1.0.4",
|
||||
defaultChoice: "clawhub",
|
||||
expectedIntegrity: "sha256-s1XdoEQDvsqri7qwaf0eewV4Ji50WeWYzFsZYVtb2rk=",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not synthesize trusted installs for unavailable or untrusted hosted entries", () => {
|
||||
@@ -170,6 +189,55 @@ describe("official external plugin catalog", () => {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "plugin",
|
||||
id: "@example/missing-state",
|
||||
title: "Missing State",
|
||||
version: "1.0.0",
|
||||
publisher: { id: "example", trust: "official" },
|
||||
install: {
|
||||
candidates: [
|
||||
{
|
||||
sourceRef: "public-clawhub",
|
||||
package: "@example/missing-state",
|
||||
version: "1.0.0",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "plugin",
|
||||
id: "@example/missing-trust",
|
||||
title: "Missing Trust",
|
||||
version: "1.0.0",
|
||||
state: "available",
|
||||
publisher: { id: "example" },
|
||||
install: {
|
||||
candidates: [
|
||||
{
|
||||
sourceRef: "public-clawhub",
|
||||
package: "@example/missing-trust",
|
||||
version: "1.0.0",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "plugin",
|
||||
id: "@example/missing-publisher",
|
||||
title: "Missing Publisher",
|
||||
version: "1.0.0",
|
||||
state: "available",
|
||||
install: {
|
||||
candidates: [
|
||||
{
|
||||
sourceRef: "public-clawhub",
|
||||
package: "@example/missing-publisher",
|
||||
version: "1.0.0",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "plugin",
|
||||
id: "@example/private-source",
|
||||
@@ -190,9 +258,9 @@ describe("official external plugin catalog", () => {
|
||||
],
|
||||
});
|
||||
|
||||
expect(entries).toHaveLength(3);
|
||||
expect(entries).toHaveLength(6);
|
||||
for (const entry of entries) {
|
||||
expect(resolveOfficialExternalPluginId(entry)).toBeUndefined();
|
||||
expect(resolveOfficialExternalPluginId(entry)).toBe(entry.id);
|
||||
expect(resolveOfficialExternalPluginInstall(entry)).toBeNull();
|
||||
}
|
||||
});
|
||||
@@ -318,6 +386,351 @@ 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(expectRequestUrl(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: 2,
|
||||
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:b355dda04403becaab8bbab069fd1e7b0578262e7459e598cc5b19615b5bdab9",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await loadHostedOfficialExternalPluginCatalogEntries({
|
||||
fetchImpl: vi.fn(async (url: RequestInfo | URL) => {
|
||||
expect(expectRequestUrl(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",
|
||||
defaultChoice: "clawhub",
|
||||
expectedIntegrity: "sha256-s1XdoEQDvsqri7qwaf0eewV4Ji50WeWYzFsZYVtb2rk=",
|
||||
});
|
||||
});
|
||||
|
||||
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(expectRequestUrl(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(expectRequestUrl(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(["feed install candidate is missing sourceRef"]);
|
||||
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/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,
|
||||
@@ -461,7 +874,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: [{}] });
|
||||
@@ -504,6 +917,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,
|
||||
@@ -797,6 +1263,125 @@ describe("official external plugin catalog", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("prefers feed install candidates before legacy install metadata", () => {
|
||||
expect(
|
||||
resolveOfficialExternalPluginInstall({
|
||||
name: "@legacy/plain-package",
|
||||
kind: "plugin",
|
||||
state: "available",
|
||||
publisher: { id: "openclaw", trust: "official" },
|
||||
install: {
|
||||
candidates: [
|
||||
{
|
||||
sourceRef: "public-clawhub",
|
||||
package: "@openclaw/candidate-package",
|
||||
version: "1.2.3",
|
||||
integrity: "sha256:b355dda04403becaab8bbab069fd1e7b0578262e7459e598cc5b19615b5bdab9",
|
||||
},
|
||||
],
|
||||
},
|
||||
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",
|
||||
defaultChoice: "clawhub",
|
||||
expectedIntegrity: "sha256-s1XdoEQDvsqri7qwaf0eewV4Ji50WeWYzFsZYVtb2rk=",
|
||||
minHostVersion: ">=2026.6.1",
|
||||
allowInvalidConfigRecovery: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveOfficialExternalPluginInstall(
|
||||
{
|
||||
name: "@acme/private-package",
|
||||
kind: "plugin",
|
||||
state: "available",
|
||||
publisher: { id: "acme", trust: "official" },
|
||||
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: "@acme/private-sha-package",
|
||||
kind: "plugin",
|
||||
state: "available",
|
||||
publisher: { id: "acme", trust: "official" },
|
||||
install: {
|
||||
candidates: [
|
||||
{
|
||||
sourceRef: "acme-npm",
|
||||
package: "@acme/private-sha-package",
|
||||
version: "4.5.6",
|
||||
integrity:
|
||||
"sha256:b355dda04403becaab8bbab069fd1e7b0578262e7459e598cc5b19615b5bdab9",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ catalogConfig: { sources: { "acme-npm": { type: "npm" } } } },
|
||||
),
|
||||
).toEqual({ npmSpec: "@acme/private-sha-package@4.5.6", defaultChoice: "npm" });
|
||||
|
||||
expect(
|
||||
resolveOfficialExternalPluginInstall(
|
||||
{
|
||||
name: "@acme/private-sri-package",
|
||||
kind: "plugin",
|
||||
state: "available",
|
||||
publisher: { id: "acme", trust: "official" },
|
||||
install: {
|
||||
candidates: [
|
||||
{
|
||||
sourceRef: "acme-npm",
|
||||
package: "@acme/private-sri-package",
|
||||
version: "4.5.6",
|
||||
integrity: "sha512-abc=",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ catalogConfig: { sources: { "acme-npm": { type: "npm" } } } },
|
||||
),
|
||||
).toEqual({
|
||||
npmSpec: "@acme/private-sri-package@4.5.6",
|
||||
defaultChoice: "npm",
|
||||
expectedIntegrity: "sha512-abc=",
|
||||
});
|
||||
|
||||
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"],
|
||||
|
||||
@@ -6,6 +6,7 @@ import officialExternalChannelCatalog from "../../scripts/lib/official-external-
|
||||
import officialExternalPluginCatalog from "../../scripts/lib/official-external-plugin-catalog.json" with { type: "json" };
|
||||
import officialExternalProviderCatalog from "../../scripts/lib/official-external-provider-catalog.json" with { type: "json" };
|
||||
import { MANIFEST_KEY } from "../compat/legacy-names.js";
|
||||
import { normalizeClawHubSha256Integrity } from "../infra/clawhub.js";
|
||||
import { isRecord } from "../utils.js";
|
||||
import type {
|
||||
PluginManifestChannelConfig,
|
||||
@@ -71,7 +72,7 @@ export type OfficialExternalPluginCatalogManifest = {
|
||||
};
|
||||
providers?: readonly OfficialExternalProviderCatalogProvider[];
|
||||
webSearchProviders?: readonly OfficialExternalWebSearchProvider[];
|
||||
install?: PluginPackageInstall;
|
||||
install?: PluginPackageInstall & { sourceRef?: string };
|
||||
contracts?: PluginManifestContracts;
|
||||
channelConfigs?: Record<string, PluginManifestChannelConfig>;
|
||||
};
|
||||
@@ -101,6 +102,32 @@ export type OfficialExternalPluginCatalogInstallCandidate = {
|
||||
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. */
|
||||
@@ -171,9 +198,30 @@ const OFFICIAL_CATALOG_SOURCES = [
|
||||
officialExternalPluginCatalog,
|
||||
] as const;
|
||||
|
||||
const OFFICIAL_EXTERNAL_CATALOG_FEED_SCHEMA_VERSIONS = new Set<unknown>([1, 2]);
|
||||
const SUPPORTED_OFFICIAL_EXTERNAL_CATALOG_FEED_SCHEMA_VERSIONS = new Set([1, 2]);
|
||||
export const DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_URL =
|
||||
"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;
|
||||
@@ -188,7 +236,8 @@ export function isOfficialExternalPluginCatalogFeed(
|
||||
const sequence = raw.sequence;
|
||||
const entries = raw.entries;
|
||||
return (
|
||||
OFFICIAL_EXTERNAL_CATALOG_FEED_SCHEMA_VERSIONS.has(raw.schemaVersion) &&
|
||||
typeof raw.schemaVersion === "number" &&
|
||||
SUPPORTED_OFFICIAL_EXTERNAL_CATALOG_FEED_SCHEMA_VERSIONS.has(raw.schemaVersion) &&
|
||||
typeof raw.id === "string" &&
|
||||
raw.id.trim().length > 0 &&
|
||||
typeof raw.generatedAt === "string" &&
|
||||
@@ -233,23 +282,184 @@ 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 getFeedEntryInstallCandidateRecords(
|
||||
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 getFeedEntryInstallCandidates(
|
||||
entry: OfficialExternalPluginCatalogEntry,
|
||||
): OfficialExternalPluginCatalogInstallCandidate[] {
|
||||
const state = normalizeOptionalString(entry.state);
|
||||
if (state !== "available") {
|
||||
return [];
|
||||
}
|
||||
const publisherTrust = normalizeOptionalString(entry.publisher?.trust);
|
||||
if (publisherTrust !== "official") {
|
||||
return [];
|
||||
}
|
||||
return getFeedEntryInstallCandidateRecords(entry);
|
||||
}
|
||||
|
||||
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 = getFeedEntryInstallCandidateRecords(entry);
|
||||
if (params?.requireManifestInstallSourceRef) {
|
||||
const manifestCandidate = getManifestInstallSourceRefCandidate(entry);
|
||||
if (manifestCandidate) {
|
||||
candidates = [...candidates, manifestCandidate];
|
||||
} else if (candidates.length === 0) {
|
||||
candidates = [{}];
|
||||
}
|
||||
}
|
||||
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) {
|
||||
@@ -353,7 +563,9 @@ async function readHostedCatalogResponseText(params: {
|
||||
|
||||
function bundledOfficialExternalPluginCatalogEntries(): OfficialExternalPluginCatalogEntry[] {
|
||||
return OFFICIAL_CATALOG_SOURCES.flatMap((source) =>
|
||||
parseOfficialExternalPluginCatalogEntries(source),
|
||||
filterOfficialExternalPluginCatalogEntriesBySourceRefs(
|
||||
parseOfficialExternalPluginCatalogEntries(source),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -410,6 +622,8 @@ function loadHostedCatalogSnapshotResult(params: {
|
||||
expectedSha256?: string;
|
||||
ifNoneMatch?: string;
|
||||
ifModifiedSince?: string;
|
||||
catalogConfig?: OfficialExternalPluginCatalogProfileConfig;
|
||||
requireManifestInstallSourceRef?: boolean;
|
||||
}): HostedOfficialExternalPluginCatalogLoadResult {
|
||||
assertSnapshotMatchesRequestValidators({
|
||||
snapshot: params.snapshot,
|
||||
@@ -425,12 +639,18 @@ function loadHostedCatalogSnapshotResult(params: {
|
||||
}
|
||||
const raw = JSON.parse(params.snapshot.body) as unknown;
|
||||
if (!isOfficialExternalPluginCatalogFeed(raw)) {
|
||||
throw new Error("hosted catalog snapshot did not match schema version 1");
|
||||
throw new Error("hosted catalog snapshot did not match a supported schema version");
|
||||
}
|
||||
return {
|
||||
source: "hosted-snapshot",
|
||||
entries: dedupeOfficialExternalPluginCatalogEntries(
|
||||
parseOfficialExternalPluginCatalogEntries(raw),
|
||||
filterOfficialExternalPluginCatalogEntriesBySourceRefs(
|
||||
parseOfficialExternalPluginCatalogEntries(raw),
|
||||
{
|
||||
catalogConfig: params.catalogConfig,
|
||||
requireManifestInstallSourceRef: params.requireManifestInstallSourceRef,
|
||||
},
|
||||
),
|
||||
),
|
||||
feed: raw,
|
||||
metadata: params.snapshot.metadata,
|
||||
@@ -464,6 +684,8 @@ async function snapshotOrBundledFallbackResult(params: {
|
||||
expectedSha256?: string;
|
||||
ifNoneMatch?: string;
|
||||
ifModifiedSince?: string;
|
||||
catalogConfig?: OfficialExternalPluginCatalogProfileConfig;
|
||||
requireManifestInstallSourceRef?: boolean;
|
||||
}): Promise<HostedOfficialExternalPluginCatalogLoadResult> {
|
||||
if (params.snapshotStore) {
|
||||
try {
|
||||
@@ -475,6 +697,8 @@ async function snapshotOrBundledFallbackResult(params: {
|
||||
expectedSha256: params.expectedSha256,
|
||||
ifNoneMatch: params.ifNoneMatch,
|
||||
ifModifiedSince: params.ifModifiedSince,
|
||||
catalogConfig: params.catalogConfig,
|
||||
requireManifestInstallSourceRef: params.requireManifestInstallSourceRef,
|
||||
});
|
||||
}
|
||||
} catch (snapshotErr) {
|
||||
@@ -524,6 +748,8 @@ async function resolveHostedCatalogSnapshotStore(params: {
|
||||
|
||||
export async function loadHostedOfficialExternalPluginCatalogEntries(params?: {
|
||||
feedUrl?: string;
|
||||
feedProfile?: string;
|
||||
catalogConfig?: OfficialExternalPluginCatalogProfileConfig;
|
||||
fetchImpl?: FetchLike;
|
||||
timeoutMs?: number;
|
||||
maxBytes?: number;
|
||||
@@ -537,12 +763,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,
|
||||
@@ -580,7 +811,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;
|
||||
@@ -595,6 +826,11 @@ export async function loadHostedOfficialExternalPluginCatalogEntries(params?: {
|
||||
expectedSha256,
|
||||
ifNoneMatch,
|
||||
ifModifiedSince,
|
||||
catalogConfig: params?.catalogConfig,
|
||||
requireManifestInstallSourceRef: shouldRequireManifestInstallSourceRef({
|
||||
feedProfile: params?.feedProfile,
|
||||
catalogConfig: params?.catalogConfig,
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (!response.ok) {
|
||||
@@ -606,6 +842,11 @@ export async function loadHostedOfficialExternalPluginCatalogEntries(params?: {
|
||||
expectedSha256,
|
||||
ifNoneMatch,
|
||||
ifModifiedSince,
|
||||
catalogConfig: params?.catalogConfig,
|
||||
requireManifestInstallSourceRef: shouldRequireManifestInstallSourceRef({
|
||||
feedProfile: params?.feedProfile,
|
||||
catalogConfig: params?.catalogConfig,
|
||||
}),
|
||||
});
|
||||
}
|
||||
const body = await readHostedCatalogResponseText({
|
||||
@@ -625,6 +866,11 @@ export async function loadHostedOfficialExternalPluginCatalogEntries(params?: {
|
||||
expectedSha256,
|
||||
ifNoneMatch,
|
||||
ifModifiedSince,
|
||||
catalogConfig: params?.catalogConfig,
|
||||
requireManifestInstallSourceRef: shouldRequireManifestInstallSourceRef({
|
||||
feedProfile: params?.feedProfile,
|
||||
catalogConfig: params?.catalogConfig,
|
||||
}),
|
||||
});
|
||||
}
|
||||
const raw = JSON.parse(body) as unknown;
|
||||
@@ -637,8 +883,23 @@ export async function loadHostedOfficialExternalPluginCatalogEntries(params?: {
|
||||
expectedSha256,
|
||||
ifNoneMatch,
|
||||
ifModifiedSince,
|
||||
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,
|
||||
@@ -648,9 +909,7 @@ export async function loadHostedOfficialExternalPluginCatalogEntries(params?: {
|
||||
.catch(() => undefined);
|
||||
return {
|
||||
source: "hosted",
|
||||
entries: dedupeOfficialExternalPluginCatalogEntries(
|
||||
parseOfficialExternalPluginCatalogEntries(raw),
|
||||
),
|
||||
entries: dedupeOfficialExternalPluginCatalogEntries(entries),
|
||||
feed: raw,
|
||||
metadata,
|
||||
};
|
||||
@@ -662,6 +921,11 @@ export async function loadHostedOfficialExternalPluginCatalogEntries(params?: {
|
||||
expectedSha256,
|
||||
ifNoneMatch,
|
||||
ifModifiedSince,
|
||||
catalogConfig: params?.catalogConfig,
|
||||
requireManifestInstallSourceRef: shouldRequireManifestInstallSourceRef({
|
||||
feedProfile: params?.feedProfile,
|
||||
catalogConfig: params?.catalogConfig,
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
if (response?.bodyUsed !== true) {
|
||||
@@ -675,6 +939,100 @@ 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);
|
||||
if (sourceType === "clawhub") {
|
||||
const expectedIntegrity = normalizeClawHubSha256ExpectedIntegrity(candidate.integrity);
|
||||
return {
|
||||
clawhubSpec: `clawhub:${spec}`,
|
||||
defaultChoice: "clawhub",
|
||||
...(expectedIntegrity ? { expectedIntegrity } : {}),
|
||||
};
|
||||
}
|
||||
if (sourceType === "npm") {
|
||||
const expectedIntegrity = normalizeNpmExpectedIntegrity(candidate.integrity);
|
||||
return {
|
||||
npmSpec: spec,
|
||||
defaultChoice: "npm",
|
||||
...(expectedIntegrity ? { expectedIntegrity } : {}),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeClawHubSha256ExpectedIntegrity(value: unknown): string | undefined {
|
||||
const integrity = normalizeOptionalString(value);
|
||||
return integrity ? (normalizeClawHubSha256Integrity(integrity) ?? undefined) : undefined;
|
||||
}
|
||||
|
||||
function normalizeNpmExpectedIntegrity(value: unknown): string | undefined {
|
||||
const integrity = normalizeOptionalString(value);
|
||||
if (!integrity || !/^[a-z0-9]+-[A-Za-z0-9+/=]+$/i.test(integrity)) {
|
||||
return undefined;
|
||||
}
|
||||
return integrity;
|
||||
}
|
||||
|
||||
/** Returns manifest metadata from an official external catalog entry when present. */
|
||||
export function getOfficialExternalPluginCatalogManifest(
|
||||
entry: OfficialExternalPluginCatalogEntry,
|
||||
@@ -690,7 +1048,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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -719,6 +1078,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"
|
||||
@@ -727,18 +1087,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 = getFeedEntryInstallCandidateRecords(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