openclaw/src/plugins/clawhub.ts

351 lines
10 KiB
TypeScript

import fs from "node:fs/promises";
import path from "node:path";
import {
ClawHubRequestError,
downloadClawHubPackageArchive,
fetchClawHubPackageDetail,
fetchClawHubPackageVersion,
parseClawHubPluginSpec,
resolveLatestVersionFromPackage,
satisfiesGatewayMinimum,
satisfiesPluginApiRange,
type ClawHubPackageChannel,
type ClawHubPackageDetail,
type ClawHubPackageFamily,
} from "../infra/clawhub.js";
import { resolveRuntimeServiceVersion } from "../version.js";
import { installPluginFromArchive, type InstallPluginResult } from "./install.js";
export const OPENCLAW_PLUGIN_API_VERSION = "1.2.0";
export const CLAWHUB_INSTALL_ERROR_CODE = {
INVALID_SPEC: "invalid_spec",
PACKAGE_NOT_FOUND: "package_not_found",
VERSION_NOT_FOUND: "version_not_found",
NO_INSTALLABLE_VERSION: "no_installable_version",
SKILL_PACKAGE: "skill_package",
UNSUPPORTED_FAMILY: "unsupported_family",
PRIVATE_PACKAGE: "private_package",
INCOMPATIBLE_PLUGIN_API: "incompatible_plugin_api",
INCOMPATIBLE_GATEWAY: "incompatible_gateway",
} as const;
export type ClawHubInstallErrorCode =
(typeof CLAWHUB_INSTALL_ERROR_CODE)[keyof typeof CLAWHUB_INSTALL_ERROR_CODE];
type PluginInstallLogger = {
info?: (message: string) => void;
warn?: (message: string) => void;
};
export type ClawHubPluginInstallRecordFields = {
source: "clawhub";
clawhubUrl: string;
clawhubPackage: string;
clawhubFamily: Exclude<ClawHubPackageFamily, "skill">;
clawhubChannel?: ClawHubPackageChannel;
version?: string;
integrity?: string;
resolvedAt?: string;
installedAt?: string;
};
type ClawHubInstallFailure = {
ok: false;
error: string;
code?: ClawHubInstallErrorCode;
};
export function formatClawHubSpecifier(params: { name: string; version?: string }): string {
return `clawhub:${params.name}${params.version ? `@${params.version}` : ""}`;
}
function buildClawHubInstallFailure(
error: string,
code?: ClawHubInstallErrorCode,
): ClawHubInstallFailure {
return { ok: false, error, code };
}
function mapClawHubRequestError(
error: unknown,
context: { stage: "package" | "version"; name: string; version?: string },
): ClawHubInstallFailure {
if (error instanceof ClawHubRequestError && error.status === 404) {
if (context.stage === "package") {
return buildClawHubInstallFailure(
"Package not found on ClawHub.",
CLAWHUB_INSTALL_ERROR_CODE.PACKAGE_NOT_FOUND,
);
}
return buildClawHubInstallFailure(
`Version not found on ClawHub: ${context.name}@${context.version ?? "unknown"}.`,
CLAWHUB_INSTALL_ERROR_CODE.VERSION_NOT_FOUND,
);
}
return buildClawHubInstallFailure(error instanceof Error ? error.message : String(error));
}
function resolveRequestedVersion(params: {
detail: ClawHubPackageDetail;
requestedVersion?: string;
}): string | null {
if (params.requestedVersion) {
return params.requestedVersion;
}
return resolveLatestVersionFromPackage(params.detail);
}
async function resolveCompatiblePackageVersion(params: {
detail: ClawHubPackageDetail;
requestedVersion?: string;
baseUrl?: string;
token?: string;
}): Promise<
| {
ok: true;
version: string;
compatibility?: {
pluginApiRange?: string;
minGatewayVersion?: string;
} | null;
}
| ClawHubInstallFailure
> {
const version = resolveRequestedVersion(params);
if (!version) {
return buildClawHubInstallFailure(
`ClawHub package "${params.detail.package?.name ?? "unknown"}" has no installable version.`,
CLAWHUB_INSTALL_ERROR_CODE.NO_INSTALLABLE_VERSION,
);
}
let versionDetail;
try {
versionDetail = await fetchClawHubPackageVersion({
name: params.detail.package?.name ?? "",
version,
baseUrl: params.baseUrl,
token: params.token,
});
} catch (error) {
return mapClawHubRequestError(error, {
stage: "version",
name: params.detail.package?.name ?? "unknown",
version,
});
}
return {
ok: true,
version,
compatibility:
versionDetail.version?.compatibility ?? params.detail.package?.compatibility ?? null,
};
}
function validateClawHubPluginPackage(params: {
detail: ClawHubPackageDetail;
compatibility?: {
pluginApiRange?: string;
minGatewayVersion?: string;
} | null;
}): ClawHubInstallFailure | null {
const pkg = params.detail.package;
if (!pkg) {
return buildClawHubInstallFailure(
"Package not found on ClawHub.",
CLAWHUB_INSTALL_ERROR_CODE.PACKAGE_NOT_FOUND,
);
}
if (pkg.family === "skill") {
return buildClawHubInstallFailure(
`"${pkg.name}" is a skill. Use "openclaw skills install ${pkg.name}" instead.`,
CLAWHUB_INSTALL_ERROR_CODE.SKILL_PACKAGE,
);
}
if (pkg.family !== "code-plugin" && pkg.family !== "bundle-plugin") {
return buildClawHubInstallFailure(
`Unsupported ClawHub package family: ${String(pkg.family)}`,
CLAWHUB_INSTALL_ERROR_CODE.UNSUPPORTED_FAMILY,
);
}
if (pkg.channel === "private") {
return buildClawHubInstallFailure(
`"${pkg.name}" is private on ClawHub and cannot be installed anonymously.`,
CLAWHUB_INSTALL_ERROR_CODE.PRIVATE_PACKAGE,
);
}
const compatibility = params.compatibility;
if (
compatibility?.pluginApiRange &&
!satisfiesPluginApiRange(OPENCLAW_PLUGIN_API_VERSION, compatibility.pluginApiRange)
) {
return buildClawHubInstallFailure(
`Plugin "${pkg.name}" requires plugin API ${compatibility.pluginApiRange}, but this OpenClaw runtime exposes ${OPENCLAW_PLUGIN_API_VERSION}.`,
CLAWHUB_INSTALL_ERROR_CODE.INCOMPATIBLE_PLUGIN_API,
);
}
const runtimeVersion = resolveRuntimeServiceVersion();
if (
compatibility?.minGatewayVersion &&
!satisfiesGatewayMinimum(runtimeVersion, compatibility.minGatewayVersion)
) {
return buildClawHubInstallFailure(
`Plugin "${pkg.name}" requires OpenClaw >=${compatibility.minGatewayVersion}, but this host is ${runtimeVersion}.`,
CLAWHUB_INSTALL_ERROR_CODE.INCOMPATIBLE_GATEWAY,
);
}
return null;
}
function logClawHubPackageSummary(params: {
detail: ClawHubPackageDetail;
version: string;
logger?: PluginInstallLogger;
}) {
const pkg = params.detail.package;
if (!pkg) {
return;
}
const verification = pkg.verification?.tier ? ` verification=${pkg.verification.tier}` : "";
params.logger?.info?.(
`ClawHub ${pkg.family} ${pkg.name}@${params.version} channel=${pkg.channel}${verification}`,
);
const compatibilityParts = [
pkg.compatibility?.pluginApiRange ? `pluginApi=${pkg.compatibility.pluginApiRange}` : null,
pkg.compatibility?.minGatewayVersion
? `minGateway=${pkg.compatibility.minGatewayVersion}`
: null,
].filter(Boolean);
if (compatibilityParts.length > 0) {
params.logger?.info?.(`Compatibility: ${compatibilityParts.join(" ")}`);
}
if (pkg.channel !== "official") {
params.logger?.warn?.(
`ClawHub package "${pkg.name}" is ${pkg.channel}; review source and verification before enabling.`,
);
}
}
export async function installPluginFromClawHub(params: {
spec: string;
baseUrl?: string;
token?: string;
logger?: PluginInstallLogger;
mode?: "install" | "update";
dryRun?: boolean;
expectedPluginId?: string;
}): Promise<
| ({
ok: true;
} & Extract<InstallPluginResult, { ok: true }> & {
clawhub: ClawHubPluginInstallRecordFields;
packageName: string;
})
| ClawHubInstallFailure
| Extract<InstallPluginResult, { ok: false }>
> {
const parsed = parseClawHubPluginSpec(params.spec);
if (!parsed?.name) {
return buildClawHubInstallFailure(
`invalid ClawHub plugin spec: ${params.spec}`,
CLAWHUB_INSTALL_ERROR_CODE.INVALID_SPEC,
);
}
params.logger?.info?.(`Resolving ${formatClawHubSpecifier(parsed)}`);
let detail: ClawHubPackageDetail;
try {
detail = await fetchClawHubPackageDetail({
name: parsed.name,
baseUrl: params.baseUrl,
token: params.token,
});
} catch (error) {
return mapClawHubRequestError(error, {
stage: "package",
name: parsed.name,
});
}
const versionState = await resolveCompatiblePackageVersion({
detail,
requestedVersion: parsed.version,
baseUrl: params.baseUrl,
token: params.token,
});
if (!versionState.ok) {
return versionState;
}
const validationFailure = validateClawHubPluginPackage({
detail,
compatibility: versionState.compatibility,
});
if (validationFailure) {
return validationFailure;
}
logClawHubPackageSummary({
detail,
version: versionState.version,
logger: params.logger,
});
let archive;
try {
archive = await downloadClawHubPackageArchive({
name: parsed.name,
version: versionState.version,
baseUrl: params.baseUrl,
token: params.token,
});
} catch (error) {
return buildClawHubInstallFailure(error instanceof Error ? error.message : String(error));
}
try {
params.logger?.info?.(
`Downloading ${detail.package?.family === "bundle-plugin" ? "bundle" : "plugin"} ${parsed.name}@${versionState.version} from ClawHub…`,
);
const installResult = await installPluginFromArchive({
archivePath: archive.archivePath,
logger: params.logger,
mode: params.mode,
dryRun: params.dryRun,
expectedPluginId: params.expectedPluginId,
});
if (!installResult.ok) {
return installResult;
}
const pkg = detail.package!;
const clawhubFamily =
pkg.family === "code-plugin" || pkg.family === "bundle-plugin" ? pkg.family : null;
if (!clawhubFamily) {
return buildClawHubInstallFailure(
`Unsupported ClawHub package family: ${pkg.family}`,
CLAWHUB_INSTALL_ERROR_CODE.UNSUPPORTED_FAMILY,
);
}
return {
...installResult,
packageName: parsed.name,
clawhub: {
source: "clawhub",
clawhubUrl:
params.baseUrl?.trim() ||
process.env.OPENCLAW_CLAWHUB_URL?.trim() ||
"https://clawhub.ai",
clawhubPackage: parsed.name,
clawhubFamily,
clawhubChannel: pkg.channel,
version: installResult.version ?? versionState.version,
integrity: archive.integrity,
resolvedAt: new Date().toISOString(),
},
};
} finally {
await fs.rm(archive.archivePath, { force: true }).catch(() => undefined);
await fs
.rm(path.dirname(archive.archivePath), { recursive: true, force: true })
.catch(() => undefined);
}
}