fix(plugins): gate bare clawhub installs on readiness

This commit is contained in:
Vincent Koc
2026-05-02 11:25:39 -07:00
parent e06e2d8c4c
commit e9e7c4325f
8 changed files with 256 additions and 5 deletions

View File

@@ -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();

View File

@@ -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.',

View File

@@ -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:")) {

View File

@@ -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,