Skip to content

Commit c29e1fe

Browse files
authored
Add hosted external catalog feed loader (#95868)
Merged via squash. Prepared head SHA: 76da932 Co-authored-by: giodl73-repo <[email protected]> Co-authored-by: giodl73-repo <[email protected]> Reviewed-by: @giodl73-repo
1 parent c52adf7 commit c29e1fe

2 files changed

Lines changed: 655 additions & 19 deletions

File tree

src/plugins/official-external-plugin-catalog.test.ts

Lines changed: 316 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
import { describe, expect, it } from "vitest";
1+
import { readFileSync } from "node:fs";
2+
import { describe, expect, it, vi } from "vitest";
23
import officialExternalPluginCatalog from "../../scripts/lib/official-external-plugin-catalog.json" with { type: "json" };
34
import {
45
type OfficialExternalPluginCatalogEntry,
56
getOfficialExternalPluginCatalogEntry,
67
isOfficialExternalPluginCatalogFeed,
78
listOfficialExternalPluginCatalogEntries,
9+
loadHostedOfficialExternalPluginCatalogEntries,
810
parseOfficialExternalPluginCatalogEntries,
911
resolveOfficialExternalProviderContractPluginIds,
1012
resolveOfficialExternalProviderPluginIds,
@@ -23,6 +25,16 @@ function expectCatalogEntry(id: string): OfficialExternalPluginCatalogEntry {
2325
}
2426

2527
describe("official external plugin catalog", () => {
28+
it("keeps hosted fetch guard loading lazy for bundled catalog import paths", () => {
29+
const source = readFileSync(
30+
new URL("./official-external-plugin-catalog.ts", import.meta.url),
31+
"utf8",
32+
);
33+
34+
expect(source).not.toMatch(/from ["']\.\.\/infra\/net\/fetch-guard\.js["']/);
35+
expect(source).toContain('await import("../infra/net/fetch-guard.js")');
36+
});
37+
2638
it("ships the official plugin catalog as a feed-shaped bundled fallback", () => {
2739
expect(isOfficialExternalPluginCatalogFeed(officialExternalPluginCatalog)).toBe(true);
2840
expect(officialExternalPluginCatalog).toMatchObject({
@@ -45,7 +57,7 @@ describe("official external plugin catalog", () => {
4557
).toBe(false);
4658
expect(
4759
isOfficialExternalPluginCatalogFeed({
48-
schemaVersion: 2,
60+
schemaVersion: 3,
4961
id: "openclaw-official-external-plugins",
5062
generatedAt: "2026-06-22T00:00:00.000Z",
5163
sequence: 1,
@@ -54,10 +66,135 @@ describe("official external plugin catalog", () => {
5466
).toBe(false);
5567
});
5668

69+
it("accepts the live ClawHub feed schema version", () => {
70+
expect(
71+
isOfficialExternalPluginCatalogFeed({
72+
schemaVersion: 2,
73+
id: "clawhub-official",
74+
generatedAt: "2026-06-25T01:19:39.629Z",
75+
sequence: 11,
76+
entries: [],
77+
}),
78+
).toBe(true);
79+
});
80+
81+
it("keeps live ClawHub marketplace entries as metadata-only feed entries", () => {
82+
const [entry] = parseOfficialExternalPluginCatalogEntries({
83+
schemaVersion: 2,
84+
id: "clawhub-official",
85+
generatedAt: "2026-06-25T01:19:39.629Z",
86+
sequence: 11,
87+
entries: [
88+
{
89+
type: "plugin",
90+
id: "@expediagroup/expedia-openclaw",
91+
title: "Expedia Travel",
92+
version: "1.0.4",
93+
state: "available",
94+
publisher: {
95+
id: "expediagroup",
96+
trust: "official",
97+
},
98+
install: {
99+
candidates: [
100+
{
101+
sourceRef: "public-clawhub",
102+
package: "@expediagroup/expedia-openclaw",
103+
version: "1.0.4",
104+
integrity:
105+
"sha256:b355dda04403becaab8bbab069fd1e7b0578262e7459e598cc5b19615b5bdab9",
106+
},
107+
],
108+
},
109+
},
110+
],
111+
});
112+
113+
if (entry === undefined) {
114+
throw new Error("Expected hosted ClawHub feed entry to parse");
115+
}
116+
117+
expect(entry).toMatchObject({
118+
id: "@expediagroup/expedia-openclaw",
119+
title: "Expedia Travel",
120+
version: "1.0.4",
121+
});
122+
expect(resolveOfficialExternalPluginId(entry)).toBeUndefined();
123+
expect(resolveOfficialExternalPluginInstall(entry)).toBeNull();
124+
});
125+
126+
it("does not synthesize trusted installs for unavailable or untrusted hosted entries", () => {
127+
const entries = parseOfficialExternalPluginCatalogEntries({
128+
schemaVersion: 2,
129+
id: "clawhub-official",
130+
generatedAt: "2026-06-25T01:19:39.629Z",
131+
sequence: 11,
132+
entries: [
133+
{
134+
type: "plugin",
135+
id: "@example/unavailable",
136+
title: "Unavailable",
137+
version: "1.0.0",
138+
state: "disabled",
139+
publisher: { id: "example", trust: "official" },
140+
install: {
141+
candidates: [
142+
{
143+
sourceRef: "public-clawhub",
144+
package: "@example/unavailable",
145+
version: "1.0.0",
146+
},
147+
],
148+
},
149+
},
150+
{
151+
type: "plugin",
152+
id: "@example/community",
153+
title: "Community",
154+
version: "1.0.0",
155+
state: "available",
156+
publisher: { id: "example", trust: "community" },
157+
install: {
158+
candidates: [
159+
{
160+
sourceRef: "public-clawhub",
161+
package: "@example/community",
162+
version: "1.0.0",
163+
},
164+
],
165+
},
166+
},
167+
{
168+
type: "plugin",
169+
id: "@example/private-source",
170+
title: "Private Source",
171+
version: "1.0.0",
172+
state: "available",
173+
publisher: { id: "example", trust: "official" },
174+
install: {
175+
candidates: [
176+
{
177+
sourceRef: "private-feed",
178+
package: "@example/private-source",
179+
version: "1.0.0",
180+
},
181+
],
182+
},
183+
},
184+
],
185+
});
186+
187+
expect(entries).toHaveLength(3);
188+
for (const entry of entries) {
189+
expect(resolveOfficialExternalPluginId(entry)).toBeUndefined();
190+
expect(resolveOfficialExternalPluginInstall(entry)).toBeNull();
191+
}
192+
});
193+
57194
it("keeps unsupported versioned feed wrappers out of legacy catalog parsing", () => {
58195
expect(
59196
parseOfficialExternalPluginCatalogEntries({
60-
schemaVersion: 2,
197+
schemaVersion: 3,
61198
id: "future-feed",
62199
generatedAt: "2026-06-22T00:00:00.000Z",
63200
sequence: 1,
@@ -71,6 +208,182 @@ describe("official external plugin catalog", () => {
71208
).toEqual([{ name: "legacy-catalog-entry" }]);
72209
});
73210

211+
it("loads a hosted feed with conditional headers and checksum metadata", async () => {
212+
const body = JSON.stringify({
213+
schemaVersion: 1,
214+
id: "openclaw-official-external-plugins",
215+
generatedAt: "2026-06-22T00:00:00.000Z",
216+
sequence: 2,
217+
entries: [
218+
{
219+
name: "@openclaw/hosted-proof",
220+
kind: "plugin",
221+
openclaw: {
222+
plugin: { id: "hosted-proof", label: "Hosted Proof" },
223+
install: { npmSpec: "@openclaw/hosted-proof", defaultChoice: "npm" },
224+
},
225+
},
226+
],
227+
});
228+
const fetchImpl = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => {
229+
const headers = new Headers(init?.headers);
230+
expect(headers.get("if-none-match")).toBe('"old"');
231+
expect(headers.get("if-modified-since")).toBe("Mon, 22 Jun 2026 00:00:00 GMT");
232+
return new Response(body, {
233+
status: 200,
234+
headers: {
235+
etag: '"next"',
236+
"last-modified": "Mon, 22 Jun 2026 01:00:00 GMT",
237+
"content-length": String(new TextEncoder().encode(body).byteLength),
238+
},
239+
});
240+
});
241+
242+
const result = await loadHostedOfficialExternalPluginCatalogEntries({
243+
fetchImpl,
244+
ifNoneMatch: '"old"',
245+
ifModifiedSince: "Mon, 22 Jun 2026 00:00:00 GMT",
246+
});
247+
248+
expect(result.source).toBe("hosted");
249+
expect(result.entries.map((entry) => entry.name)).toEqual(["@openclaw/hosted-proof"]);
250+
if (result.source === "hosted") {
251+
expect(result.feed.sequence).toBe(2);
252+
expect(result.metadata).toMatchObject({
253+
status: 200,
254+
etag: '"next"',
255+
lastModified: "Mon, 22 Jun 2026 01:00:00 GMT",
256+
});
257+
expect(result.metadata.checksum).toMatch(/^sha256:[0-9a-f]{64}$/);
258+
}
259+
});
260+
261+
it("keeps live ClawHub metadata-only entries after hosted feed loading", async () => {
262+
const body = JSON.stringify({
263+
schemaVersion: 2,
264+
id: "clawhub-official",
265+
generatedAt: "2026-06-25T01:19:39.629Z",
266+
sequence: 11,
267+
entries: [
268+
{
269+
type: "plugin",
270+
id: "@expediagroup/expedia-openclaw",
271+
title: "Expedia Travel",
272+
version: "1.0.4",
273+
state: "available",
274+
publisher: {
275+
id: "expediagroup",
276+
trust: "official",
277+
},
278+
install: {
279+
candidates: [
280+
{
281+
sourceRef: "public-clawhub",
282+
package: "@expediagroup/expedia-openclaw",
283+
version: "1.0.4",
284+
integrity:
285+
"sha256:b355dda04403becaab8bbab069fd1e7b0578262e7459e598cc5b19615b5bdab9",
286+
},
287+
],
288+
},
289+
},
290+
],
291+
});
292+
293+
const result = await loadHostedOfficialExternalPluginCatalogEntries({
294+
fetchImpl: vi.fn(
295+
async () =>
296+
new Response(body, {
297+
status: 200,
298+
headers: {
299+
"content-length": String(new TextEncoder().encode(body).byteLength),
300+
},
301+
}),
302+
),
303+
});
304+
305+
expect(result.source).toBe("hosted");
306+
expect(result.entries).toHaveLength(1);
307+
expect(result.entries[0]).toMatchObject({
308+
id: "@expediagroup/expedia-openclaw",
309+
title: "Expedia Travel",
310+
version: "1.0.4",
311+
});
312+
});
313+
314+
it("falls back to the bundled catalog when hosted feed validation fails", async () => {
315+
const result = await loadHostedOfficialExternalPluginCatalogEntries({
316+
fetchImpl: vi.fn(
317+
async () =>
318+
new Response(JSON.stringify({ schemaVersion: 1, id: " ", entries: [] }), {
319+
status: 200,
320+
}),
321+
),
322+
});
323+
324+
expect(result.source).toBe("bundled-fallback");
325+
expect(result.entries.length).toBe(listOfficialExternalPluginCatalogEntries().length);
326+
if (result.source === "bundled-fallback") {
327+
expect(result.error).toContain("supported schema version");
328+
expect(result.metadata?.checksum).toMatch(/^sha256:[0-9a-f]{64}$/);
329+
}
330+
});
331+
332+
it("falls back to the bundled catalog on HTTP 304 until a snapshot cache exists", async () => {
333+
const result = await loadHostedOfficialExternalPluginCatalogEntries({
334+
fetchImpl: vi.fn(
335+
async () =>
336+
new Response(null, {
337+
status: 304,
338+
headers: { etag: '"same"', "last-modified": "Mon, 22 Jun 2026 01:00:00 GMT" },
339+
}),
340+
),
341+
});
342+
343+
expect(result.source).toBe("bundled-fallback");
344+
if (result.source === "bundled-fallback") {
345+
expect(result.error).toContain("without a cached snapshot");
346+
expect(result.metadata).toMatchObject({
347+
status: 304,
348+
etag: '"same"',
349+
lastModified: "Mon, 22 Jun 2026 01:00:00 GMT",
350+
});
351+
}
352+
});
353+
354+
it("falls back to the bundled catalog on checksum mismatch and oversized bodies", async () => {
355+
const mismatch = await loadHostedOfficialExternalPluginCatalogEntries({
356+
expectedSha256: "sha256:not-current",
357+
fetchImpl: vi.fn(
358+
async () =>
359+
new Response(
360+
JSON.stringify({
361+
schemaVersion: 1,
362+
id: "openclaw-official-external-plugins",
363+
generatedAt: "2026-06-22T00:00:00.000Z",
364+
sequence: 1,
365+
entries: [],
366+
}),
367+
{ status: 200 },
368+
),
369+
),
370+
});
371+
expect(mismatch.source).toBe("bundled-fallback");
372+
if (mismatch.source === "bundled-fallback") {
373+
expect(mismatch.error).toContain("checksum mismatch");
374+
expect(mismatch.metadata?.checksum).toMatch(/^sha256:[0-9a-f]{64}$/);
375+
}
376+
377+
const oversized = await loadHostedOfficialExternalPluginCatalogEntries({
378+
maxBytes: 4,
379+
fetchImpl: vi.fn(async () => new Response("12345", { status: 200 })),
380+
});
381+
expect(oversized.source).toBe("bundled-fallback");
382+
if (oversized.source === "bundled-fallback") {
383+
expect(oversized.error).toContain("exceeds 4 bytes");
384+
}
385+
});
386+
74387
it("lists the externalized provider and capability plugins with install metadata", () => {
75388
const providers = [
76389
["arcee", "@openclaw/arcee-provider"],

0 commit comments

Comments
 (0)