diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index cbf5cda76ece..718648fd8134 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -54,6 +54,9 @@ openclaw plugins update openclaw plugins update --all openclaw plugins marketplace list openclaw plugins marketplace list --json +openclaw plugins marketplace refresh +openclaw plugins marketplace refresh --feed-profile clawhub-public --json +openclaw plugins marketplace refresh --feed-url https://clawhub.ai/v1/feeds/plugins --expected-sha256 openclaw plugins init my-tool --name "My Tool" openclaw plugins init my-provider --name "My Provider" --type provider openclaw plugins init my-provider --name "My Provider" --type provider --directory ./my-provider @@ -521,10 +524,26 @@ Use `plugins registry` to inspect whether the persisted registry is present, cur ```bash openclaw plugins marketplace list openclaw plugins marketplace list --json +openclaw plugins marketplace refresh +openclaw plugins marketplace refresh --feed-profile +openclaw plugins marketplace refresh --feed-url +openclaw plugins marketplace refresh --expected-sha256 --json ``` Marketplace list accepts a local marketplace path, a `marketplace.json` path, a GitHub shorthand like `owner/repo`, a GitHub repo URL, or a git URL. `--json` prints the resolved source label plus the parsed marketplace manifest and plugin entries. +Marketplace refresh loads a hosted OpenClaw marketplace feed and persists the +validated response as the local hosted-feed snapshot. Without options, it uses +the configured default feed profile. Use `--feed-profile ` to refresh a +specific configured profile, `--feed-url ` to refresh an explicit hosted +feed URL, `--expected-sha256 ` to require a matching payload checksum +(`sha256:` or a bare 64-character hex digest), and `--json` for +machine-readable output. Explicit hosted feed URLs must not include +credentials, query strings, or fragments. Unpinned refreshes can report a +hosted snapshot or bundled fallback result without failing the command. Pinned +refreshes fail unless they accept a fresh hosted payload, and successful hosted +refreshes fail if OpenClaw cannot persist the validated snapshot. + ## Related - [Building plugins](/plugins/building-plugins) diff --git a/src/cli/plugins-cli.lazy.test.ts b/src/cli/plugins-cli.lazy.test.ts index c78deaba9fa4..deb8cfde6b7d 100644 --- a/src/cli/plugins-cli.lazy.test.ts +++ b/src/cli/plugins-cli.lazy.test.ts @@ -18,6 +18,7 @@ describe("plugins cli lazy runtime boundary", () => { runtimeLoaded(); return { runPluginMarketplaceListCommand: vi.fn(), + runPluginMarketplaceRefreshCommand: vi.fn(), runPluginsDisableCommand: vi.fn(), runPluginsDoctorCommand: vi.fn(), runPluginsEnableCommand: vi.fn(), @@ -50,6 +51,7 @@ describe("plugins cli lazy runtime boundary", () => { runtimeLoaded(); return { runPluginMarketplaceListCommand: vi.fn(), + runPluginMarketplaceRefreshCommand: vi.fn(), runPluginsDisableCommand: vi.fn(), runPluginsDoctorCommand: vi.fn(), runPluginsEnableCommand: vi.fn(), @@ -67,4 +69,39 @@ describe("plugins cli lazy runtime boundary", () => { expect(runtimeLoaded).toHaveBeenCalledTimes(1); expect(runPluginsRegistryCommand).toHaveBeenCalledWith(expect.objectContaining({ json: true })); }); + + it("loads the plugins runtime for marketplace refresh", async () => { + const runPluginMarketplaceRefreshCommand = vi.fn().mockResolvedValue(undefined); + vi.doMock("./plugins-cli.runtime.js", () => ({ + runPluginMarketplaceListCommand: vi.fn(), + runPluginMarketplaceRefreshCommand, + runPluginsDisableCommand: vi.fn(), + runPluginsDoctorCommand: vi.fn(), + runPluginsEnableCommand: vi.fn(), + runPluginsInstallAction: vi.fn(), + runPluginsRegistryCommand: vi.fn(), + })); + + const { registerPluginsCli } = await import("./plugins-cli.js"); + const program = new Command(); + registerPluginsCli(program); + + await program.parseAsync( + [ + "plugins", + "marketplace", + "refresh", + "--feed-profile", + "acme", + "--expected-sha256", + "abc123", + "--json", + ], + { from: "user" }, + ); + + expect(runPluginMarketplaceRefreshCommand).toHaveBeenCalledWith( + expect.objectContaining({ feedProfile: "acme", expectedSha256: "abc123", json: true }), + ); + }); }); diff --git a/src/cli/plugins-cli.marketplace-refresh.test.ts b/src/cli/plugins-cli.marketplace-refresh.test.ts new file mode 100644 index 000000000000..1dec9e11d813 --- /dev/null +++ b/src/cli/plugins-cli.marketplace-refresh.test.ts @@ -0,0 +1,243 @@ +// Covers the hosted OpenClaw marketplace feed refresh command. +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + const defaultRuntime = { + error: vi.fn(), + exit: vi.fn((code: number) => { + throw new Error(`exit ${code}`); + }), + log: vi.fn(), + writeJson: vi.fn(), + }; + return { + defaultRuntime, + getRuntimeConfig: vi.fn(), + loadConfiguredHostedOfficialExternalPluginCatalogEntries: vi.fn(), + }; +}); + +vi.mock("../config/config.js", () => ({ + assertConfigWriteAllowedInCurrentMode: vi.fn(), + getRuntimeConfig: mocks.getRuntimeConfig, + readConfigFileSnapshot: vi.fn(), + replaceConfigFile: vi.fn(), +})); + +vi.mock("../runtime.js", () => ({ + defaultRuntime: mocks.defaultRuntime, +})); + +vi.mock("../plugins/official-external-plugin-catalog.js", () => ({ + loadConfiguredHostedOfficialExternalPluginCatalogEntries: + mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries, +})); + +describe("plugins marketplace refresh", () => { + beforeEach(() => { + mocks.defaultRuntime.error.mockClear(); + mocks.defaultRuntime.exit.mockClear(); + mocks.defaultRuntime.log.mockClear(); + mocks.defaultRuntime.writeJson.mockClear(); + mocks.getRuntimeConfig.mockReset(); + mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockReset(); + }); + + it("refreshes the configured marketplace feed and prints JSON", async () => { + const config = { + marketplaces: { + feeds: { acme: { url: "https://packages.acme.example/openclaw/feed" } }, + }, + }; + mocks.getRuntimeConfig.mockReturnValue(config); + mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({ + source: "hosted", + entries: [{ name: "@acme/calendar" }, { name: "@acme/docs" }], + feed: { + schemaVersion: 1, + id: "acme-marketplace", + generatedAt: "2026-06-23T00:00:00.000Z", + sequence: 7, + entries: [], + }, + metadata: { + url: "https://packages.acme.example/openclaw/feed", + status: 200, + checksum: "feed-sha", + etag: '"abc"', + }, + }); + + const { runPluginMarketplaceRefreshCommand } = await import("./plugins-cli.runtime.js"); + await runPluginMarketplaceRefreshCommand({ + feedProfile: "acme", + expectedSha256: "feed-sha", + json: true, + }); + + expect(mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries).toHaveBeenCalledWith( + config, + { feedProfile: "acme", expectedSha256: "feed-sha", requireSnapshotWrite: true }, + ); + expect(mocks.defaultRuntime.writeJson).toHaveBeenCalledWith({ + source: "hosted", + entries: 2, + feed: { + id: "acme-marketplace", + generatedAt: "2026-06-23T00:00:00.000Z", + sequence: 7, + }, + metadata: { + url: "https://packages.acme.example/openclaw/feed", + status: 200, + checksum: "feed-sha", + etag: '"abc"', + }, + }); + }); + + it("normalizes bare SHA-256 pins before refreshing", async () => { + const config = { + marketplaces: { + feeds: { acme: { url: "https://packages.acme.example/openclaw/feed" } }, + }, + }; + mocks.getRuntimeConfig.mockReturnValue(config); + mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({ + source: "hosted", + entries: [{ name: "@acme/calendar" }], + feed: { + schemaVersion: 1, + id: "acme-marketplace", + generatedAt: "2026-06-23T00:00:00.000Z", + sequence: 7, + entries: [], + }, + metadata: { + url: "https://packages.acme.example/openclaw/feed", + status: 200, + checksum: "sha256:abcdef", + }, + }); + + const { runPluginMarketplaceRefreshCommand } = await import("./plugins-cli.runtime.js"); + await runPluginMarketplaceRefreshCommand({ + feedProfile: "acme", + expectedSha256: "ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789", + json: true, + }); + + expect(mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries).toHaveBeenCalledWith( + config, + { + feedProfile: "acme", + expectedSha256: "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + requireSnapshotWrite: true, + }, + ); + + mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockClear(); + + await runPluginMarketplaceRefreshCommand({ + feedProfile: "acme", + expectedSha256: "sha256:ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789", + json: true, + }); + + expect(mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries).toHaveBeenCalledWith( + config, + { + feedProfile: "acme", + expectedSha256: "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + requireSnapshotWrite: true, + }, + ); + }); + + it("reports bundled fallback without failing the command", async () => { + mocks.getRuntimeConfig.mockReturnValue({}); + mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({ + source: "bundled-fallback", + entries: [{ name: "@openclaw/acpx" }], + error: "hosted catalog feed returned HTTP 503", + metadata: { + url: "https://clawhub.ai/v1/feeds/plugins", + status: 503, + }, + }); + + const { runPluginMarketplaceRefreshCommand } = await import("./plugins-cli.runtime.js"); + await runPluginMarketplaceRefreshCommand({}); + + const output = mocks.defaultRuntime.log.mock.calls.map(([value]) => String(value)).join("\n"); + expect(output).toContain("bundled fallback"); + expect(output).toContain("hosted catalog feed returned HTTP 503"); + expect(mocks.defaultRuntime.exit).not.toHaveBeenCalled(); + }); + + it("redacts query-bearing feed URLs from refresh output", async () => { + mocks.getRuntimeConfig.mockReturnValue({}); + mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({ + source: "bundled-fallback", + entries: [{ name: "@openclaw/acpx" }], + error: + "hosted catalog feed fetch failed for https://clawhub.ai/v1/feeds/plugins?token=secret#frag", + metadata: { + url: "https://clawhub.ai/v1/feeds/plugins?token=secret#frag", + status: 503, + }, + }); + + const { runPluginMarketplaceRefreshCommand } = await import("./plugins-cli.runtime.js"); + await runPluginMarketplaceRefreshCommand({ + feedUrl: "https://clawhub.ai/v1/feeds/plugins?token=secret#frag", + json: true, + }); + + expect(mocks.defaultRuntime.writeJson).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ url: "https://clawhub.ai/v1/feeds/plugins" }), + error: "hosted catalog feed fetch failed for https://clawhub.ai/v1/feeds/plugins", + }), + ); + + mocks.defaultRuntime.writeJson.mockClear(); + mocks.defaultRuntime.log.mockClear(); + + await runPluginMarketplaceRefreshCommand({ + feedUrl: "https://clawhub.ai/v1/feeds/plugins?token=secret#frag", + }); + + const output = mocks.defaultRuntime.log.mock.calls.map(([value]) => String(value)).join("\n"); + expect(output).toContain("https://clawhub.ai/v1/feeds/plugins"); + expect(output).not.toContain("token=secret"); + expect(output).not.toContain("#frag"); + }); + + it("fails checksum-pinned refreshes that fall back", async () => { + mocks.getRuntimeConfig.mockReturnValue({}); + mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({ + source: "bundled-fallback", + entries: [{ name: "@openclaw/acpx" }], + error: "hosted catalog feed checksum mismatch: expected sha256:expected", + metadata: { + url: "https://clawhub.ai/v1/feeds/plugins", + status: 200, + checksum: "sha256:actual", + }, + }); + + const { runPluginMarketplaceRefreshCommand } = await import("./plugins-cli.runtime.js"); + await expect( + runPluginMarketplaceRefreshCommand({ expectedSha256: "sha256:expected", json: true }), + ).rejects.toThrow("exit 1"); + + expect(mocks.defaultRuntime.writeJson).toHaveBeenCalledWith( + expect.objectContaining({ source: "bundled-fallback" }), + ); + expect(mocks.defaultRuntime.error).toHaveBeenCalledWith( + "Pinned marketplace feed refresh did not accept a fresh hosted payload (source: bundled-fallback).", + ); + expect(mocks.defaultRuntime.exit).toHaveBeenCalledWith(1); + }); +}); diff --git a/src/cli/plugins-cli.runtime.ts b/src/cli/plugins-cli.runtime.ts index 0266a072e34b..7ffbe28ebefc 100644 --- a/src/cli/plugins-cli.runtime.ts +++ b/src/cli/plugins-cli.runtime.ts @@ -17,7 +17,11 @@ import { tracePluginLifecyclePhaseAsync } from "../plugins/plugin-lifecycle-trac import { defaultRuntime } from "../runtime.js"; import { shortenHomeInString } from "../utils.js"; import { formatMissingPluginMessage } from "./error-format.js"; -import type { PluginMarketplaceListOptions, PluginRegistryOptions } from "./plugins-cli.js"; +import type { + PluginMarketplaceListOptions, + PluginMarketplaceRefreshOptions, + PluginRegistryOptions, +} from "./plugins-cli.js"; type PluginInstallActionOptions = { acknowledgeClawHubRisk?: boolean; @@ -443,6 +447,200 @@ export async function runPluginsDoctorCommand(): Promise { defaultRuntime.log(lines.join("\n")); } +type MarketplaceRefreshPayload = { + source: "hosted" | "hosted-snapshot" | "bundled-fallback"; + entries: number; + feed?: { + id: string; + generatedAt: string; + sequence: number; + }; + metadata?: { + url: string; + status: number; + etag?: string; + lastModified?: string; + checksum?: string; + }; + snapshot?: { + savedAt: string; + }; + error?: string; +}; + +function buildMarketplaceRefreshPayload( + result: Awaited< + ReturnType< + typeof import("../plugins/official-external-plugin-catalog.js").loadConfiguredHostedOfficialExternalPluginCatalogEntries + > + >, +): MarketplaceRefreshPayload { + const payload: MarketplaceRefreshPayload = { + source: result.source, + entries: result.entries.length, + ...(result.metadata ? { metadata: result.metadata } : {}), + }; + if (result.source === "hosted" || result.source === "hosted-snapshot") { + payload.feed = { + id: result.feed.id, + generatedAt: result.feed.generatedAt, + sequence: result.feed.sequence, + }; + } + if (result.source === "hosted-snapshot") { + payload.snapshot = { savedAt: result.snapshot.savedAt }; + payload.error = result.error; + } + if (result.source === "bundled-fallback") { + payload.error = result.error; + } + return payload; +} + +function redactMarketplaceFeedUrl(value: string): string { + try { + const url = new URL(value); + url.username = ""; + url.password = ""; + url.search = ""; + url.hash = ""; + return url.href; + } catch { + return value; + } +} + +function replaceAllLiteral(value: string, search: string, replacement: string): string { + return search ? value.split(search).join(replacement) : value; +} + +function redactMarketplaceOutputText( + value: string, + rawUrls: readonly (string | undefined)[], +): string { + let redacted = value; + for (const rawUrl of rawUrls) { + if (!rawUrl) { + continue; + } + redacted = replaceAllLiteral(redacted, rawUrl, redactMarketplaceFeedUrl(rawUrl)); + } + return redacted; +} + +function sanitizeMarketplaceRefreshPayload( + payload: MarketplaceRefreshPayload, + params?: { feedUrl?: string }, +): MarketplaceRefreshPayload { + const rawMetadataUrl = payload.metadata?.url; + const sanitized: MarketplaceRefreshPayload = { + ...payload, + ...(payload.metadata + ? { metadata: { ...payload.metadata, url: redactMarketplaceFeedUrl(payload.metadata.url) } } + : {}), + }; + if (payload.error) { + sanitized.error = redactMarketplaceOutputText(payload.error, [params?.feedUrl, rawMetadataUrl]); + } + return sanitized; +} + +function formatMarketplaceRefreshSource(source: MarketplaceRefreshPayload["source"]): string { + if (source === "hosted") { + return theme.success("hosted"); + } + if (source === "hosted-snapshot") { + return theme.warn("hosted snapshot"); + } + return theme.warn("bundled fallback"); +} + +function shouldFailPinnedMarketplaceRefresh(params: { + expectedSha256?: string; + source: MarketplaceRefreshPayload["source"]; +}): boolean { + return Boolean(params.expectedSha256?.trim()) && params.source !== "hosted"; +} + +function normalizeMarketplaceExpectedSha256(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) { + return undefined; + } + if (/^[0-9a-f]{64}$/iu.test(trimmed)) { + return `sha256:${trimmed.toLowerCase()}`; + } + const prefixed = /^sha256:([0-9a-f]{64})$/iu.exec(trimmed); + if (prefixed?.[1]) { + return `sha256:${prefixed[1].toLowerCase()}`; + } + return trimmed; +} + +function formatPinnedMarketplaceRefreshFailure(payload: MarketplaceRefreshPayload): string { + return `Pinned marketplace feed refresh did not accept a fresh hosted payload (source: ${payload.source}).`; +} + +/** Refresh the configured OpenClaw marketplace feed snapshot. */ +export async function runPluginMarketplaceRefreshCommand( + opts: PluginMarketplaceRefreshOptions, +): Promise { + const { loadConfiguredHostedOfficialExternalPluginCatalogEntries } = + await import("../plugins/official-external-plugin-catalog.js"); + const cfg = getRuntimeConfig(); + const expectedSha256 = normalizeMarketplaceExpectedSha256(opts.expectedSha256); + const result = await loadConfiguredHostedOfficialExternalPluginCatalogEntries(cfg, { + ...(opts.feedProfile ? { feedProfile: opts.feedProfile } : {}), + ...(opts.feedUrl ? { feedUrl: opts.feedUrl } : {}), + ...(expectedSha256 ? { expectedSha256 } : {}), + requireSnapshotWrite: true, + }); + const payload = sanitizeMarketplaceRefreshPayload(buildMarketplaceRefreshPayload(result), { + feedUrl: opts.feedUrl, + }); + + const failedPinnedRefresh = shouldFailPinnedMarketplaceRefresh({ + expectedSha256, + source: payload.source, + }); + + if (opts.json) { + defaultRuntime.writeJson(payload); + if (failedPinnedRefresh) { + defaultRuntime.error(formatPinnedMarketplaceRefreshFailure(payload)); + return defaultRuntime.exit(1); + } + return; + } + + const lines = [ + `${theme.muted("Source:")} ${formatMarketplaceRefreshSource(payload.source)}`, + `${theme.muted("Entries:")} ${payload.entries}`, + ]; + if (payload.feed) { + lines.push( + `${theme.muted("Feed:")} ${payload.feed.id} ${theme.muted(`sequence ${payload.feed.sequence}`)}`, + ); + } + if (payload.metadata?.url) { + lines.push(`${theme.muted("URL:")} ${payload.metadata.url}`); + } + if (payload.metadata?.checksum) { + lines.push(`${theme.muted("SHA-256:")} ${payload.metadata.checksum}`); + } + if (payload.snapshot?.savedAt) { + lines.push(`${theme.muted("Snapshot:")} ${payload.snapshot.savedAt}`); + } + if (payload.error) { + lines.push(`${theme.muted("Fallback reason:")} ${payload.error}`); + } + defaultRuntime.log(lines.join("\n")); + if (failedPinnedRefresh) { + defaultRuntime.error(formatPinnedMarketplaceRefreshFailure(payload)); + return defaultRuntime.exit(1); + } +} + /** List plugins from a configured marketplace manifest. */ export async function runPluginMarketplaceListCommand( source: string, diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index b5f6d3b0cb08..5db79183ca32 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -26,6 +26,13 @@ export type PluginMarketplaceListOptions = { json?: boolean; }; +export type PluginMarketplaceRefreshOptions = { + expectedSha256?: string; + feedProfile?: string; + feedUrl?: string; + json?: boolean; +}; + export type PluginSearchOptions = { json?: boolean; limit?: number; @@ -276,6 +283,18 @@ export function registerPluginsCli(program: Command) { .command("marketplace") .description("Inspect Claude-compatible plugin marketplaces"); + marketplace + .command("refresh") + .description("Refresh the configured OpenClaw marketplace feed snapshot") + .option("--feed-profile ", "Configured marketplace feed profile to refresh") + .option("--feed-url ", "Explicit hosted marketplace feed URL") + .option("--expected-sha256 ", "Expected hosted feed SHA-256 payload checksum") + .option("--json", "Print JSON") + .action(async (opts: PluginMarketplaceRefreshOptions) => { + const { runPluginMarketplaceRefreshCommand } = await loadPluginsRuntime(); + await runPluginMarketplaceRefreshCommand(opts); + }); + marketplace .command("list") .description("List plugins published by a marketplace source") diff --git a/src/plugins/official-external-plugin-catalog.test.ts b/src/plugins/official-external-plugin-catalog.test.ts index 6ced31fac0b0..22f44cbf97ff 100644 --- a/src/plugins/official-external-plugin-catalog.test.ts +++ b/src/plugins/official-external-plugin-catalog.test.ts @@ -568,6 +568,43 @@ describe("official external plugin catalog", () => { } }); + it("rejects credential-bearing direct hosted feed URL overrides", async () => { + const fetchImpl = vi.fn(async () => new Response("{}", { status: 200 })); + + const result = await loadHostedOfficialExternalPluginCatalogEntries({ + feedUrl: "https://user:secret@clawhub.ai/v1/feeds/plugins", + fetchImpl, + snapshotStore: null, + }); + + expect(result.source).toBe("bundled-fallback"); + expect(fetchImpl).not.toHaveBeenCalled(); + if (result.source === "bundled-fallback") { + expect(result.error).toContain("must not include credentials"); + } + }); + + it("rejects query or fragment-bearing direct hosted feed URL overrides", async () => { + for (const feedUrl of [ + "https://clawhub.ai/v1/feeds/plugins?token=secret", + "https://clawhub.ai/v1/feeds/plugins#fragment", + ]) { + const fetchImpl = vi.fn(async () => new Response("{}", { status: 200 })); + + const result = await loadHostedOfficialExternalPluginCatalogEntries({ + feedUrl, + fetchImpl, + snapshotStore: null, + }); + + expect(result.source).toBe("bundled-fallback"); + expect(fetchImpl).not.toHaveBeenCalled(); + if (result.source === "bundled-fallback") { + expect(result.error).toContain("must not include query strings or fragments"); + } + } + }); + it("requires manifest install source refs when the default feed profile URL is overridden", async () => { const body = JSON.stringify({ schemaVersion: 1, @@ -612,6 +649,83 @@ describe("official external plugin catalog", () => { ]); }); + it("preserves default feed manifest installs for direct default hosted feed URL refreshes", async () => { + const body = JSON.stringify({ + schemaVersion: 1, + id: "openclaw-official-external-plugins", + generatedAt: "2026-06-22T00:00:00.000Z", + sequence: 14, + entries: [ + { + name: "@openclaw/direct-default-missing-source-ref", + kind: "plugin", + openclaw: { + plugin: { id: "direct-default-missing-source-ref" }, + install: { npmSpec: "@openclaw/direct-default-missing-source-ref" }, + }, + }, + ], + }); + + const result = await loadHostedOfficialExternalPluginCatalogEntries({ + feedUrl: "https://clawhub.ai/v1/feeds/plugins", + fetchImpl: vi.fn(async (url: RequestInfo | URL) => { + expect(expectRequestUrl(url)).toBe("https://clawhub.ai/v1/feeds/plugins"); + return new Response(body, { status: 200 }); + }), + snapshotStore: null, + }); + + expect(result.source).toBe("hosted"); + expect(result.entries.map((entry) => entry.name)).toEqual([ + "@openclaw/direct-default-missing-source-ref", + ]); + }); + + it("requires manifest install source refs for non-default direct hosted feed URL overrides", async () => { + const body = JSON.stringify({ + schemaVersion: 1, + id: "openclaw-official-external-plugins", + generatedAt: "2026-06-22T00:00:00.000Z", + sequence: 15, + entries: [ + { + name: "@acme/direct-url-missing-source-ref", + kind: "plugin", + openclaw: { + plugin: { id: "direct-url-missing-source-ref" }, + install: { npmSpec: "@acme/direct-url-missing-source-ref" }, + }, + }, + { + name: "@acme/direct-url-known-source-ref", + kind: "plugin", + openclaw: { + plugin: { id: "direct-url-known-source-ref" }, + install: { sourceRef: "acme-npm", npmSpec: "@acme/direct-url-known-source-ref" }, + }, + }, + ], + }); + + const result = await loadHostedOfficialExternalPluginCatalogEntries({ + feedUrl: "https://clawhub.ai/v1/feeds/acme", + catalogConfig: { + sources: { "acme-npm": { type: "npm", registry: "https://packages.acme.example/npm/" } }, + }, + fetchImpl: vi.fn(async (url: RequestInfo | URL) => { + expect(expectRequestUrl(url)).toBe("https://clawhub.ai/v1/feeds/acme"); + return new Response(body, { status: 200 }); + }), + snapshotStore: null, + }); + + expect(result.source).toBe("hosted"); + expect(result.entries.map((entry) => entry.name)).toEqual([ + "@acme/direct-url-known-source-ref", + ]); + }); + it("requires manifest install source refs for custom local feed profiles", async () => { const missingManifestSourceRef = { name: "@acme/missing-manifest-source-ref", @@ -621,6 +735,17 @@ describe("official external plugin catalog", () => { install: { npmSpec: "@acme/missing-manifest-source-ref" }, }, }; + const knownManifestSourceRef = { + name: "@acme/known-manifest-source-ref", + kind: "plugin", + openclaw: { + plugin: { id: "known-manifest-source-ref" }, + install: { + sourceRef: "acme-npm", + npmSpec: "@acme/known-manifest-source-ref", + }, + }, + }; const implicitNameInstall = { name: "@acme/implicit-name-install", kind: "plugin", @@ -637,14 +762,6 @@ describe("official external plugin catalog", () => { 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", @@ -858,6 +975,84 @@ describe("official external plugin catalog", () => { expect(snapshot?.metadata.checksum).toMatch(/^sha256:[0-9a-f]{64}$/); }); + it("fails explicit refreshes when required snapshot persistence fails", async () => { + const body = JSON.stringify({ + schemaVersion: 1, + id: "openclaw-official-external-plugins", + generatedAt: "2026-06-22T00:00:00.000Z", + sequence: 4, + entries: [ + { + name: "@openclaw/snapshot-write-fail-proof", + kind: "plugin", + openclaw: { plugin: { id: "snapshot-write-fail-proof" } }, + }, + ], + }); + const snapshotStore = { + read: vi.fn(async () => null), + write: vi.fn(async () => { + throw new Error("state database is read-only"); + }), + }; + + await expect( + loadHostedOfficialExternalPluginCatalogEntries({ + snapshotStore, + requireSnapshotWrite: true, + fetchImpl: vi.fn(async () => new Response(body, { status: 200 })), + }), + ).rejects.toThrow("state database is read-only"); + + expect(snapshotStore.write).toHaveBeenCalledTimes(1); + }); + + it("reads the latest accepted snapshot in offline mode without fetching", async () => { + const snapshotStore = createInMemoryHostedOfficialExternalPluginCatalogSnapshotStore(); + const body = JSON.stringify({ + schemaVersion: 1, + id: "openclaw-official-external-plugins", + generatedAt: "2026-06-22T00:00:00.000Z", + sequence: 5, + entries: [ + { + name: "@openclaw/offline-snapshot-proof", + kind: "plugin", + openclaw: { plugin: { id: "offline-snapshot-proof" } }, + }, + ], + }); + const seedFetch = vi.fn( + async () => + new Response(body, { + status: 200, + headers: { etag: '"offline"' }, + }), + ); + const seeded = await loadHostedOfficialExternalPluginCatalogEntries({ + snapshotStore, + fetchImpl: seedFetch, + }); + if (seeded.source !== "hosted") { + throw new Error("expected seeded hosted feed"); + } + + const offlineFetch = vi.fn(async () => new Response(null, { status: 500 })); + const result = await loadHostedOfficialExternalPluginCatalogEntries({ + snapshotStore, + fetchImpl: offlineFetch, + offline: true, + }); + + expect(offlineFetch).not.toHaveBeenCalled(); + expect(result.source).toBe("hosted-snapshot"); + expect(result.entries.map((entry) => entry.name)).toEqual(["@openclaw/offline-snapshot-proof"]); + if (result.source === "hosted-snapshot") { + expect(result.error).toBe("hosted catalog feed offline mode"); + expect(result.metadata.checksum).toBe(seeded.metadata.checksum); + } + }); + it("persists hosted feed snapshots in OpenClaw state for HTTP 304 reuse", async () => { const stateDir = mkdtempSync(path.join(os.tmpdir(), "openclaw-hosted-catalog-")); try { @@ -1356,7 +1551,10 @@ describe("official external plugin catalog", () => { }, { catalogConfig: { sources: { "acme-npm": { type: "npm" } } } }, ), - ).toEqual({ npmSpec: "@acme/private-package@4.5.6", defaultChoice: "npm" }); + ).toEqual({ + npmSpec: "@acme/private-package@4.5.6", + defaultChoice: "npm", + }); expect( resolveOfficialExternalPluginInstall( diff --git a/src/plugins/official-external-plugin-catalog.ts b/src/plugins/official-external-plugin-catalog.ts index e3245b98e16a..b815bb7be167 100644 --- a/src/plugins/official-external-plugin-catalog.ts +++ b/src/plugins/official-external-plugin-catalog.ts @@ -302,6 +302,12 @@ function resolveHostedCatalogFeedUrl(raw: string): URL { if (parsed.protocol !== "https:") { throw new Error("hosted catalog feed URL must use HTTPS"); } + if (parsed.username || parsed.password) { + throw new Error("hosted catalog feed URL must not include credentials"); + } + if (parsed.search || parsed.hash) { + throw new Error("hosted catalog feed URL must not include query strings or fragments"); + } return parsed; } @@ -385,9 +391,21 @@ function getFeedEntryInstallCandidates( } function shouldRequireManifestInstallSourceRef(params: { + feedUrl?: string; feedProfile?: string; catalogConfig?: OfficialExternalPluginCatalogProfileConfig; }): boolean { + const feedUrl = normalizeOptionalString(params.feedUrl); + if (feedUrl) { + try { + return ( + resolveHostedCatalogFeedUrl(feedUrl).href !== + resolveHostedCatalogFeedUrl(DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_URL).href + ); + } catch { + return true; + } + } const profileName = normalizeOptionalString(params.feedProfile) ?? DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_PROFILE; @@ -794,6 +812,7 @@ export async function loadHostedOfficialExternalPluginCatalogEntries(params?: { }); const expectedSha256 = normalizeOptionalString(params?.expectedSha256); const requireManifestInstallSourceRef = shouldRequireManifestInstallSourceRef({ + feedUrl: params?.feedUrl, feedProfile: params?.feedProfile, catalogConfig: params?.catalogConfig, });