Files
openclaw/src/infra/clawhub.ts
Penchan 1c52447f0b fix(plugins): treat CalVer correction versions as compatible with plugin API ranges (#77450)
* fix(plugins): accept CalVer correction plugin API hosts

Fixes #77293

* docs(changelog): credit plugin api calver fix pr

---------

Co-authored-by: pingu <pingu@penchan.co>
2026-05-04 14:46:29 -07:00

1076 lines
31 KiB
TypeScript

import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "../shared/string-coerce.js";
import { isAtLeast, parseSemver } from "./runtime-guard.js";
import { compareComparableSemver, parseComparableSemver } from "./semver-compare.js";
import { createTempDownloadTarget } from "./temp-download.js";
export { parseClawHubPluginSpec } from "./clawhub-spec.js";
const DEFAULT_CLAWHUB_URL = "https://clawhub.ai";
const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
export type ClawHubPackageFamily = "skill" | "code-plugin" | "bundle-plugin";
export type ClawHubPackageChannel = "official" | "community" | "private";
// Keep aligned with @openclaw/plugin-package-contract ExternalPluginCompatibility.
export type ClawHubPackageCompatibility = {
pluginApiRange?: string;
builtWithOpenClawVersion?: string;
pluginSdkVersion?: string;
minGatewayVersion?: string;
};
export type ClawHubPackageHostTarget = {
os?: string | null;
arch?: string | null;
libc?: string | null;
key?: string | null;
};
export type ClawHubPackageEnvironmentSummary = {
requiresLocalDesktop?: boolean;
requiresBrowser?: boolean;
requiresAudioDevice?: boolean;
requiresNetwork?: boolean;
requiresExternalServices?: string[];
requiresOsPermissions?: string[];
supportsRemoteHost?: boolean;
knownUnsupported?: string[];
};
export type ClawHubPackageArtifactSummary = {
kind?: string | null;
sha256?: string | null;
size?: number | null;
format?: string | null;
npmIntegrity?: string | null;
npmShasum?: string | null;
npmTarballName?: string | null;
npmUnpackedSize?: number | null;
npmFileCount?: number | null;
downloadUrl?: string | null;
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 ClawHubPackageSecurityState =
| "pending"
| "approved"
| "limited"
| "quarantined"
| "rejected"
| "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;
displayName?: string | null;
family?: ClawHubPackageFamily | (string & {}) | null;
} | null;
version?:
| ({
version?: string | null;
createdAt?: number | null;
changelog?: string | null;
distTags?: string[];
files?: unknown[];
sha256hash?: string | null;
compatibility?: ClawHubPackageCompatibility | null;
artifact?: ClawHubPackageArtifactSummary | null;
clawpack?: ClawHubPackageClawPackSummary | null;
} & Record<string, unknown>)
| string
| null;
artifact?: ClawHubResolvedArtifact | null;
};
export type ClawHubPackageSecurityResponse = {
packageId?: string | null;
releaseId?: string | null;
state: ClawHubPackageSecurityState;
reasonCode?: string | null;
moderatorNote?: string | null;
actorId?: string | null;
createdAt?: number | null;
scanState?: ClawHubArtifactScanState | null;
moderationState?: ClawHubArtifactModerationState | null;
};
export type ClawHubPackageClawPackSummary = {
available: boolean;
specVersion?: number | null;
format?: string | null;
sha256?: string | null;
size?: number | null;
fileCount?: number | null;
manifestSha256?: string | null;
npmIntegrity?: string | null;
npmShasum?: string | null;
npmTarballName?: string | null;
builtAt?: number | null;
buildVersion?: string | null;
hostTargets?: ClawHubPackageHostTarget[];
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;
family: ClawHubPackageFamily;
runtimeId?: string | null;
channel: ClawHubPackageChannel;
isOfficial: boolean;
summary?: string | null;
ownerHandle?: string | null;
createdAt: number;
updatedAt: number;
latestVersion?: string | null;
capabilityTags?: string[];
executesCode?: boolean;
verificationTier?: string | null;
clawpackAvailable?: boolean;
hostTargetKeys?: string[];
environmentFlags?: string[];
artifact?: ClawHubPackageArtifactSummary | null;
clawpack?: ClawHubPackageClawPackSummary;
};
export type ClawHubPackageDetail = {
package:
| (ClawHubPackageListItem & {
tags?: Record<string, string>;
compatibility?: ClawHubPackageCompatibility | null;
capabilities?: {
executesCode?: boolean;
runtimeId?: string;
capabilityTags?: string[];
bundleFormat?: string;
hostTargets?: string[];
pluginKind?: string;
channels?: string[];
providers?: string[];
hooks?: string[];
bundledSkills?: string[];
} | null;
verification?: {
tier?: string;
scope?: string;
summary?: string;
sourceRepo?: string;
sourceCommit?: string;
hasProvenance?: boolean;
scanStatus?: string;
} | null;
artifact?: ClawHubPackageArtifactSummary | null;
clawpack?: ClawHubPackageClawPackSummary;
})
| null;
owner?: {
handle?: string | null;
displayName?: string | null;
image?: string | null;
} | null;
};
export type ClawHubPackageVersion = {
package: {
name: string;
displayName: string;
family: ClawHubPackageFamily;
} | null;
version: {
version: string;
createdAt: number;
changelog: string;
distTags?: string[];
files?: Array<{
path: string;
size?: number;
sha256: string;
contentType?: string;
}>;
sha256hash?: string | null;
compatibility?: ClawHubPackageCompatibility | null;
capabilities?: ClawHubPackageDetail["package"] extends infer T
? T extends { capabilities?: infer C }
? C
: never
: never;
verification?: ClawHubPackageDetail["package"] extends infer T
? T extends { verification?: infer C }
? C
: never
: never;
artifact?: ClawHubPackageArtifactSummary | null;
clawpack?: ClawHubPackageClawPackSummary;
} | null;
};
export type ClawHubPackageSearchResult = {
score: number;
package: ClawHubPackageListItem;
};
export type ClawHubSkillSearchResult = {
score: number;
slug: string;
displayName: string;
summary?: string;
version?: string;
updatedAt?: number;
};
export type ClawHubSkillDetail = {
skill: {
slug: string;
displayName: string;
summary?: string;
tags?: Record<string, string>;
createdAt: number;
updatedAt: number;
} | null;
latestVersion?: {
version: string;
createdAt: number;
changelog?: string;
} | null;
metadata?: {
os?: string[] | null;
systems?: string[] | null;
} | null;
owner?: {
handle?: string | null;
displayName?: string | null;
image?: string | null;
} | null;
};
export type ClawHubSkillListResponse = {
items: Array<{
slug: string;
displayName: string;
summary?: string;
tags?: Record<string, string>;
latestVersion?: {
version: string;
createdAt: number;
changelog?: string;
} | null;
metadata?: {
os?: string[] | null;
systems?: string[] | null;
} | null;
createdAt: number;
updatedAt: number;
}>;
nextCursor?: string | null;
};
export type ClawHubDownloadResult = {
archivePath: string;
integrity: string;
sha256Hex: string;
artifact: "archive" | "clawpack";
clawpackHeaderSha256?: string;
clawpackHeaderSpecVersion?: number;
npmIntegrity?: string;
npmShasum?: string;
npmTarballName?: string;
cleanup: () => Promise<void>;
};
type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
type ClawHubRequestParams = {
baseUrl?: string;
path: string;
token?: string;
timeoutMs?: number;
search?: Record<string, string | undefined>;
fetchImpl?: FetchLike;
};
type ClawHubConfigLike = {
token?: unknown;
accessToken?: unknown;
authToken?: unknown;
apiToken?: unknown;
auth?: ClawHubConfigLike | null;
session?: ClawHubConfigLike | null;
credentials?: ClawHubConfigLike | null;
user?: ClawHubConfigLike | null;
};
export class ClawHubRequestError extends Error {
readonly status: number;
readonly requestPath: string;
readonly responseBody: string;
constructor(params: { path: string; status: number; body: string }) {
super(`ClawHub ${params.path} failed (${params.status}): ${params.body}`);
this.name = "ClawHubRequestError";
this.status = params.status;
this.requestPath = params.path;
this.responseBody = params.body;
}
}
function normalizeBaseUrl(baseUrl?: string): string {
const envValue =
normalizeOptionalString(process.env.OPENCLAW_CLAWHUB_URL) ||
normalizeOptionalString(process.env.CLAWHUB_URL) ||
DEFAULT_CLAWHUB_URL;
const value = (normalizeOptionalString(baseUrl) || envValue).replace(/\/+$/, "");
return value || DEFAULT_CLAWHUB_URL;
}
function extractTokenFromClawHubConfig(value: unknown): string | undefined {
if (!value || typeof value !== "object") {
return undefined;
}
const record = value as ClawHubConfigLike;
return (
normalizeOptionalString(record.accessToken) ??
normalizeOptionalString(record.authToken) ??
normalizeOptionalString(record.apiToken) ??
normalizeOptionalString(record.token) ??
extractTokenFromClawHubConfig(record.auth) ??
extractTokenFromClawHubConfig(record.session) ??
extractTokenFromClawHubConfig(record.credentials) ??
extractTokenFromClawHubConfig(record.user)
);
}
function resolveClawHubConfigPaths(): string[] {
const explicit =
normalizeOptionalString(process.env.OPENCLAW_CLAWHUB_CONFIG_PATH) ||
normalizeOptionalString(process.env.CLAWHUB_CONFIG_PATH) ||
normalizeOptionalString(process.env.CLAWDHUB_CONFIG_PATH); // legacy misspelling from older clawhub CLI builds; keep for back-compat
if (explicit) {
return [explicit];
}
const xdgConfigHome = normalizeOptionalString(process.env.XDG_CONFIG_HOME);
const configHome =
xdgConfigHome && xdgConfigHome.length > 0 ? xdgConfigHome : path.join(os.homedir(), ".config");
const xdgPath = path.join(configHome, "clawhub", "config.json");
if (process.platform === "darwin") {
return [
path.join(os.homedir(), "Library", "Application Support", "clawhub", "config.json"),
xdgPath,
];
}
return [xdgPath];
}
export async function resolveClawHubAuthToken(): Promise<string | undefined> {
const envToken =
normalizeOptionalString(process.env.OPENCLAW_CLAWHUB_TOKEN) ||
normalizeOptionalString(process.env.CLAWHUB_TOKEN) ||
normalizeOptionalString(process.env.CLAWHUB_AUTH_TOKEN);
if (envToken) {
return envToken;
}
for (const configPath of resolveClawHubConfigPaths()) {
try {
const raw = await fs.readFile(configPath, "utf8");
const token = extractTokenFromClawHubConfig(JSON.parse(raw));
if (token) {
return token;
}
} catch {
// Try the next candidate path.
}
}
return undefined;
}
function normalizePartialComparableVersion(version: string): {
version: string;
isPartial: boolean;
} {
const trimmed = version.trim();
return /^[vV]?[0-9]+\.[0-9]+$/.test(trimmed)
? { version: `${trimmed}.0`, isPartial: true }
: { version: trimmed, isPartial: false };
}
function compareSemver(left: string, right: string): number | null {
return compareComparableSemver(
parseComparableSemver(normalizePartialComparableVersion(left).version),
parseComparableSemver(normalizePartialComparableVersion(right).version),
);
}
function upperBoundForCaret(version: string): string | null {
const parsed = parseComparableSemver(normalizePartialComparableVersion(version).version);
if (!parsed) {
return null;
}
if (parsed.major > 0) {
return `${parsed.major + 1}.0.0`;
}
if (parsed.minor > 0) {
return `0.${parsed.minor + 1}.0`;
}
return `0.0.${parsed.patch + 1}`;
}
function matchWildcardComparator(token: string): "any" | "none" | null {
const match = /^(>=|<=|>|<|=|\^|~)?\s*([*xX])$/.exec(token);
if (!match) {
return null;
}
const operator = match[1];
return operator === ">" || operator === "<" ? "none" : "any";
}
function satisfiesComparator(version: string, token: string): boolean {
const trimmed = token.trim();
if (!trimmed) {
return true;
}
const wildcard = matchWildcardComparator(trimmed);
if (wildcard) {
return wildcard === "any" && parseComparableSemver(version) != null;
}
if (trimmed.startsWith("^")) {
const base = trimmed.slice(1).trim();
const upperBound = upperBoundForCaret(base);
const lowerCmp = compareSemver(version, base);
const upperCmp = upperBound ? compareSemver(version, upperBound) : null;
return lowerCmp != null && upperCmp != null && lowerCmp >= 0 && upperCmp < 0;
}
const match = /^(>=|<=|>|<|=)?\s*(.+)$/.exec(trimmed);
if (!match) {
return false;
}
const operator = match[1];
const target = match[2]?.trim();
if (!target) {
return false;
}
const normalizedTarget = normalizePartialComparableVersion(target);
const cmp = compareSemver(version, normalizedTarget.version);
if (cmp == null) {
return false;
}
switch (operator) {
case ">=":
return cmp >= 0;
case "<=":
return cmp <= 0;
case ">":
return cmp > 0;
case "<":
return cmp < 0;
case "=":
default:
return normalizedTarget.isPartial && !operator ? cmp >= 0 : cmp === 0;
}
}
function satisfiesSemverRange(version: string, range: string): boolean {
const tokens = range
.trim()
.split(/\s+/)
.map((token) => token.trim())
.filter(Boolean);
if (tokens.length === 0) {
return false;
}
return tokens.every((token) => satisfiesComparator(version, token));
}
const OPENCLAW_CALVER_STABLE_CORRECTION_PATTERN = /^[vV]?(\d{4}\.\d{1,2}\.\d{1,2})-\d+$/;
function normalizeCalVerCorrectionForPluginApi(pluginApiVersion: string): string {
const match = OPENCLAW_CALVER_STABLE_CORRECTION_PATTERN.exec(pluginApiVersion.trim());
return match?.[1] ?? pluginApiVersion;
}
function buildUrl(params: Pick<ClawHubRequestParams, "baseUrl" | "path" | "search">): URL {
const url = new URL(params.path, `${normalizeBaseUrl(params.baseUrl)}/`);
for (const [key, value] of Object.entries(params.search ?? {})) {
if (!value) {
continue;
}
url.searchParams.set(key, value);
}
return url;
}
async function clawhubRequest(
params: ClawHubRequestParams,
): Promise<{ response: Response; url: URL; hasToken: boolean }> {
const url = buildUrl(params);
const token = normalizeOptionalString(params.token) || (await resolveClawHubAuthToken());
const controller = new AbortController();
const timeout = setTimeout(
() =>
controller.abort(
new Error(
`ClawHub request timed out after ${params.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS}ms`,
),
),
params.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS,
);
try {
const response = await (params.fetchImpl ?? fetch)(url, {
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
signal: controller.signal,
});
return { response, url, hasToken: Boolean(token) };
} finally {
clearTimeout(timeout);
}
}
async function readErrorBody(response: Response): Promise<string> {
try {
const text = (await response.text()).trim();
return text || response.statusText || `HTTP ${response.status}`;
} catch {
return response.statusText || `HTTP ${response.status}`;
}
}
async function buildClawHubError(
response: Response,
url: URL,
hasToken: boolean,
): Promise<ClawHubRequestError> {
let body = await readErrorBody(response);
if (response.status === 429) {
const suffix = formatRateLimitSuffix(response.headers, hasToken);
if (suffix) {
body = `${body} ${suffix}`;
}
}
return new ClawHubRequestError({
path: url.pathname,
status: response.status,
body,
});
}
function formatRateLimitSuffix(headers: Headers, hasToken: boolean): string {
const reset =
normalizeHeaderValue(headers.get("RateLimit-Reset")) ??
normalizeHeaderValue(headers.get("Retry-After"));
const segments: string[] = [];
if (reset && Number.isFinite(Number(reset))) {
segments.push(`(resets in ${reset}s)`);
}
if (!hasToken) {
segments.push("Sign in for higher rate limits.");
}
return segments.join(" ");
}
async function fetchJson<T>(params: ClawHubRequestParams): Promise<T> {
const { response, url, hasToken } = await clawhubRequest(params);
if (!response.ok) {
throw await buildClawHubError(response, url, hasToken);
}
return (await response.json()) as T;
}
export function resolveClawHubBaseUrl(baseUrl?: string): string {
return normalizeBaseUrl(baseUrl);
}
function formatSha256Integrity(bytes: Uint8Array): string {
const digest = createHash("sha256").update(bytes).digest("base64");
return `sha256-${digest}`;
}
function formatSha256Hex(bytes: Uint8Array): string {
return createHash("sha256").update(bytes).digest("hex");
}
function formatSha512Integrity(bytes: Uint8Array): string {
const digest = createHash("sha512").update(bytes).digest("base64");
return `sha512-${digest}`;
}
function formatSha1Hex(bytes: Uint8Array): string {
return createHash("sha1").update(bytes).digest("hex");
}
function normalizeHeaderValue(value: string | null): string | undefined {
const normalized = normalizeOptionalString(value);
return normalized && normalized.length > 0 ? normalized : undefined;
}
function safePackageTarballName(name: string, version: string): string {
const base = name
.replace(/^@/, "")
.replace(/[\\/]+/g, "-")
.replace(/[^A-Za-z0-9._-]/g, "-");
return `${base || "package"}-${version}.tgz`;
}
export function normalizeClawHubSha256Integrity(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const prefixedBase64 = /^sha256-([A-Za-z0-9+/]+={0,1})$/.exec(trimmed);
if (prefixedBase64?.[1]) {
try {
const decoded = Buffer.from(prefixedBase64[1], "base64");
if (decoded.length === 32) {
return `sha256-${decoded.toString("base64")}`;
}
} catch {
return null;
}
return null;
}
const prefixedHex = /^sha256:([A-Fa-f0-9]{64})$/.exec(trimmed);
if (prefixedHex?.[1]) {
return `sha256-${Buffer.from(prefixedHex[1], "hex").toString("base64")}`;
}
if (/^[A-Fa-f0-9]{64}$/.test(trimmed)) {
return `sha256-${Buffer.from(trimmed, "hex").toString("base64")}`;
}
return null;
}
export function normalizeClawHubSha256Hex(value: string): string | null {
const trimmed = value.trim();
if (!/^[A-Fa-f0-9]{64}$/.test(trimmed)) {
return null;
}
return normalizeLowercaseStringOrEmpty(trimmed);
}
export async function fetchClawHubPackageDetail(params: {
name: string;
baseUrl?: string;
token?: string;
timeoutMs?: number;
fetchImpl?: FetchLike;
}): Promise<ClawHubPackageDetail> {
return await fetchJson<ClawHubPackageDetail>({
baseUrl: params.baseUrl,
path: `/api/v1/packages/${encodeURIComponent(params.name)}`,
token: params.token,
timeoutMs: params.timeoutMs,
fetchImpl: params.fetchImpl,
});
}
export async function fetchClawHubPackageVersion(params: {
name: string;
version: string;
baseUrl?: string;
token?: string;
timeoutMs?: number;
fetchImpl?: FetchLike;
}): Promise<ClawHubPackageVersion> {
return await fetchJson<ClawHubPackageVersion>({
baseUrl: params.baseUrl,
path: `/api/v1/packages/${encodeURIComponent(params.name)}/versions/${encodeURIComponent(
params.version,
)}`,
token: params.token,
timeoutMs: params.timeoutMs,
fetchImpl: params.fetchImpl,
});
}
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 fetchClawHubPackageSecurity(params: {
name: string;
version: string;
baseUrl?: string;
token?: string;
timeoutMs?: number;
fetchImpl?: FetchLike;
}): Promise<ClawHubPackageSecurityResponse> {
return await fetchJson<ClawHubPackageSecurityResponse>({
baseUrl: params.baseUrl,
path: `/api/v1/packages/${encodeURIComponent(params.name)}/versions/${encodeURIComponent(
params.version,
)}/security`,
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;
baseUrl?: string;
token?: string;
timeoutMs?: number;
fetchImpl?: FetchLike;
limit?: number;
}): Promise<ClawHubPackageSearchResult[]> {
const result = await fetchJson<{ results: ClawHubPackageSearchResult[] }>({
baseUrl: params.baseUrl,
path: "/api/v1/packages/search",
token: params.token,
timeoutMs: params.timeoutMs,
fetchImpl: params.fetchImpl,
search: {
q: params.query.trim(),
family: params.family,
limit: params.limit ? String(params.limit) : undefined,
},
});
return result.results ?? [];
}
export async function searchClawHubSkills(params: {
query: string;
baseUrl?: string;
token?: string;
timeoutMs?: number;
fetchImpl?: FetchLike;
limit?: number;
}): Promise<ClawHubSkillSearchResult[]> {
const result = await fetchJson<{ results: ClawHubSkillSearchResult[] }>({
baseUrl: params.baseUrl,
path: "/api/v1/search",
token: params.token,
timeoutMs: params.timeoutMs,
fetchImpl: params.fetchImpl,
search: {
q: params.query.trim(),
limit: params.limit ? String(params.limit) : undefined,
},
});
return result.results ?? [];
}
export async function fetchClawHubSkillDetail(params: {
slug: string;
baseUrl?: string;
token?: string;
timeoutMs?: number;
fetchImpl?: FetchLike;
}): Promise<ClawHubSkillDetail> {
return await fetchJson<ClawHubSkillDetail>({
baseUrl: params.baseUrl,
path: `/api/v1/skills/${encodeURIComponent(params.slug)}`,
token: params.token,
timeoutMs: params.timeoutMs,
fetchImpl: params.fetchImpl,
});
}
export async function listClawHubSkills(params: {
baseUrl?: string;
token?: string;
timeoutMs?: number;
fetchImpl?: FetchLike;
limit?: number;
}): Promise<ClawHubSkillListResponse> {
return await fetchJson<ClawHubSkillListResponse>({
baseUrl: params.baseUrl,
path: "/api/v1/skills",
token: params.token,
timeoutMs: params.timeoutMs,
fetchImpl: params.fetchImpl,
search: {
limit: params.limit ? String(params.limit) : undefined,
},
});
}
export async function downloadClawHubPackageArchive(params: {
name: string;
version?: string;
tag?: string;
artifact?: "archive" | "clawpack";
baseUrl?: string;
token?: string;
timeoutMs?: number;
fetchImpl?: FetchLike;
}): Promise<ClawHubDownloadResult> {
if (params.artifact === "clawpack") {
if (!params.version) {
throw new Error("ClawPack package downloads require an explicit version.");
}
const { response, url, hasToken } = await clawhubRequest({
baseUrl: params.baseUrl,
path: `/api/v1/packages/${encodeURIComponent(params.name)}/versions/${encodeURIComponent(
params.version,
)}/artifact/download`,
token: params.token,
timeoutMs: params.timeoutMs,
fetchImpl: params.fetchImpl,
});
if (!response.ok) {
throw await buildClawHubError(response, url, hasToken);
}
const bytes = new Uint8Array(await response.arrayBuffer());
const sha256Hex = formatSha256Hex(bytes);
const npmIntegrity = formatSha512Integrity(bytes);
const npmShasum = formatSha1Hex(bytes);
const headerSha256 = normalizeClawHubSha256Hex(
response.headers.get("X-ClawHub-Artifact-Sha256") ??
response.headers.get("X-ClawHub-ClawPack-Sha256") ??
"",
);
if (!headerSha256) {
throw new Error(
`ClawHub ClawPack download for "${params.name}@${params.version}" is missing X-ClawHub-Artifact-Sha256.`,
);
}
if (headerSha256 !== sha256Hex) {
throw new Error(
`ClawHub ClawPack download for "${params.name}@${params.version}" declared sha256 ${headerSha256}, got ${sha256Hex}.`,
);
}
const headerNpmIntegrity = normalizeHeaderValue(
response.headers.get("X-ClawHub-Npm-Integrity"),
);
if (headerNpmIntegrity && headerNpmIntegrity !== npmIntegrity) {
throw new Error(
`ClawHub ClawPack download for "${params.name}@${params.version}" declared npm integrity ${headerNpmIntegrity}, got ${npmIntegrity}.`,
);
}
const headerNpmShasum = normalizeHeaderValue(response.headers.get("X-ClawHub-Npm-Shasum"));
if (headerNpmShasum && headerNpmShasum !== npmShasum) {
throw new Error(
`ClawHub ClawPack download for "${params.name}@${params.version}" declared npm shasum ${headerNpmShasum}, got ${npmShasum}.`,
);
}
const npmTarballName =
normalizeHeaderValue(response.headers.get("X-ClawHub-Npm-Tarball-Name")) ??
safePackageTarballName(params.name, params.version);
const rawSpecVersion = response.headers.get("X-ClawHub-ClawPack-Spec-Version");
const specVersion = rawSpecVersion ? Number.parseInt(rawSpecVersion, 10) : undefined;
const target = await createTempDownloadTarget({
prefix: "openclaw-clawhub-clawpack",
fileName: npmTarballName,
tmpDir: os.tmpdir(),
});
await fs.writeFile(target.path, bytes);
return {
archivePath: target.path,
integrity: normalizeClawHubSha256Integrity(sha256Hex) ?? formatSha256Integrity(bytes),
sha256Hex,
artifact: "clawpack",
clawpackHeaderSha256: headerSha256,
...(typeof specVersion === "number" && Number.isSafeInteger(specVersion) && specVersion >= 0
? { clawpackHeaderSpecVersion: specVersion }
: {}),
npmIntegrity,
npmShasum,
npmTarballName,
cleanup: target.cleanup,
};
}
const search = params.version
? { version: params.version }
: params.tag
? { tag: params.tag }
: undefined;
const { response, url, hasToken } = await clawhubRequest({
baseUrl: params.baseUrl,
path: `/api/v1/packages/${encodeURIComponent(params.name)}/download`,
search,
token: params.token,
timeoutMs: params.timeoutMs,
fetchImpl: params.fetchImpl,
});
if (!response.ok) {
throw await buildClawHubError(response, url, hasToken);
}
const bytes = new Uint8Array(await response.arrayBuffer());
const sha256Hex = formatSha256Hex(bytes);
const target = await createTempDownloadTarget({
prefix: "openclaw-clawhub-package",
fileName: `${params.name}.zip`,
tmpDir: os.tmpdir(),
});
await fs.writeFile(target.path, bytes);
return {
archivePath: target.path,
integrity: formatSha256Integrity(bytes),
sha256Hex,
artifact: "archive",
cleanup: target.cleanup,
};
}
export async function downloadClawHubSkillArchive(params: {
slug: string;
version?: string;
tag?: string;
baseUrl?: string;
token?: string;
timeoutMs?: number;
fetchImpl?: FetchLike;
}): Promise<ClawHubDownloadResult> {
const { response, url, hasToken } = await clawhubRequest({
baseUrl: params.baseUrl,
path: "/api/v1/download",
token: params.token,
timeoutMs: params.timeoutMs,
fetchImpl: params.fetchImpl,
search: {
slug: params.slug,
version: params.version,
tag: params.version ? undefined : params.tag,
},
});
if (!response.ok) {
throw await buildClawHubError(response, url, hasToken);
}
const bytes = new Uint8Array(await response.arrayBuffer());
const sha256Hex = formatSha256Hex(bytes);
const target = await createTempDownloadTarget({
prefix: "openclaw-clawhub-skill",
fileName: `${params.slug}.zip`,
tmpDir: os.tmpdir(),
});
await fs.writeFile(target.path, bytes);
return {
archivePath: target.path,
integrity: formatSha256Integrity(bytes),
sha256Hex,
artifact: "archive",
cleanup: target.cleanup,
};
}
export function resolveLatestVersionFromPackage(detail: ClawHubPackageDetail): string | null {
return detail.package?.latestVersion ?? detail.package?.tags?.latest ?? null;
}
export function isClawHubFamilySkill(detail: ClawHubPackageDetail | ClawHubSkillDetail): boolean {
if ("package" in detail) {
return detail.package?.family === "skill";
}
return Boolean(detail.skill);
}
export function satisfiesPluginApiRange(
pluginApiVersion: string,
pluginApiRange?: string | null,
): boolean {
if (!pluginApiRange) {
return true;
}
return satisfiesSemverRange(
normalizeCalVerCorrectionForPluginApi(pluginApiVersion),
pluginApiRange,
);
}
export function satisfiesGatewayMinimum(
currentVersion: string,
minGatewayVersion?: string | null,
): boolean {
if (!minGatewayVersion) {
return true;
}
const current = parseSemver(currentVersion);
const minimum = parseSemver(minGatewayVersion);
if (!current || !minimum) {
return false;
}
return isAtLeast(current, minimum);
}