






















1-import { describe, expect, it } from "vitest";
1+import { readFileSync } from "node:fs";
2+import { describe, expect, it, vi } from "vitest";
23import officialExternalPluginCatalog from "../../scripts/lib/official-external-plugin-catalog.json" with { type: "json" };
34import {
45type OfficialExternalPluginCatalogEntry,
56getOfficialExternalPluginCatalogEntry,
67isOfficialExternalPluginCatalogFeed,
78listOfficialExternalPluginCatalogEntries,
9+loadHostedOfficialExternalPluginCatalogEntries,
810parseOfficialExternalPluginCatalogEntries,
911resolveOfficialExternalProviderContractPluginIds,
1012resolveOfficialExternalProviderPluginIds,
@@ -23,6 +25,16 @@ function expectCatalogEntry(id: string): OfficialExternalPluginCatalogEntry {
2325}
24262527describe("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+2638it("ships the official plugin catalog as a feed-shaped bundled fallback", () => {
2739expect(isOfficialExternalPluginCatalogFeed(officialExternalPluginCatalog)).toBe(true);
2840expect(officialExternalPluginCatalog).toMatchObject({
@@ -45,7 +57,7 @@ describe("official external plugin catalog", () => {
4557).toBe(false);
4658expect(
4759isOfficialExternalPluginCatalogFeed({
48-schemaVersion: 2,
60+schemaVersion: 3,
4961id: "openclaw-official-external-plugins",
5062generatedAt: "2026-06-22T00:00:00.000Z",
5163sequence: 1,
@@ -54,10 +66,135 @@ describe("official external plugin catalog", () => {
5466).toBe(false);
5567});
566869+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+57194it("keeps unsupported versioned feed wrappers out of legacy catalog parsing", () => {
58195expect(
59196parseOfficialExternalPluginCatalogEntries({
60-schemaVersion: 2,
197+schemaVersion: 3,
61198id: "future-feed",
62199generatedAt: "2026-06-22T00:00:00.000Z",
63200sequence: 1,
@@ -71,6 +208,182 @@ describe("official external plugin catalog", () => {
71208).toEqual([{ name: "legacy-catalog-entry" }]);
72209});
73210211+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+74387it("lists the externalized provider and capability plugins with install metadata", () => {
75388const providers = [
76389["arcee", "@openclaw/arcee-provider"],
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。