mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:00:42 +00:00
fix(plugins): gate bare clawhub installs on readiness
This commit is contained in:
@@ -18,10 +18,10 @@ vi.mock("../../cli/npm-resolution.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../../cli/plugins-command-helpers.js", () => ({
|
||||
buildPreferredClawHubSpec: vi.fn(() => null),
|
||||
createPluginInstallLogger: vi.fn(() => ({})),
|
||||
decidePreferredClawHubFallback: vi.fn(() => "fallback_to_npm"),
|
||||
resolveFileNpmSpecToLocalPath: vi.fn(() => null),
|
||||
resolvePreferredClawHubSpec: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("../../cli/plugins-install-persist.js", () => ({
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import fs from "node:fs";
|
||||
import { buildNpmInstallRecordFields } from "../../cli/npm-resolution.js";
|
||||
import {
|
||||
buildPreferredClawHubSpec,
|
||||
createPluginInstallLogger,
|
||||
decidePreferredClawHubFallback,
|
||||
resolveFileNpmSpecToLocalPath,
|
||||
resolvePreferredClawHubSpec,
|
||||
} from "../../cli/plugins-command-helpers.js";
|
||||
import { persistPluginInstall } from "../../cli/plugins-install-persist.js";
|
||||
import type { ConfigSnapshotForInstallPersist } from "../../cli/plugins-install-persist.js";
|
||||
@@ -256,7 +256,7 @@ async function installPluginFromPluginsCommand(params: {
|
||||
return { ok: true, pluginId: result.pluginId };
|
||||
}
|
||||
|
||||
const preferredClawHubSpec = buildPreferredClawHubSpec(params.raw);
|
||||
const preferredClawHubSpec = await resolvePreferredClawHubSpec(params.raw);
|
||||
if (preferredClawHubSpec) {
|
||||
const clawhubResult = await installPluginFromClawHub({
|
||||
spec: preferredClawHubSpec,
|
||||
|
||||
@@ -11,6 +11,8 @@ type UnknownMock = Mock<(...args: unknown[]) => unknown>;
|
||||
type AsyncUnknownMock = Mock<(...args: unknown[]) => Promise<unknown>>;
|
||||
type LoadConfigFn = (typeof import("../config/config.js"))["loadConfig"];
|
||||
type ParseClawHubPluginSpecFn = (typeof import("../infra/clawhub.js"))["parseClawHubPluginSpec"];
|
||||
type FetchClawHubPackageReadinessFn =
|
||||
(typeof import("../infra/clawhub.js"))["fetchClawHubPackageReadiness"];
|
||||
type InstallPluginFromMarketplaceFn =
|
||||
(typeof import("../plugins/marketplace.js"))["installPluginFromMarketplace"];
|
||||
type InstallPluginFromGitSpecFn =
|
||||
@@ -78,6 +80,7 @@ export const installPluginFromNpmSpec: AsyncUnknownMock = vi.fn();
|
||||
export const installPluginFromPath: AsyncUnknownMock = vi.fn();
|
||||
export const installPluginFromClawHub: AsyncUnknownMock = vi.fn();
|
||||
export const parseClawHubPluginSpec: Mock<ParseClawHubPluginSpecFn> = vi.fn();
|
||||
export const fetchClawHubPackageReadiness: Mock<FetchClawHubPackageReadinessFn> = vi.fn();
|
||||
export const installHooksFromNpmSpec: AsyncUnknownMock = vi.fn();
|
||||
export const installHooksFromPath: AsyncUnknownMock = vi.fn();
|
||||
export const recordHookInstall: UnknownMock = vi.fn();
|
||||
@@ -560,6 +563,16 @@ vi.mock("../plugins/clawhub.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../infra/clawhub.js", () => ({
|
||||
fetchClawHubPackageReadiness: ((
|
||||
...args: Parameters<(typeof import("../infra/clawhub.js"))["fetchClawHubPackageReadiness"]>
|
||||
) =>
|
||||
invokeMock<
|
||||
Parameters<(typeof import("../infra/clawhub.js"))["fetchClawHubPackageReadiness"]>,
|
||||
ReturnType<(typeof import("../infra/clawhub.js"))["fetchClawHubPackageReadiness"]>
|
||||
>(
|
||||
fetchClawHubPackageReadiness,
|
||||
...args,
|
||||
)) as (typeof import("../infra/clawhub.js"))["fetchClawHubPackageReadiness"],
|
||||
parseClawHubPluginSpec: ((
|
||||
...args: Parameters<(typeof import("../infra/clawhub.js"))["parseClawHubPluginSpec"]>
|
||||
) =>
|
||||
@@ -621,6 +634,7 @@ export function resetPluginsCliTestState() {
|
||||
installPluginFromPath.mockReset();
|
||||
installPluginFromClawHub.mockReset();
|
||||
parseClawHubPluginSpec.mockReset();
|
||||
fetchClawHubPackageReadiness.mockReset();
|
||||
installHooksFromNpmSpec.mockReset();
|
||||
installHooksFromPath.mockReset();
|
||||
recordHookInstall.mockReset();
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
applyExclusiveSlotSelection,
|
||||
buildPluginSnapshotReport,
|
||||
enablePluginInConfig,
|
||||
fetchClawHubPackageReadiness,
|
||||
installHooksFromNpmSpec,
|
||||
installHooksFromPath,
|
||||
installPluginFromClawHub,
|
||||
@@ -660,6 +661,7 @@ describe("plugins cli install", () => {
|
||||
} as OpenClawConfig;
|
||||
const enabledCfg = createEnabledPluginConfig("demo");
|
||||
loadConfig.mockReturnValue(cfg);
|
||||
fetchClawHubPackageReadiness.mockResolvedValue({ readyForOpenClaw: true });
|
||||
installPluginFromClawHub.mockResolvedValue(
|
||||
createClawHubInstallResult({
|
||||
pluginId: "demo",
|
||||
@@ -702,6 +704,7 @@ describe("plugins cli install", () => {
|
||||
} as OpenClawConfig;
|
||||
const enabledCfg = createEnabledPluginConfig("demo");
|
||||
loadConfig.mockReturnValue(cfg);
|
||||
fetchClawHubPackageReadiness.mockResolvedValue({ phase: "legacy-zip-only" });
|
||||
installPluginFromClawHub.mockResolvedValue(
|
||||
createClawHubInstallResult({
|
||||
pluginId: "demo",
|
||||
@@ -735,6 +738,7 @@ describe("plugins cli install", () => {
|
||||
|
||||
it("falls back to npm when ClawHub does not have the package", async () => {
|
||||
primeNpmPluginFallback();
|
||||
fetchClawHubPackageReadiness.mockResolvedValue({ phase: "ready-for-openclaw" });
|
||||
|
||||
await runPluginsCommand(["plugins", "install", "demo"]);
|
||||
|
||||
@@ -750,6 +754,29 @@ describe("plugins cli install", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves npm install behavior for bare specs until ClawHub readiness is available", async () => {
|
||||
const cfg = createEmptyPluginConfig();
|
||||
const enabledCfg = createEnabledPluginConfig("demo");
|
||||
loadConfig.mockReturnValue(cfg);
|
||||
fetchClawHubPackageReadiness.mockRejectedValue(new Error("not deployed"));
|
||||
installPluginFromNpmSpec.mockResolvedValue(createNpmPluginInstallResult("demo"));
|
||||
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
|
||||
applyExclusiveSlotSelection.mockReturnValue({
|
||||
config: enabledCfg,
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
await runPluginsCommand(["plugins", "install", "demo"]);
|
||||
|
||||
expect(fetchClawHubPackageReadiness).toHaveBeenCalledWith({ name: "demo" });
|
||||
expect(installPluginFromClawHub).not.toHaveBeenCalled();
|
||||
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
spec: "demo",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("installs directly from npm when npm: prefix is used", async () => {
|
||||
const cfg = createEmptyPluginConfig();
|
||||
const enabledCfg = createEnabledPluginConfig("demo");
|
||||
@@ -1511,6 +1538,7 @@ describe("plugins cli install", () => {
|
||||
});
|
||||
|
||||
it("does not fall back to npm when ClawHub rejects a real package", async () => {
|
||||
fetchClawHubPackageReadiness.mockResolvedValue({ phase: "ready-for-openclaw" });
|
||||
installPluginFromClawHub.mockResolvedValue({
|
||||
ok: false,
|
||||
error: 'Use "openclaw skills install demo" instead.',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { fetchClawHubPackageReadiness, type ClawHubPackageReadiness } from "../infra/clawhub.js";
|
||||
import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js";
|
||||
import { CLAWHUB_INSTALL_ERROR_CODE } from "../plugins/clawhub.js";
|
||||
import type { PluginKind } from "../plugins/plugin-kind.types.js";
|
||||
@@ -211,6 +212,45 @@ export function buildPreferredClawHubSpec(raw: string): string | null {
|
||||
return `clawhub:${parsed.name}${parsed.selector ? `@${parsed.selector}` : ""}`;
|
||||
}
|
||||
|
||||
function normalizeReadinessPhase(readiness: ClawHubPackageReadiness): string {
|
||||
return normalizeLowercaseStringOrEmpty(String(readiness.phase ?? readiness.status ?? ""));
|
||||
}
|
||||
|
||||
export function isClawHubReadinessInstallReady(
|
||||
readiness: ClawHubPackageReadiness | null | undefined,
|
||||
): boolean {
|
||||
if (!readiness) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
readiness.ready === true ||
|
||||
readiness.readyForOpenClaw === true ||
|
||||
readiness.installReady === true
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const phase = normalizeReadinessPhase(readiness);
|
||||
return (
|
||||
phase === "ready-for-openclaw" || phase === "clawpack-ready" || phase === "legacy-zip-only"
|
||||
);
|
||||
}
|
||||
|
||||
export async function resolvePreferredClawHubSpec(raw: string): Promise<string | null> {
|
||||
const parsed = parseRegistryNpmSpec(raw);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const readiness = await fetchClawHubPackageReadiness({ name: parsed.name });
|
||||
if (!isClawHubReadinessInstallReady(readiness)) {
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return `clawhub:${parsed.name}${parsed.selector ? `@${parsed.selector}` : ""}`;
|
||||
}
|
||||
|
||||
export function parseNpmPrefixSpec(raw: string): string | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!normalizeLowercaseStringOrEmpty(trimmed).startsWith("npm:")) {
|
||||
|
||||
@@ -38,12 +38,12 @@ import {
|
||||
resolveBundledInstallPlanForNpmFailure,
|
||||
} from "./plugin-install-plan.js";
|
||||
import {
|
||||
buildPreferredClawHubSpec,
|
||||
createHookPackInstallLogger,
|
||||
createPluginInstallLogger,
|
||||
decidePreferredClawHubFallback,
|
||||
formatPluginInstallWithHookFallbackError,
|
||||
parseNpmPrefixSpec,
|
||||
resolvePreferredClawHubSpec,
|
||||
} from "./plugins-command-helpers.js";
|
||||
import { persistHookPackInstall, persistPluginInstall } from "./plugins-install-persist.js";
|
||||
import type { ConfigSnapshotForInstallPersist } from "./plugins-install-persist.js";
|
||||
@@ -776,7 +776,7 @@ export async function runPluginInstallCommand(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
const preferredClawHubSpec = buildPreferredClawHubSpec(raw);
|
||||
const preferredClawHubSpec = await resolvePreferredClawHubSpec(raw);
|
||||
if (preferredClawHubSpec) {
|
||||
const clawhubResult = await installPluginFromClawHub({
|
||||
...safetyOverrides,
|
||||
|
||||
@@ -7,6 +7,8 @@ import { withTempDir } from "../test-helpers/temp-dir.js";
|
||||
import {
|
||||
downloadClawHubPackageArchive,
|
||||
downloadClawHubSkillArchive,
|
||||
fetchClawHubPackageArtifact,
|
||||
fetchClawHubPackageReadiness,
|
||||
normalizeClawHubSha256Integrity,
|
||||
normalizeClawHubSha256Hex,
|
||||
parseClawHubPluginSpec,
|
||||
@@ -222,6 +224,74 @@ describe("clawhub helpers", () => {
|
||||
|
||||
await expect(searchClawHubSkills({ query: "calendar", fetchImpl })).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("fetches typed package readiness reports", async () => {
|
||||
let requestedUrl = "";
|
||||
await expect(
|
||||
fetchClawHubPackageReadiness({
|
||||
name: "@openclaw/diagnostics-otel",
|
||||
fetchImpl: async (input) => {
|
||||
requestedUrl = input instanceof Request ? input.url : String(input);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
package: { name: "@openclaw/diagnostics-otel", isOfficial: true },
|
||||
phase: "legacy-zip-only",
|
||||
blockers: [],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
package: { name: "@openclaw/diagnostics-otel", isOfficial: true },
|
||||
phase: "legacy-zip-only",
|
||||
blockers: [],
|
||||
});
|
||||
expect(new URL(requestedUrl).pathname).toBe(
|
||||
"/api/v1/packages/%40openclaw%2Fdiagnostics-otel/readiness",
|
||||
);
|
||||
});
|
||||
|
||||
it("fetches typed package artifact resolver reports", async () => {
|
||||
let requestedUrl = "";
|
||||
await expect(
|
||||
fetchClawHubPackageArtifact({
|
||||
name: "@openclaw/diagnostics-otel",
|
||||
version: "2026.3.22",
|
||||
fetchImpl: async (input) => {
|
||||
requestedUrl = input instanceof Request ? input.url : String(input);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
artifact: {
|
||||
source: "clawhub",
|
||||
artifactKind: "npm-pack",
|
||||
packageName: "@openclaw/diagnostics-otel",
|
||||
version: "2026.3.22",
|
||||
downloadUrl: "https://clawhub.ai/api/v1/clawpacks/abc",
|
||||
npmIntegrity: "sha512-demo",
|
||||
npmShasum: "abc",
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
artifact: {
|
||||
source: "clawhub",
|
||||
artifactKind: "npm-pack",
|
||||
packageName: "@openclaw/diagnostics-otel",
|
||||
version: "2026.3.22",
|
||||
downloadUrl: "https://clawhub.ai/api/v1/clawpacks/abc",
|
||||
npmIntegrity: "sha512-demo",
|
||||
npmShasum: "abc",
|
||||
},
|
||||
});
|
||||
expect(new URL(requestedUrl).pathname).toBe(
|
||||
"/api/v1/packages/%40openclaw%2Fdiagnostics-otel/versions/2026.3.22/artifact",
|
||||
);
|
||||
});
|
||||
|
||||
it("downloads package archives to sanitized temp paths and cleans them up", async () => {
|
||||
const archive = await downloadClawHubPackageArchive({
|
||||
name: "@hyf/zai-external-alpha",
|
||||
|
||||
@@ -53,6 +53,43 @@ export type ClawHubPackageArtifactSummary = {
|
||||
tarballUrl?: string | null;
|
||||
legacyDownloadUrl?: string | null;
|
||||
};
|
||||
export type ClawHubArtifactKind = "legacy-zip" | "npm-pack";
|
||||
export type ClawHubArtifactScanState =
|
||||
| "pending"
|
||||
| "clean"
|
||||
| "suspicious"
|
||||
| "malicious"
|
||||
| "not-run"
|
||||
| string;
|
||||
export type ClawHubArtifactModerationState = "approved" | "quarantined" | "revoked" | string;
|
||||
export type ClawHubResolvedArtifact =
|
||||
| {
|
||||
source: "clawhub";
|
||||
artifactKind: "legacy-zip";
|
||||
packageName: string;
|
||||
version: string;
|
||||
downloadUrl?: string | null;
|
||||
artifactSha256?: string | null;
|
||||
scanState?: ClawHubArtifactScanState | null;
|
||||
moderationState?: ClawHubArtifactModerationState | null;
|
||||
}
|
||||
| {
|
||||
source: "clawhub";
|
||||
artifactKind: "npm-pack";
|
||||
packageName: string;
|
||||
version: string;
|
||||
downloadUrl?: string | null;
|
||||
npmIntegrity: string;
|
||||
npmShasum?: string | null;
|
||||
artifactSha256?: string | null;
|
||||
scanState?: ClawHubArtifactScanState | null;
|
||||
moderationState?: ClawHubArtifactModerationState | null;
|
||||
};
|
||||
export type ClawHubPackageArtifactResolverResponse = {
|
||||
package?: { name?: string | null } | null;
|
||||
version?: { version?: string | null } | string | null;
|
||||
artifact?: ClawHubResolvedArtifact | null;
|
||||
};
|
||||
export type ClawHubPackageClawPackSummary = {
|
||||
available: boolean;
|
||||
specVersion?: number | null;
|
||||
@@ -70,6 +107,33 @@ export type ClawHubPackageClawPackSummary = {
|
||||
environment?: ClawHubPackageEnvironmentSummary | null;
|
||||
runtimeBundles?: unknown[];
|
||||
};
|
||||
export type ClawHubPackageReadinessPhase =
|
||||
| "planned"
|
||||
| "published"
|
||||
| "clawpack-ready"
|
||||
| "legacy-zip-only"
|
||||
| "metadata-ready"
|
||||
| "blocked"
|
||||
| "ready-for-openclaw"
|
||||
| string;
|
||||
export type ClawHubPackageReadiness = {
|
||||
ready?: boolean | null;
|
||||
readyForOpenClaw?: boolean | null;
|
||||
installReady?: boolean | null;
|
||||
phase?: ClawHubPackageReadinessPhase | null;
|
||||
status?: ClawHubPackageReadinessPhase | null;
|
||||
package?: {
|
||||
name?: string | null;
|
||||
family?: ClawHubPackageFamily | string | null;
|
||||
channel?: ClawHubPackageChannel | string | null;
|
||||
isOfficial?: boolean | null;
|
||||
} | null;
|
||||
packageName?: string | null;
|
||||
artifactKind?: ClawHubArtifactKind | string | null;
|
||||
blockers?: string[];
|
||||
scanState?: ClawHubArtifactScanState | null;
|
||||
moderationState?: ClawHubArtifactModerationState | null;
|
||||
};
|
||||
export type ClawHubPackageListItem = {
|
||||
name: string;
|
||||
displayName: string;
|
||||
@@ -591,6 +655,41 @@ export async function fetchClawHubPackageVersion(params: {
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchClawHubPackageArtifact(params: {
|
||||
name: string;
|
||||
version: string;
|
||||
baseUrl?: string;
|
||||
token?: string;
|
||||
timeoutMs?: number;
|
||||
fetchImpl?: FetchLike;
|
||||
}): Promise<ClawHubPackageArtifactResolverResponse> {
|
||||
return await fetchJson<ClawHubPackageArtifactResolverResponse>({
|
||||
baseUrl: params.baseUrl,
|
||||
path: `/api/v1/packages/${encodeURIComponent(params.name)}/versions/${encodeURIComponent(
|
||||
params.version,
|
||||
)}/artifact`,
|
||||
token: params.token,
|
||||
timeoutMs: params.timeoutMs,
|
||||
fetchImpl: params.fetchImpl,
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchClawHubPackageReadiness(params: {
|
||||
name: string;
|
||||
baseUrl?: string;
|
||||
token?: string;
|
||||
timeoutMs?: number;
|
||||
fetchImpl?: FetchLike;
|
||||
}): Promise<ClawHubPackageReadiness> {
|
||||
return await fetchJson<ClawHubPackageReadiness>({
|
||||
baseUrl: params.baseUrl,
|
||||
path: `/api/v1/packages/${encodeURIComponent(params.name)}/readiness`,
|
||||
token: params.token,
|
||||
timeoutMs: params.timeoutMs,
|
||||
fetchImpl: params.fetchImpl,
|
||||
});
|
||||
}
|
||||
|
||||
export async function searchClawHubPackages(params: {
|
||||
query: string;
|
||||
family?: ClawHubPackageFamily;
|
||||
|
||||
Reference in New Issue
Block a user