Add marketplace feed refresh command (#96155)

Merged via squash.

Prepared head SHA: 1614070e92
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:
Gio Della-Libera
2026-06-28 10:29:15 -07:00
committed by GitHub
parent 891096926e
commit 102a65477e
7 changed files with 743 additions and 10 deletions
+19
View File
@@ -54,6 +54,9 @@ openclaw plugins update <id-or-npm-spec>
openclaw plugins update --all
openclaw plugins marketplace list <marketplace>
openclaw plugins marketplace list <marketplace> --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 <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 <source>
openclaw plugins marketplace list <source> --json
openclaw plugins marketplace refresh
openclaw plugins marketplace refresh --feed-profile <name>
openclaw plugins marketplace refresh --feed-url <url>
openclaw plugins marketplace refresh --expected-sha256 <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 <name>` to refresh a
specific configured profile, `--feed-url <url>` to refresh an explicit hosted
feed URL, `--expected-sha256 <sha256>` to require a matching payload checksum
(`sha256:<hex>` 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)
+37
View File
@@ -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 }),
);
});
});
@@ -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);
});
});
+199 -1
View File
@@ -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<void> {
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<void> {
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,
+19
View File
@@ -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 <name>", "Configured marketplace feed profile to refresh")
.option("--feed-url <url>", "Explicit hosted marketplace feed URL")
.option("--expected-sha256 <hash>", "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")
@@ -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(
@@ -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,
});