mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 10:11:37 +00:00
refactor(skills): split ClawHub lifecycle (#113789)
* refactor(skills): split ClawHub lifecycle * refactor(skills): privatize ClawHub store internals
This commit is contained in:
committed by
GitHub
parent
2949343039
commit
0a2a629117
@@ -982,7 +982,6 @@ src/sessions/session-state-events.ts
|
||||
src/shared/json-schema-defaults.ts
|
||||
src/shared/text/assistant-visible-text.ts
|
||||
src/skills/lifecycle/clawhub.test.ts
|
||||
src/skills/lifecycle/clawhub.ts
|
||||
src/skills/lifecycle/install.ts
|
||||
src/skills/loading/workspace-load.test.ts
|
||||
src/skills/loading/workspace.ts
|
||||
|
||||
678
src/skills/lifecycle/clawhub-install-core.ts
Normal file
678
src/skills/lifecycle/clawhub-install-core.ts
Normal file
@@ -0,0 +1,678 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import {
|
||||
type ClawHubTrustErrorCode,
|
||||
ensureClawHubPackageTrustAcknowledged,
|
||||
type ClawHubRiskAcknowledgementRequest,
|
||||
} from "../../infra/clawhub-install-trust.js";
|
||||
import {
|
||||
CLAWHUB_SKILLS_SH_TRUST_LABEL,
|
||||
CLAWHUB_SKILLS_SH_TRUST_STATE,
|
||||
downloadClawHubGitHubSkillArchive,
|
||||
downloadClawHubSkillArchive,
|
||||
downloadClawHubSkillArchiveUrl,
|
||||
fetchClawHubSkillDetail,
|
||||
fetchClawHubSkillInstallResolution,
|
||||
fetchClawHubSkillVerification,
|
||||
isDefaultClawHubBaseUrl,
|
||||
normalizeClawHubSha256Integrity,
|
||||
reportClawHubSkillInstallTelemetry,
|
||||
resolveClawHubBaseUrl,
|
||||
type ClawHubDownloadResult,
|
||||
type ClawHubSkillDetail,
|
||||
type ClawHubSkillInstallResolutionResponse,
|
||||
type ClawHubSkillVerificationResponse,
|
||||
type ClawHubSkillsShTrustState,
|
||||
} from "../../infra/clawhub.js";
|
||||
import { sha256Hex } from "../../infra/crypto-digest.js";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import { pathExists } from "../../infra/fs-safe.js";
|
||||
import { withExtractedArchiveRoot } from "../../infra/install-flow.js";
|
||||
import { markClawPackageIndependentlyOwned } from "../../state/claw-package-adoption.js";
|
||||
import {
|
||||
CLAWHUB_SKILL_ARCHIVE_ROOT_MARKERS,
|
||||
installExtractedSkillRoot,
|
||||
resolveWorkspaceSkillInstallDir,
|
||||
} from "./archive-install.js";
|
||||
import {
|
||||
formatClawHubSkillRef,
|
||||
normalizeGitHubCommitSegment,
|
||||
normalizeOptionalStringValue,
|
||||
readClawHubSkillsLockfile,
|
||||
writeClawHubSkillOrigin,
|
||||
writeClawHubSkillsLockfile,
|
||||
type ClawHubSkillDownloadedArtifactLock,
|
||||
type ClawHubSkillFileLock,
|
||||
type ClawHubSkillVerificationLock,
|
||||
} from "./clawhub-store.js";
|
||||
import { digestClawHubSkillTree } from "./skill-tree-digest.js";
|
||||
|
||||
export type Logger = {
|
||||
info?: (message: string) => void;
|
||||
warn?: (message: string) => void;
|
||||
terminalLinks?: boolean;
|
||||
};
|
||||
|
||||
export type ClawHubInstallParams = {
|
||||
workspaceDir: string;
|
||||
slug: string;
|
||||
ownerHandle?: string;
|
||||
requestedReference?: string;
|
||||
trustState?: ClawHubSkillsShTrustState;
|
||||
version?: string;
|
||||
expectedIntegrity?: string;
|
||||
baseUrl?: string;
|
||||
force?: boolean;
|
||||
forceInstall?: boolean;
|
||||
acknowledgeClawHubRisk?: boolean;
|
||||
onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise<boolean>;
|
||||
logger?: Logger;
|
||||
config?: OpenClawConfig;
|
||||
clawManaged?: boolean;
|
||||
};
|
||||
|
||||
export type InstallClawHubSkillResult =
|
||||
| {
|
||||
ok: true;
|
||||
slug: string;
|
||||
version: string;
|
||||
targetDir: string;
|
||||
detail?: ClawHubSkillDetail;
|
||||
warning?: string;
|
||||
}
|
||||
| { ok: false; error: string; code?: ClawHubTrustErrorCode; version?: string; warning?: string };
|
||||
|
||||
export function normalizeExpectedArtifactIntegrity(expectedIntegrity: string): string;
|
||||
export function normalizeExpectedArtifactIntegrity(expectedIntegrity: undefined): undefined;
|
||||
export function normalizeExpectedArtifactIntegrity(
|
||||
expectedIntegrity: string | undefined,
|
||||
): string | undefined;
|
||||
export function normalizeExpectedArtifactIntegrity(
|
||||
expectedIntegrity: string | undefined,
|
||||
): string | undefined {
|
||||
if (expectedIntegrity === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = normalizeClawHubSha256Integrity(expectedIntegrity);
|
||||
if (!normalized) {
|
||||
throw new Error(`Invalid expected ClawHub archive integrity: ${expectedIntegrity}`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function assertDownloadedArtifactIntegrity(
|
||||
archive: ClawHubDownloadResult,
|
||||
expectedIntegrity: string | undefined,
|
||||
): void {
|
||||
const normalizedExpected = normalizeExpectedArtifactIntegrity(expectedIntegrity);
|
||||
if (normalizedExpected && archive.integrity !== normalizedExpected) {
|
||||
throw new Error(
|
||||
`ClawHub archive integrity mismatch: expected ${normalizedExpected}, got ${archive.integrity}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type ClawHubOfficialFlagContainer = {
|
||||
channel?: unknown;
|
||||
official?: unknown;
|
||||
isOfficial?: unknown;
|
||||
};
|
||||
|
||||
function hasOfficialClawHubFlag(value: ClawHubOfficialFlagContainer | null | undefined): boolean {
|
||||
return value?.channel === "official" || value?.official === true || value?.isOfficial === true;
|
||||
}
|
||||
|
||||
export function isDefaultOfficialClawHubSkillSource(params: {
|
||||
baseUrl?: string;
|
||||
detail?: ClawHubSkillDetail;
|
||||
resolution?: Extract<ClawHubSkillInstallResolutionResponse, { ok: true }>;
|
||||
}): boolean {
|
||||
if (!isDefaultClawHubBaseUrl(params.baseUrl)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
hasOfficialClawHubFlag(params.detail?.skill) ||
|
||||
hasOfficialClawHubFlag(params.detail?.owner) ||
|
||||
hasOfficialClawHubFlag(params.resolution) ||
|
||||
(params.resolution?.installKind === "archive" &&
|
||||
hasOfficialClawHubFlag(params.resolution.archive))
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchDefaultClawHubSkillDetailIfOfficial(params: {
|
||||
baseUrl?: string;
|
||||
slug: string;
|
||||
ownerHandle?: string;
|
||||
}): Promise<ClawHubSkillDetail | undefined> {
|
||||
if (!isDefaultClawHubBaseUrl(params.baseUrl)) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const detail = await fetchClawHubSkillDetail({
|
||||
slug: params.slug,
|
||||
...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}),
|
||||
baseUrl: params.baseUrl,
|
||||
});
|
||||
return isDefaultOfficialClawHubSkillSource({ baseUrl: params.baseUrl, detail })
|
||||
? detail
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveInstallVersion(params: {
|
||||
slug: string;
|
||||
ownerHandle?: string;
|
||||
version?: string;
|
||||
baseUrl?: string;
|
||||
}): Promise<{ detail: ClawHubSkillDetail; version: string }> {
|
||||
const detail = await fetchClawHubSkillDetail({
|
||||
slug: params.slug,
|
||||
...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}),
|
||||
baseUrl: params.baseUrl,
|
||||
});
|
||||
if (!detail.skill) {
|
||||
throw new Error(`Skill "${params.slug}" not found on ClawHub.`);
|
||||
}
|
||||
const version = params.version ?? detail.latestVersion?.version;
|
||||
if (!version) {
|
||||
throw new Error(`Skill "${params.slug}" has no installable version.`);
|
||||
}
|
||||
return { detail, version };
|
||||
}
|
||||
|
||||
function normalizeGitHubSourcePath(raw: string): string {
|
||||
const parts = raw.replaceAll("\\", "/").split("/").filter(Boolean);
|
||||
if (parts.length === 0 || parts.some((part) => part === "." || part === "..")) {
|
||||
throw new Error(`Invalid GitHub skill source path: ${raw}`);
|
||||
}
|
||||
return parts.join("/");
|
||||
}
|
||||
|
||||
function buildGitHubTreeUrl(params: { repo: string; commit: string; sourcePath?: string }): string {
|
||||
const [owner, name] = params.repo.split("/") as [string, string];
|
||||
const segments = [
|
||||
owner,
|
||||
name,
|
||||
"tree",
|
||||
params.commit,
|
||||
...(params.sourcePath ? params.sourcePath.split("/") : []),
|
||||
];
|
||||
return `https://github.com/${segments.map(encodeURIComponent).join("/")}`;
|
||||
}
|
||||
|
||||
export function readVerifiedClawHubSkillSourceUrl(raw: unknown): string | undefined {
|
||||
const provenance =
|
||||
raw && typeof raw === "object" && !Array.isArray(raw)
|
||||
? (raw as Record<string, unknown>)
|
||||
: undefined;
|
||||
// Only this ClawHub variant is server-resolved; other provenance metadata
|
||||
// must not become a trusted source link.
|
||||
if (provenance?.source !== "server-resolved-github-import") {
|
||||
return undefined;
|
||||
}
|
||||
const repo = normalizeOptionalStringValue(provenance.repo);
|
||||
const repoParts = repo?.split("/");
|
||||
const commit = normalizeGitHubCommitSegment(provenance.commit);
|
||||
if (
|
||||
!repo ||
|
||||
repoParts?.length !== 2 ||
|
||||
repoParts.some((part) => !/^[A-Za-z0-9._-]+$/.test(part)) ||
|
||||
!commit
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const pathValue = normalizeOptionalStringValue(provenance.path);
|
||||
try {
|
||||
const sourcePath = pathValue ? normalizeGitHubSourcePath(pathValue) : undefined;
|
||||
return buildGitHubTreeUrl({ repo, commit, ...(sourcePath ? { sourcePath } : {}) });
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function buildDownloadedArtifactLock(
|
||||
archive: ClawHubDownloadResult,
|
||||
): ClawHubSkillDownloadedArtifactLock {
|
||||
return { kind: archive.artifact, sha256: archive.sha256Hex, integrity: archive.integrity };
|
||||
}
|
||||
|
||||
function snapshotClawHubSkillVerification(
|
||||
verification: ClawHubSkillVerificationResponse,
|
||||
): ClawHubSkillVerificationLock {
|
||||
return {
|
||||
schema: verification.schema,
|
||||
ok: verification.ok,
|
||||
decision: verification.decision,
|
||||
reasons: [...verification.reasons],
|
||||
...(verification.card !== undefined ? { card: verification.card } : {}),
|
||||
...(verification.artifact !== undefined ? { artifact: verification.artifact } : {}),
|
||||
...(verification.provenance !== undefined ? { provenance: verification.provenance } : {}),
|
||||
...(verification.security !== undefined ? { security: verification.security } : {}),
|
||||
...(verification.signature !== undefined ? { signature: verification.signature } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchInstallVerificationLock(params: {
|
||||
slug: string;
|
||||
ownerHandle?: string;
|
||||
requestedReference?: string;
|
||||
version?: string;
|
||||
baseUrl?: string;
|
||||
logger?: Logger;
|
||||
}): Promise<ClawHubSkillVerificationLock | undefined> {
|
||||
try {
|
||||
const verification = await fetchClawHubSkillVerification({
|
||||
slug: params.slug,
|
||||
...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}),
|
||||
...(params.requestedReference ? { requestedReference: params.requestedReference } : {}),
|
||||
version: params.version,
|
||||
baseUrl: params.baseUrl,
|
||||
});
|
||||
return snapshotClawHubSkillVerification(verification);
|
||||
} catch (err) {
|
||||
params.logger?.warn?.(
|
||||
`Skill verification for ${formatClawHubSkillRef(params)} failed: ${formatErrorMessage(err)}`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function readInstalledSkillFileLock(
|
||||
skillDir: string,
|
||||
): Promise<ClawHubSkillFileLock | undefined> {
|
||||
for (const marker of CLAWHUB_SKILL_ARCHIVE_ROOT_MARKERS) {
|
||||
try {
|
||||
return { path: marker, sha256: sha256Hex(await fs.readFile(path.join(skillDir, marker))) };
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveGitHubSkillSourceDir(repoRoot: string, sourcePath: string): string {
|
||||
return path.join(repoRoot, ...normalizeGitHubSourcePath(sourcePath).split("/"));
|
||||
}
|
||||
|
||||
async function installArchiveResolution(params: {
|
||||
workspaceDir: string;
|
||||
slug: string;
|
||||
ownerHandle?: string;
|
||||
version: string;
|
||||
archivePath: string;
|
||||
registry: string;
|
||||
authority: "official" | "openclaw" | "third-party";
|
||||
force?: boolean;
|
||||
logger?: Logger;
|
||||
config?: OpenClawConfig;
|
||||
}) {
|
||||
return await withExtractedArchiveRoot({
|
||||
archivePath: params.archivePath,
|
||||
tempDirPrefix: "openclaw-skill-clawhub-",
|
||||
timeoutMs: 120_000,
|
||||
rootMarkers: CLAWHUB_SKILL_ARCHIVE_ROOT_MARKERS,
|
||||
onExtracted: async (rootDir) =>
|
||||
await installExtractedSkillRoot({
|
||||
workspaceDir: params.workspaceDir,
|
||||
slug: params.slug,
|
||||
extractedRoot: rootDir,
|
||||
mode: params.force ? "update" : "install",
|
||||
logger: params.logger,
|
||||
policy: {
|
||||
config: params.config,
|
||||
installId: "clawhub",
|
||||
origin: {
|
||||
type: "clawhub",
|
||||
registry: params.registry,
|
||||
slug: params.slug,
|
||||
...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}),
|
||||
version: params.version,
|
||||
},
|
||||
source: { kind: "clawhub", authority: params.authority, mutable: false, network: true },
|
||||
requestedSpecifier: `clawhub:${formatClawHubSkillRef(params)}@${params.version}`,
|
||||
},
|
||||
rootMarkers: CLAWHUB_SKILL_ARCHIVE_ROOT_MARKERS,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function installGitHubResolution(params: {
|
||||
workspaceDir: string;
|
||||
slug: string;
|
||||
ownerHandle?: string;
|
||||
sourcePath: string;
|
||||
archivePath: string;
|
||||
registry: string;
|
||||
authority: "official" | "third-party";
|
||||
repo: string;
|
||||
commit: string;
|
||||
requestedReference?: string;
|
||||
trustState?: ClawHubSkillsShTrustState;
|
||||
force?: boolean;
|
||||
logger?: Logger;
|
||||
config?: OpenClawConfig;
|
||||
}) {
|
||||
// Preserve the repository root for sourcePath selection. Root markers validate
|
||||
// the selected skill directory afterward, so nested paths are not applied twice.
|
||||
return await withExtractedArchiveRoot({
|
||||
archivePath: params.archivePath,
|
||||
tempDirPrefix: "openclaw-skill-clawhub-github-",
|
||||
timeoutMs: 120_000,
|
||||
onExtracted: async (repoRoot) =>
|
||||
await installExtractedSkillRoot({
|
||||
workspaceDir: params.workspaceDir,
|
||||
slug: params.slug,
|
||||
extractedRoot: resolveGitHubSkillSourceDir(repoRoot, params.sourcePath),
|
||||
mode: params.force ? "update" : "install",
|
||||
logger: params.logger,
|
||||
policy: {
|
||||
config: params.config,
|
||||
installId: "clawhub",
|
||||
origin: {
|
||||
type: "clawhub",
|
||||
registry: params.registry,
|
||||
slug: params.slug,
|
||||
...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}),
|
||||
version: params.commit,
|
||||
repo: params.repo,
|
||||
path: params.sourcePath,
|
||||
commit: params.commit,
|
||||
...(params.requestedReference ? { reference: params.requestedReference } : {}),
|
||||
...(params.trustState ? { trustState: params.trustState } : {}),
|
||||
},
|
||||
source: { kind: "git", authority: params.authority, mutable: false, network: true },
|
||||
requestedSpecifier:
|
||||
params.requestedReference ??
|
||||
`clawhub:${formatClawHubSkillRef(params)}@${params.commit}`,
|
||||
},
|
||||
rootMarkers: CLAWHUB_SKILL_ARCHIVE_ROOT_MARKERS,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function assertInstallResolutionAllowed(
|
||||
resolution: ClawHubSkillInstallResolutionResponse,
|
||||
): Extract<ClawHubSkillInstallResolutionResponse, { ok: true }> {
|
||||
if (!resolution.ok) {
|
||||
if (resolution.reason === "ambiguous_slug") {
|
||||
const message = resolution.message ? ` ${resolution.message}` : "";
|
||||
throw new Error(
|
||||
`Skill "${resolution.slug}" is ambiguous on ClawHub. Install an owner-qualified skill, for example: openclaw skills install @owner/${resolution.slug}.${message}`,
|
||||
);
|
||||
}
|
||||
throw new Error(resolution.message || `Skill "${resolution.slug}" is not installable.`);
|
||||
}
|
||||
if (resolution.installKind !== "github") {
|
||||
return resolution;
|
||||
}
|
||||
const commit = normalizeGitHubCommitSegment(resolution.github.commit)?.toLowerCase();
|
||||
if (!commit) {
|
||||
throw new Error(
|
||||
`Skill "${resolution.slug}" resolved to a mutable or invalid GitHub source ref; expected a full 40-character commit SHA.`,
|
||||
);
|
||||
}
|
||||
return { ...resolution, github: { ...resolution.github, commit } };
|
||||
}
|
||||
|
||||
export async function ensureClawHubSkillTrustAcknowledged(
|
||||
params: ClawHubInstallParams & { version: string; skipClawHubTrustCheck?: boolean },
|
||||
): Promise<
|
||||
| { ok: true; warning?: string }
|
||||
| { ok: false; error: string; code?: ClawHubTrustErrorCode; warning?: string }
|
||||
> {
|
||||
if (params.skipClawHubTrustCheck) {
|
||||
return { ok: true };
|
||||
}
|
||||
const result = await ensureClawHubPackageTrustAcknowledged({
|
||||
subject: {
|
||||
kind: "skill",
|
||||
packageName: params.slug,
|
||||
...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}),
|
||||
},
|
||||
version: params.version,
|
||||
baseUrl: params.baseUrl,
|
||||
acknowledgeClawHubRisk: params.acknowledgeClawHubRisk,
|
||||
onClawHubRisk: params.onClawHubRisk,
|
||||
logger: params.logger,
|
||||
mode: params.force ? "update" : "install",
|
||||
});
|
||||
return result.ok
|
||||
? { ok: true, ...(result.warning ? { warning: result.warning } : {}) }
|
||||
: {
|
||||
ok: false,
|
||||
error: result.error,
|
||||
...(result.code ? { code: result.code } : {}),
|
||||
...(result.warning ? { warning: result.warning } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function performClawHubSkillInstall(
|
||||
params: ClawHubInstallParams,
|
||||
): Promise<InstallClawHubSkillResult> {
|
||||
try {
|
||||
normalizeExpectedArtifactIntegrity(params.expectedIntegrity);
|
||||
const targetDir = resolveWorkspaceSkillInstallDir(params.workspaceDir, params.slug);
|
||||
const registry = resolveClawHubBaseUrl(params.baseUrl);
|
||||
if (!params.force && (await pathExists(targetDir))) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Skill already exists at ${targetDir}. Re-run with force/update.`,
|
||||
};
|
||||
}
|
||||
|
||||
let version: string;
|
||||
let detail: ClawHubSkillDetail | undefined;
|
||||
let resolution: Extract<ClawHubSkillInstallResolutionResponse, { ok: true }> | undefined;
|
||||
let trustWarning: string | undefined;
|
||||
let official = false;
|
||||
let archive: ClawHubDownloadResult;
|
||||
if (params.version) {
|
||||
const resolved = await resolveInstallVersion(params);
|
||||
detail = resolved.detail;
|
||||
version = resolved.version;
|
||||
official = isDefaultOfficialClawHubSkillSource({ baseUrl: params.baseUrl, detail });
|
||||
const trust = await ensureClawHubSkillTrustAcknowledged({
|
||||
...params,
|
||||
version,
|
||||
skipClawHubTrustCheck: official,
|
||||
});
|
||||
if (!trust.ok) {
|
||||
return { ...trust, version };
|
||||
}
|
||||
trustWarning = trust.warning;
|
||||
params.logger?.info?.(`Downloading ${params.slug}@${version} from ClawHub…`);
|
||||
archive = await downloadClawHubSkillArchive({
|
||||
slug: params.slug,
|
||||
...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}),
|
||||
version,
|
||||
baseUrl: params.baseUrl,
|
||||
});
|
||||
} else {
|
||||
resolution = assertInstallResolutionAllowed(
|
||||
await fetchClawHubSkillInstallResolution({
|
||||
slug: params.slug,
|
||||
...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}),
|
||||
...(params.requestedReference ? { requestedReference: params.requestedReference } : {}),
|
||||
baseUrl: params.baseUrl,
|
||||
...(params.forceInstall ? { forceInstall: true } : {}),
|
||||
}),
|
||||
);
|
||||
if (params.requestedReference) {
|
||||
if (
|
||||
resolution.installKind !== "github" ||
|
||||
resolution.trust?.state !== CLAWHUB_SKILLS_SH_TRUST_STATE
|
||||
) {
|
||||
throw new Error(
|
||||
`Skill "${params.slug}" did not resolve to an unscanned, commit-pinned GitHub source.`,
|
||||
);
|
||||
}
|
||||
trustWarning = CLAWHUB_SKILLS_SH_TRUST_LABEL;
|
||||
params.logger?.warn?.(CLAWHUB_SKILLS_SH_TRUST_LABEL);
|
||||
}
|
||||
const resolutionOfficial = isDefaultOfficialClawHubSkillSource({
|
||||
baseUrl: params.baseUrl,
|
||||
resolution,
|
||||
});
|
||||
detail = resolutionOfficial
|
||||
? undefined
|
||||
: await fetchDefaultClawHubSkillDetailIfOfficial({
|
||||
baseUrl: params.baseUrl,
|
||||
slug: params.slug,
|
||||
...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}),
|
||||
});
|
||||
official = isDefaultOfficialClawHubSkillSource({
|
||||
baseUrl: params.baseUrl,
|
||||
detail,
|
||||
resolution,
|
||||
});
|
||||
if (resolution.installKind === "github") {
|
||||
version = resolution.github.commit;
|
||||
// GitHub-backed ClawHub skills are commit resolutions, not ClawHub skill
|
||||
// release versions; the install resolver owns their scan/force policy.
|
||||
params.logger?.info?.(`Downloading ${params.slug}@${version} from GitHub…`);
|
||||
archive = await downloadClawHubGitHubSkillArchive({
|
||||
repo: resolution.github.repo,
|
||||
commit: resolution.github.commit,
|
||||
});
|
||||
} else {
|
||||
version = resolution.archive.version;
|
||||
const trust = await ensureClawHubSkillTrustAcknowledged({
|
||||
...params,
|
||||
version,
|
||||
skipClawHubTrustCheck: official,
|
||||
});
|
||||
if (!trust.ok) {
|
||||
return { ...trust, version };
|
||||
}
|
||||
trustWarning = trust.warning;
|
||||
params.logger?.info?.(`Downloading ${params.slug}@${version} from ClawHub…`);
|
||||
archive = await downloadClawHubSkillArchiveUrl({
|
||||
url: resolution.archive.downloadUrl,
|
||||
baseUrl: params.baseUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
assertDownloadedArtifactIntegrity(archive, params.expectedIntegrity);
|
||||
if (!params.version && !resolution) {
|
||||
throw new Error(`Skill "${params.slug}" has no install resolution.`);
|
||||
}
|
||||
const install =
|
||||
resolution?.installKind === "github" && !params.version
|
||||
? await installGitHubResolution({
|
||||
workspaceDir: params.workspaceDir,
|
||||
slug: params.slug,
|
||||
...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}),
|
||||
sourcePath: resolution.github.path,
|
||||
archivePath: archive.archivePath,
|
||||
registry,
|
||||
authority: official ? "official" : "third-party",
|
||||
repo: resolution.github.repo,
|
||||
commit: resolution.github.commit,
|
||||
requestedReference: params.requestedReference,
|
||||
trustState: params.trustState,
|
||||
force: params.force,
|
||||
logger: params.logger,
|
||||
config: params.config,
|
||||
})
|
||||
: await installArchiveResolution({
|
||||
workspaceDir: params.workspaceDir,
|
||||
slug: params.slug,
|
||||
...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}),
|
||||
version,
|
||||
archivePath: archive.archivePath,
|
||||
registry,
|
||||
authority: official
|
||||
? "official"
|
||||
: isDefaultClawHubBaseUrl(params.baseUrl)
|
||||
? "openclaw"
|
||||
: "third-party",
|
||||
force: params.force,
|
||||
logger: params.logger,
|
||||
config: params.config,
|
||||
});
|
||||
if (!install.ok) {
|
||||
return { ok: false, error: install.error };
|
||||
}
|
||||
|
||||
const installedAt = Date.now();
|
||||
const artifact = buildDownloadedArtifactLock(archive);
|
||||
const fileTreeSha256 = await digestClawHubSkillTree(install.targetDir);
|
||||
const verificationVersion =
|
||||
resolution?.installKind === "github" && !params.version ? undefined : version;
|
||||
const [skillFile, verification] = await Promise.all([
|
||||
readInstalledSkillFileLock(install.targetDir),
|
||||
fetchInstallVerificationLock({
|
||||
slug: params.slug,
|
||||
...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}),
|
||||
...(params.requestedReference ? { requestedReference: params.requestedReference } : {}),
|
||||
version: verificationVersion,
|
||||
baseUrl: params.baseUrl,
|
||||
logger: params.logger,
|
||||
}),
|
||||
]);
|
||||
const sourceUrl =
|
||||
(resolution?.installKind === "github"
|
||||
? normalizeOptionalStringValue(resolution.github.sourceUrl)
|
||||
: undefined) ?? readVerifiedClawHubSkillSourceUrl(verification?.provenance);
|
||||
const trackedMetadata = {
|
||||
...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}),
|
||||
...(params.requestedReference ? { requestedReference: params.requestedReference } : {}),
|
||||
...(params.trustState ? { trustState: params.trustState } : {}),
|
||||
installedAt,
|
||||
...(sourceUrl ? { sourceUrl } : {}),
|
||||
artifact,
|
||||
...(skillFile ? { skillFile } : {}),
|
||||
fileTreeSha256,
|
||||
};
|
||||
await writeClawHubSkillOrigin(install.targetDir, {
|
||||
version: 1,
|
||||
registry,
|
||||
slug: params.slug,
|
||||
...trackedMetadata,
|
||||
installedVersion: version,
|
||||
});
|
||||
const lock = await readClawHubSkillsLockfile(params.workspaceDir);
|
||||
lock.skills[params.slug] = {
|
||||
version,
|
||||
registry,
|
||||
...trackedMetadata,
|
||||
...(verification ? { verification } : {}),
|
||||
};
|
||||
await writeClawHubSkillsLockfile(params.workspaceDir, lock);
|
||||
if (!params.clawManaged) {
|
||||
markClawPackageIndependentlyOwned({
|
||||
kind: "skill",
|
||||
source: "clawhub",
|
||||
ref: params.slug,
|
||||
version,
|
||||
workspace: params.workspaceDir,
|
||||
});
|
||||
}
|
||||
await reportClawHubSkillInstallTelemetry({
|
||||
baseUrl: params.baseUrl,
|
||||
slug: params.slug,
|
||||
...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}),
|
||||
version,
|
||||
...(params.requestedReference ? { requestedReference: params.requestedReference } : {}),
|
||||
...(params.trustState ? { trustState: params.trustState } : {}),
|
||||
}).catch(() => undefined);
|
||||
return {
|
||||
ok: true,
|
||||
slug: params.slug,
|
||||
version,
|
||||
targetDir: install.targetDir,
|
||||
...(detail ? { detail } : {}),
|
||||
...(trustWarning ? { warning: trustWarning } : {}),
|
||||
};
|
||||
} finally {
|
||||
await archive.cleanup().catch(() => undefined);
|
||||
}
|
||||
} catch (err) {
|
||||
return { ok: false, error: formatErrorMessage(err) };
|
||||
}
|
||||
}
|
||||
491
src/skills/lifecycle/clawhub-status.ts
Normal file
491
src/skills/lifecycle/clawhub-status.ts
Normal file
@@ -0,0 +1,491 @@
|
||||
import fsSync from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
CLAWHUB_SKILLS_SH_TRUST_STATE,
|
||||
resolveClawHubBaseUrl,
|
||||
searchClawHubSkills,
|
||||
type ClawHubSkillSearchResult,
|
||||
type ClawHubSkillsShTrustState,
|
||||
} from "../../infra/clawhub.js";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import { normalizeTrackedSkillSlug, resolveWorkspaceSkillInstallDir } from "./archive-install.js";
|
||||
import {
|
||||
normalizeDownloadedArtifactLock,
|
||||
normalizeOptionalStringValue,
|
||||
normalizeSkillFileLock,
|
||||
normalizeStoredRegistry,
|
||||
parseRequestedClawHubSkillRef,
|
||||
readClawHubSkillOriginStatusSync,
|
||||
readClawHubSkillOriginStrict,
|
||||
readClawHubSkillsLockfile,
|
||||
readClawHubSkillsLockfileStatusSync,
|
||||
type ClawHubSkillDownloadedArtifactLock,
|
||||
type ClawHubSkillFileLock,
|
||||
type ClawHubSkillsLockfileStatusRead,
|
||||
} from "./clawhub-store.js";
|
||||
|
||||
const LOCAL_SKILL_CARD_FILENAME = "skill-card.md";
|
||||
const LOCAL_SKILL_CARD_MAX_BYTES = 256 * 1024;
|
||||
|
||||
export type ClawHubSkillStatusLink =
|
||||
| {
|
||||
status: "linked";
|
||||
valid: true;
|
||||
registry: string;
|
||||
slug: string;
|
||||
ownerHandle?: string;
|
||||
requestedReference?: string;
|
||||
trustState?: ClawHubSkillsShTrustState;
|
||||
installedVersion: string;
|
||||
installedAt: number;
|
||||
originPath: string;
|
||||
lockPath: string;
|
||||
sourceUrl?: string;
|
||||
artifact?: ClawHubSkillDownloadedArtifactLock;
|
||||
skillFile?: ClawHubSkillFileLock;
|
||||
fileTreeSha256?: string;
|
||||
}
|
||||
| {
|
||||
status: "invalid";
|
||||
valid: false;
|
||||
reason: string;
|
||||
registry?: string;
|
||||
slug?: string;
|
||||
installedVersion?: string;
|
||||
installedAt?: number;
|
||||
originPath?: string;
|
||||
lockPath?: string;
|
||||
};
|
||||
|
||||
export type LocalSkillCardStatus = {
|
||||
present: true;
|
||||
path: string;
|
||||
sizeBytes: number;
|
||||
};
|
||||
|
||||
type LocalSkillCardRead = LocalSkillCardStatus & { content?: string };
|
||||
type ClawHubSkillVerificationSelector = "installed-version" | "version" | "tag" | "latest";
|
||||
|
||||
type ClawHubSkillVerificationTargetResult =
|
||||
| {
|
||||
ok: true;
|
||||
slug: string;
|
||||
ownerHandle?: string;
|
||||
requestedReference?: string;
|
||||
trustState?: ClawHubSkillsShTrustState;
|
||||
baseUrl: string;
|
||||
version: string | undefined;
|
||||
tag: string | undefined;
|
||||
resolution: {
|
||||
source: "installed" | "registry";
|
||||
selector: ClawHubSkillVerificationSelector;
|
||||
registry: string;
|
||||
skillDir: string | undefined;
|
||||
installedVersion: string | undefined;
|
||||
};
|
||||
}
|
||||
| { ok: false; error: string };
|
||||
|
||||
function readRealPathSync(candidate: string): string | undefined {
|
||||
try {
|
||||
return fsSync.realpathSync.native(candidate);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function invalidLink(
|
||||
reason: string,
|
||||
details: Omit<Extract<ClawHubSkillStatusLink, { valid: false }>, "status" | "valid" | "reason">,
|
||||
): ClawHubSkillStatusLink {
|
||||
return { status: "invalid", valid: false, reason, ...details };
|
||||
}
|
||||
|
||||
export function resolveClawHubSkillStatusLinkSync(params: {
|
||||
workspaceDir: string;
|
||||
skillDir: string;
|
||||
skillKey: string;
|
||||
lockRead?: ClawHubSkillsLockfileStatusRead;
|
||||
lockfileScope?: "workspace" | "managed";
|
||||
}): ClawHubSkillStatusLink | undefined {
|
||||
const originRead = readClawHubSkillOriginStatusSync(params.skillDir);
|
||||
const lockRead = params.lockRead ?? readClawHubSkillsLockfileStatusSync(params.workspaceDir);
|
||||
const lockfileLabel = `${params.lockfileScope ?? "workspace"} ClawHub lockfile`;
|
||||
if (originRead.kind === "missing") {
|
||||
let trackedSlug: string;
|
||||
try {
|
||||
trackedSlug = normalizeTrackedSkillSlug(params.skillKey);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
const locked = lockRead.kind === "found" ? lockRead.lock.skills[trackedSlug] : undefined;
|
||||
if (!locked) {
|
||||
return undefined;
|
||||
}
|
||||
return invalidLink(
|
||||
`Skill "${trackedSlug}" is tracked by the ${lockfileLabel} but is missing local ClawHub origin metadata.`,
|
||||
{
|
||||
slug: trackedSlug,
|
||||
installedVersion: locked.version,
|
||||
installedAt: locked.installedAt,
|
||||
registry: normalizeStoredRegistry(locked.registry ?? resolveClawHubBaseUrl()),
|
||||
lockPath: lockRead.kind === "found" ? lockRead.path : undefined,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (originRead.kind === "malformed") {
|
||||
return invalidLink(
|
||||
`Malformed ClawHub origin metadata at ${originRead.path}: ${originRead.error}`,
|
||||
{
|
||||
originPath: originRead.path,
|
||||
lockPath: lockRead.kind === "found" ? lockRead.path : undefined,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const originDetails = {
|
||||
registry: originRead.origin.registry,
|
||||
installedVersion: originRead.origin.installedVersion,
|
||||
installedAt: originRead.origin.installedAt,
|
||||
originPath: originRead.path,
|
||||
};
|
||||
let trackedSlug: string;
|
||||
try {
|
||||
trackedSlug = normalizeTrackedSkillSlug(originRead.origin.slug);
|
||||
} catch (err) {
|
||||
return invalidLink(
|
||||
`Invalid ClawHub origin slug "${originRead.origin.slug}": ${formatErrorMessage(err)}`,
|
||||
{
|
||||
...originDetails,
|
||||
slug: originRead.origin.slug,
|
||||
lockPath: lockRead.kind === "found" ? lockRead.path : undefined,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (lockRead.kind === "missing") {
|
||||
return invalidLink(
|
||||
`Skill "${trackedSlug}" has ClawHub origin metadata but is not tracked by the ${lockfileLabel}.`,
|
||||
{ ...originDetails, slug: trackedSlug },
|
||||
);
|
||||
}
|
||||
if (lockRead.kind === "malformed") {
|
||||
return invalidLink(`Malformed ${lockfileLabel} at ${lockRead.path}: ${lockRead.error}`, {
|
||||
...originDetails,
|
||||
slug: trackedSlug,
|
||||
lockPath: lockRead.path,
|
||||
});
|
||||
}
|
||||
const locked = lockRead.lock.skills[trackedSlug];
|
||||
if (!locked) {
|
||||
return invalidLink(
|
||||
`Skill "${trackedSlug}" has ClawHub origin metadata but is not tracked by the ${lockfileLabel}.`,
|
||||
{ ...originDetails, slug: trackedSlug, lockPath: lockRead.path },
|
||||
);
|
||||
}
|
||||
const expectedSkillDir = readRealPathSync(
|
||||
resolveWorkspaceSkillInstallDir(params.workspaceDir, trackedSlug),
|
||||
);
|
||||
if (!expectedSkillDir || readRealPathSync(params.skillDir) !== expectedSkillDir) {
|
||||
return invalidLink(
|
||||
`Skill "${trackedSlug}" ClawHub origin metadata is not in the expected ClawHub install directory.`,
|
||||
{ ...originDetails, slug: trackedSlug, lockPath: lockRead.path },
|
||||
);
|
||||
}
|
||||
const originRegistry = normalizeStoredRegistry(originRead.origin.registry);
|
||||
const lockedRegistry =
|
||||
locked.registry === undefined ? originRegistry : normalizeStoredRegistry(locked.registry);
|
||||
const sourceUrl = normalizeOptionalStringValue(locked.sourceUrl);
|
||||
const ownerHandle = normalizeOptionalStringValue(locked.ownerHandle);
|
||||
const requestedReference = normalizeOptionalStringValue(locked.requestedReference);
|
||||
const trustState =
|
||||
locked.trustState === CLAWHUB_SKILLS_SH_TRUST_STATE ? CLAWHUB_SKILLS_SH_TRUST_STATE : undefined;
|
||||
const artifact = normalizeDownloadedArtifactLock(locked.artifact);
|
||||
const skillFile = normalizeSkillFileLock(locked.skillFile);
|
||||
const fileTreeSha256 = normalizeOptionalStringValue(locked.fileTreeSha256);
|
||||
// A linked status is a trust signal. Only expose provenance when both
|
||||
// install records agree, so a one-sided origin edit cannot become trusted.
|
||||
const provenanceMatches =
|
||||
originRead.origin.ownerHandle === ownerHandle &&
|
||||
originRead.origin.requestedReference === requestedReference &&
|
||||
originRead.origin.trustState === trustState &&
|
||||
originRead.origin.sourceUrl === sourceUrl &&
|
||||
originRead.origin.artifact?.kind === artifact?.kind &&
|
||||
originRead.origin.artifact?.sha256 === artifact?.sha256 &&
|
||||
originRead.origin.artifact?.integrity === artifact?.integrity &&
|
||||
originRead.origin.skillFile?.path === skillFile?.path &&
|
||||
originRead.origin.skillFile?.sha256 === skillFile?.sha256 &&
|
||||
originRead.origin.fileTreeSha256 === fileTreeSha256;
|
||||
if (
|
||||
locked.version !== originRead.origin.installedVersion ||
|
||||
locked.installedAt !== originRead.origin.installedAt ||
|
||||
lockedRegistry !== originRegistry ||
|
||||
!provenanceMatches
|
||||
) {
|
||||
return invalidLink(
|
||||
`Skill "${trackedSlug}" ClawHub origin metadata does not match the ${lockfileLabel}.`,
|
||||
{
|
||||
...originDetails,
|
||||
registry: lockedRegistry,
|
||||
slug: trackedSlug,
|
||||
lockPath: lockRead.path,
|
||||
},
|
||||
);
|
||||
}
|
||||
return {
|
||||
status: "linked",
|
||||
valid: true,
|
||||
registry: lockedRegistry,
|
||||
slug: trackedSlug,
|
||||
...(ownerHandle ? { ownerHandle } : {}),
|
||||
...(requestedReference ? { requestedReference } : {}),
|
||||
...(trustState ? { trustState } : {}),
|
||||
installedVersion: locked.version,
|
||||
installedAt: locked.installedAt,
|
||||
originPath: originRead.path,
|
||||
lockPath: lockRead.path,
|
||||
...(sourceUrl ? { sourceUrl } : {}),
|
||||
...(artifact ? { artifact } : {}),
|
||||
...(skillFile ? { skillFile } : {}),
|
||||
...(fileTreeSha256 ? { fileTreeSha256 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function isPathInsideDir(child: string, parent: string): boolean {
|
||||
const relative = path.relative(parent, child);
|
||||
return (
|
||||
relative === "" ||
|
||||
(relative.length > 0 && !relative.startsWith("..") && !path.isAbsolute(relative))
|
||||
);
|
||||
}
|
||||
|
||||
function readLocalSkillCardSync(
|
||||
skillDir: string,
|
||||
includeContent = false,
|
||||
): LocalSkillCardRead | undefined {
|
||||
const cardPath = path.join(skillDir, LOCAL_SKILL_CARD_FILENAME);
|
||||
let lstat: fsSync.Stats;
|
||||
try {
|
||||
lstat = fsSync.lstatSync(cardPath);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (!lstat.isFile() || lstat.size > LOCAL_SKILL_CARD_MAX_BYTES) {
|
||||
return undefined;
|
||||
}
|
||||
let fd: number | undefined;
|
||||
try {
|
||||
const rootRealPath = fsSync.realpathSync.native(skillDir);
|
||||
const cardRealPath = fsSync.realpathSync.native(cardPath);
|
||||
if (!isPathInsideDir(cardRealPath, rootRealPath)) {
|
||||
return undefined;
|
||||
}
|
||||
fd = fsSync.openSync(cardPath, fsSync.constants.O_RDONLY | (fsSync.constants.O_NOFOLLOW ?? 0));
|
||||
const fdStat = fsSync.fstatSync(fd);
|
||||
if (!fdStat.isFile() || fdStat.size > LOCAL_SKILL_CARD_MAX_BYTES) {
|
||||
return undefined;
|
||||
}
|
||||
const result: LocalSkillCardRead = {
|
||||
present: true,
|
||||
path: cardPath,
|
||||
sizeBytes: fdStat.size,
|
||||
};
|
||||
if (includeContent) {
|
||||
result.content = fsSync.readFileSync(fd, "utf8");
|
||||
}
|
||||
return result;
|
||||
} catch {
|
||||
return undefined;
|
||||
} finally {
|
||||
if (fd !== undefined) {
|
||||
try {
|
||||
fsSync.closeSync(fd);
|
||||
} catch {
|
||||
// ignore close errors while reporting the card as unavailable
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveLocalSkillCardStatusSync(
|
||||
skillDir: string,
|
||||
): LocalSkillCardStatus | undefined {
|
||||
return readLocalSkillCardSync(skillDir);
|
||||
}
|
||||
|
||||
export function readLocalSkillCardContentSync(skillDir: string): string | undefined {
|
||||
return readLocalSkillCardSync(skillDir, true)?.content;
|
||||
}
|
||||
|
||||
function normalizeOptionalSelector(value: string | undefined): string | undefined {
|
||||
return value?.trim() || undefined;
|
||||
}
|
||||
|
||||
export async function searchSkillsFromClawHub(params: {
|
||||
query?: string;
|
||||
limit?: number;
|
||||
baseUrl?: string;
|
||||
}): Promise<ClawHubSkillSearchResult[]> {
|
||||
return await searchClawHubSkills({
|
||||
query: params.query?.trim() || "*",
|
||||
limit: params.limit,
|
||||
baseUrl: params.baseUrl,
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveClawHubSkillVerificationTarget(params: {
|
||||
workspaceDir: string;
|
||||
slug: string;
|
||||
version?: string;
|
||||
tag?: string;
|
||||
baseUrl?: string;
|
||||
}): Promise<ClawHubSkillVerificationTargetResult> {
|
||||
try {
|
||||
const version = normalizeOptionalSelector(params.version);
|
||||
const tag = normalizeOptionalSelector(params.tag);
|
||||
if (version && tag) {
|
||||
return { ok: false, error: "Use either --version or --tag." };
|
||||
}
|
||||
const requestedRef = parseRequestedClawHubSkillRef(params.slug);
|
||||
if (requestedRef.requestedReference && (version || tag)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "--version and --tag are not supported for skills-sh references.",
|
||||
};
|
||||
}
|
||||
const trackedSlug = requestedRef.slug;
|
||||
const skillDir = resolveWorkspaceSkillInstallDir(params.workspaceDir, trackedSlug);
|
||||
const originRead = await readClawHubSkillOriginStrict(skillDir);
|
||||
if (originRead.kind === "malformed") {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Malformed ClawHub origin metadata at ${originRead.path}: ${originRead.error}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (originRead.kind === "found") {
|
||||
const locked = (await readClawHubSkillsLockfile(params.workspaceDir)).skills[trackedSlug];
|
||||
if (!locked) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Skill "${trackedSlug}" has ClawHub origin metadata but is not tracked by the workspace ClawHub lockfile. Reinstall it from ClawHub before verifying it as an installed ClawHub skill.`,
|
||||
};
|
||||
}
|
||||
const originSlug = normalizeTrackedSkillSlug(originRead.origin.slug);
|
||||
if (originSlug !== trackedSlug) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Skill "${trackedSlug}" has ClawHub origin metadata for "${originRead.origin.slug}". Reinstall it from ClawHub before verifying it as an installed ClawHub skill.`,
|
||||
};
|
||||
}
|
||||
const originRegistry = normalizeStoredRegistry(originRead.origin.registry);
|
||||
const lockedRegistry =
|
||||
locked.registry === undefined ? originRegistry : normalizeStoredRegistry(locked.registry);
|
||||
const ownerHandle = normalizeOptionalStringValue(locked.ownerHandle);
|
||||
const requestedReference = normalizeOptionalStringValue(locked.requestedReference);
|
||||
const trustState =
|
||||
locked.trustState === CLAWHUB_SKILLS_SH_TRUST_STATE
|
||||
? CLAWHUB_SKILLS_SH_TRUST_STATE
|
||||
: undefined;
|
||||
if (
|
||||
locked.version !== originRead.origin.installedVersion ||
|
||||
locked.installedAt !== originRead.origin.installedAt ||
|
||||
lockedRegistry !== originRegistry ||
|
||||
originRead.origin.ownerHandle !== ownerHandle ||
|
||||
originRead.origin.requestedReference !== requestedReference ||
|
||||
originRead.origin.trustState !== trustState
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Skill "${trackedSlug}" ClawHub origin metadata does not match the workspace ClawHub lockfile. Reinstall it from ClawHub before verifying it as an installed ClawHub skill.`,
|
||||
};
|
||||
}
|
||||
if (requestedReference && (version || tag)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "--version and --tag are not supported for skills-sh references.",
|
||||
};
|
||||
}
|
||||
if (requestedRef.ownerHandle && ownerHandle !== requestedRef.ownerHandle) {
|
||||
const trackedRef = ownerHandle ? `@${ownerHandle}/${trackedSlug}` : trackedSlug;
|
||||
return {
|
||||
ok: false,
|
||||
error: `Skill "${trackedSlug}" is tracked as ${trackedRef}, not @${requestedRef.ownerHandle}/${trackedSlug}.`,
|
||||
};
|
||||
}
|
||||
if (
|
||||
requestedRef.requestedReference &&
|
||||
requestedReference !== requestedRef.requestedReference
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Skill "${trackedSlug}" is not tracked from ${requestedRef.requestedReference}.`,
|
||||
};
|
||||
}
|
||||
const selector: ClawHubSkillVerificationSelector = version
|
||||
? "version"
|
||||
: tag
|
||||
? "tag"
|
||||
: "installed-version";
|
||||
// ClawHub's skills.sh verify route accepts the catalog reference as its sole selector.
|
||||
// It rejects version/tag; the stored commit remains local installed provenance.
|
||||
const verificationVersion = requestedReference
|
||||
? undefined
|
||||
: (version ?? (tag ? undefined : locked.version));
|
||||
return {
|
||||
ok: true,
|
||||
slug: trackedSlug,
|
||||
...(ownerHandle ? { ownerHandle } : {}),
|
||||
...(requestedReference ? { requestedReference } : {}),
|
||||
...(trustState ? { trustState } : {}),
|
||||
baseUrl: lockedRegistry,
|
||||
version: verificationVersion,
|
||||
tag: requestedReference ? undefined : tag,
|
||||
resolution: {
|
||||
source: "installed",
|
||||
selector,
|
||||
registry: lockedRegistry,
|
||||
skillDir,
|
||||
installedVersion: locked.version,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const lockRead = readClawHubSkillsLockfileStatusSync(params.workspaceDir);
|
||||
if (lockRead.kind === "malformed") {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Malformed workspace ClawHub lockfile at ${lockRead.path}: ${lockRead.error}`,
|
||||
};
|
||||
}
|
||||
if (lockRead.kind === "found" && lockRead.lock.skills[trackedSlug]) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Skill "${trackedSlug}" is tracked by the workspace ClawHub lockfile but is missing ClawHub origin metadata. Reinstall it from ClawHub before verifying it as an installed ClawHub skill.`,
|
||||
};
|
||||
}
|
||||
const registry = resolveClawHubBaseUrl(params.baseUrl);
|
||||
const selector: ClawHubSkillVerificationSelector = version ? "version" : tag ? "tag" : "latest";
|
||||
return {
|
||||
ok: true,
|
||||
slug: requestedRef.slug,
|
||||
...(requestedRef.ownerHandle ? { ownerHandle: requestedRef.ownerHandle } : {}),
|
||||
...(requestedRef.requestedReference
|
||||
? { requestedReference: requestedRef.requestedReference }
|
||||
: {}),
|
||||
...(requestedRef.trustState ? { trustState: requestedRef.trustState } : {}),
|
||||
baseUrl: registry,
|
||||
version,
|
||||
tag,
|
||||
resolution: {
|
||||
source: "registry",
|
||||
selector,
|
||||
registry,
|
||||
skillDir: undefined,
|
||||
installedVersion: undefined,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
return { ok: false, error: formatErrorMessage(err) };
|
||||
}
|
||||
}
|
||||
422
src/skills/lifecycle/clawhub-store.ts
Normal file
422
src/skills/lifecycle/clawhub-store.ts
Normal file
@@ -0,0 +1,422 @@
|
||||
import fsSync from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
CLAWHUB_SKILLS_SH_TRUST_STATE,
|
||||
type ClawHubDownloadResult,
|
||||
type ClawHubSkillVerificationResponse,
|
||||
type ClawHubSkillsShTrustState,
|
||||
} from "../../infra/clawhub.js";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import { readJsonIfExists, tryReadJson, writeJson } from "../../infra/json-files.js";
|
||||
import { normalizeTrackedSkillSlug, validateRequestedSkillSlug } from "./archive-install.js";
|
||||
|
||||
const DOT_DIR = ".clawhub";
|
||||
const LEGACY_DOT_DIR = ".clawdhub";
|
||||
const CLAWHUB_OWNER_HANDLE_PATTERN = /^[a-z0-9](?:[a-z0-9._-]{0,38}[a-z0-9])?$/;
|
||||
const GITHUB_OWNER_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/;
|
||||
const GITHUB_REPO_PATTERN = /^[A-Za-z0-9._-]{1,100}$/;
|
||||
|
||||
export type ClawHubSkillDownloadedArtifactLock = {
|
||||
kind: ClawHubDownloadResult["artifact"];
|
||||
sha256: string;
|
||||
integrity: string;
|
||||
};
|
||||
|
||||
export type ClawHubSkillFileLock = {
|
||||
path: string;
|
||||
sha256: string;
|
||||
};
|
||||
|
||||
export type ClawHubSkillVerificationLock = {
|
||||
schema: ClawHubSkillVerificationResponse["schema"];
|
||||
ok: boolean;
|
||||
decision: ClawHubSkillVerificationResponse["decision"];
|
||||
reasons: string[];
|
||||
card?: unknown;
|
||||
artifact?: unknown;
|
||||
provenance?: unknown;
|
||||
security?: unknown;
|
||||
signature?: unknown;
|
||||
};
|
||||
|
||||
type ClawHubSkillLockEntry = {
|
||||
version: string;
|
||||
installedAt: number;
|
||||
registry?: string;
|
||||
ownerHandle?: string;
|
||||
requestedReference?: string;
|
||||
trustState?: ClawHubSkillsShTrustState;
|
||||
sourceUrl?: string;
|
||||
artifact?: ClawHubSkillDownloadedArtifactLock;
|
||||
skillFile?: ClawHubSkillFileLock;
|
||||
fileTreeSha256?: string;
|
||||
verification?: ClawHubSkillVerificationLock;
|
||||
};
|
||||
|
||||
type ClawHubSkillOrigin = {
|
||||
version: 1;
|
||||
registry: string;
|
||||
slug: string;
|
||||
ownerHandle?: string;
|
||||
requestedReference?: string;
|
||||
trustState?: ClawHubSkillsShTrustState;
|
||||
installedVersion: string;
|
||||
installedAt: number;
|
||||
sourceUrl?: string;
|
||||
artifact?: ClawHubSkillDownloadedArtifactLock;
|
||||
skillFile?: ClawHubSkillFileLock;
|
||||
fileTreeSha256?: string;
|
||||
};
|
||||
|
||||
export type ClawHubSkillsLockfile = {
|
||||
version: 1;
|
||||
skills: Record<string, ClawHubSkillLockEntry>;
|
||||
};
|
||||
|
||||
export type ClawHubSkillsLockfileStatusRead =
|
||||
| { kind: "found"; lock: ClawHubSkillsLockfile; path: string }
|
||||
| { kind: "missing" }
|
||||
| { kind: "malformed"; path: string; error: string };
|
||||
|
||||
export type ClawHubSkillRef = {
|
||||
slug: string;
|
||||
ownerHandle?: string;
|
||||
requestedReference?: string;
|
||||
trustState?: ClawHubSkillsShTrustState;
|
||||
};
|
||||
|
||||
type StrictOriginReadResult =
|
||||
| { kind: "found"; origin: ClawHubSkillOrigin; path: string }
|
||||
| { kind: "missing" }
|
||||
| { kind: "malformed"; path: string; error: string };
|
||||
|
||||
function metadataPaths(rootDir: string, filename: string): string[] {
|
||||
return [path.join(rootDir, DOT_DIR, filename), path.join(rootDir, LEGACY_DOT_DIR, filename)];
|
||||
}
|
||||
|
||||
function normalizeClawHubOwnerHandle(raw: string): string {
|
||||
const ownerHandle = raw.trim().toLowerCase();
|
||||
if (!CLAWHUB_OWNER_HANDLE_PATTERN.test(ownerHandle)) {
|
||||
throw new Error(`Invalid ClawHub owner handle: ${raw}`);
|
||||
}
|
||||
return ownerHandle;
|
||||
}
|
||||
|
||||
export function parseRequestedClawHubSkillRef(raw: string): ClawHubSkillRef {
|
||||
const value = raw.trim();
|
||||
if (value.startsWith("skills-sh/")) {
|
||||
throw new Error(`Invalid skills.sh skill reference: ${raw}`);
|
||||
}
|
||||
if (value.startsWith("skills-sh:")) {
|
||||
const parts = value.slice("skills-sh:".length).split("/");
|
||||
if (parts.length !== 3) {
|
||||
throw new Error(`Invalid skills.sh skill reference: ${raw}`);
|
||||
}
|
||||
const [owner, repo, slug] = parts;
|
||||
if (
|
||||
!owner ||
|
||||
!repo ||
|
||||
!slug ||
|
||||
!GITHUB_OWNER_PATTERN.test(owner) ||
|
||||
!GITHUB_REPO_PATTERN.test(repo) ||
|
||||
repo === "." ||
|
||||
repo === ".."
|
||||
) {
|
||||
throw new Error(`Invalid skills.sh skill reference: ${raw}`);
|
||||
}
|
||||
return {
|
||||
slug: validateRequestedSkillSlug(slug),
|
||||
requestedReference: value,
|
||||
trustState: CLAWHUB_SKILLS_SH_TRUST_STATE,
|
||||
};
|
||||
}
|
||||
if (!value.startsWith("@")) {
|
||||
return { slug: validateRequestedSkillSlug(value) };
|
||||
}
|
||||
const parts = value.slice(1).split("/");
|
||||
if (parts.length !== 2) {
|
||||
throw new Error(`Invalid ClawHub skill reference: ${raw}`);
|
||||
}
|
||||
const [owner, slug] = parts;
|
||||
if (!owner || !slug) {
|
||||
throw new Error(`Invalid ClawHub skill reference: ${raw}`);
|
||||
}
|
||||
return {
|
||||
ownerHandle: normalizeClawHubOwnerHandle(owner),
|
||||
slug: validateRequestedSkillSlug(slug),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatClawHubSkillRef(ref: ClawHubSkillRef): string {
|
||||
return ref.ownerHandle ? `@${ref.ownerHandle}/${ref.slug}` : ref.slug;
|
||||
}
|
||||
|
||||
export function normalizeStoredRegistry(registry: string): string {
|
||||
const trimmed = registry.trim();
|
||||
return trimmed.replace(/\/+$/, "") || trimmed;
|
||||
}
|
||||
|
||||
export function normalizeOptionalStringValue(raw: unknown): string | undefined {
|
||||
return typeof raw === "string" && raw.trim() ? raw.trim() : undefined;
|
||||
}
|
||||
|
||||
export function normalizeGitHubCommitSegment(raw: unknown): string | undefined {
|
||||
const commit = normalizeOptionalStringValue(raw);
|
||||
return commit && /^[0-9a-f]{40}$/i.test(commit) ? commit : undefined;
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
export function normalizeDownloadedArtifactLock(
|
||||
raw: unknown,
|
||||
): ClawHubSkillDownloadedArtifactLock | undefined {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const candidate = raw as Partial<ClawHubSkillDownloadedArtifactLock>;
|
||||
if (
|
||||
(candidate.kind === "archive" || candidate.kind === "clawpack") &&
|
||||
isNonEmptyString(candidate.sha256) &&
|
||||
isNonEmptyString(candidate.integrity)
|
||||
) {
|
||||
return { kind: candidate.kind, sha256: candidate.sha256, integrity: candidate.integrity };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function normalizeSkillFileLock(raw: unknown): ClawHubSkillFileLock | undefined {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const candidate = raw as Partial<ClawHubSkillFileLock>;
|
||||
return isNonEmptyString(candidate.path) && isNonEmptyString(candidate.sha256)
|
||||
? { path: candidate.path, sha256: candidate.sha256 }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function normalizeClawHubSkillOrigin(
|
||||
raw: Partial<ClawHubSkillOrigin> | null,
|
||||
): ClawHubSkillOrigin | null {
|
||||
if (
|
||||
raw?.version !== 1 ||
|
||||
!isNonEmptyString(raw.registry) ||
|
||||
!isNonEmptyString(raw.slug) ||
|
||||
!isNonEmptyString(raw.installedVersion) ||
|
||||
typeof raw.installedAt !== "number"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const sourceUrl = normalizeOptionalStringValue((raw as { sourceUrl?: unknown }).sourceUrl);
|
||||
const ownerHandleRaw = normalizeOptionalStringValue(
|
||||
(raw as { ownerHandle?: unknown }).ownerHandle,
|
||||
);
|
||||
let ownerHandle: string | undefined;
|
||||
if (ownerHandleRaw) {
|
||||
try {
|
||||
ownerHandle = normalizeClawHubOwnerHandle(ownerHandleRaw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const requestedReferenceRaw = normalizeOptionalStringValue(
|
||||
(raw as { requestedReference?: unknown }).requestedReference,
|
||||
);
|
||||
let requestedReference: string | undefined;
|
||||
let trustState: ClawHubSkillsShTrustState | undefined;
|
||||
if (requestedReferenceRaw) {
|
||||
try {
|
||||
const parsed = parseRequestedClawHubSkillRef(requestedReferenceRaw);
|
||||
if (!parsed.requestedReference || parsed.slug !== raw.slug) {
|
||||
return null;
|
||||
}
|
||||
requestedReference = parsed.requestedReference;
|
||||
const rawTrustState = normalizeOptionalStringValue(
|
||||
(raw as { trustState?: unknown }).trustState,
|
||||
);
|
||||
if (rawTrustState !== CLAWHUB_SKILLS_SH_TRUST_STATE) {
|
||||
return null;
|
||||
}
|
||||
trustState = CLAWHUB_SKILLS_SH_TRUST_STATE;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
} else if ((raw as { trustState?: unknown }).trustState !== undefined) {
|
||||
return null;
|
||||
}
|
||||
const artifact = normalizeDownloadedArtifactLock((raw as { artifact?: unknown }).artifact);
|
||||
const skillFile = normalizeSkillFileLock((raw as { skillFile?: unknown }).skillFile);
|
||||
const fileTreeSha256 = normalizeOptionalStringValue(
|
||||
(raw as { fileTreeSha256?: unknown }).fileTreeSha256,
|
||||
);
|
||||
return {
|
||||
version: 1,
|
||||
registry: normalizeStoredRegistry(raw.registry),
|
||||
slug: raw.slug,
|
||||
...(ownerHandle ? { ownerHandle } : {}),
|
||||
...(requestedReference ? { requestedReference } : {}),
|
||||
...(trustState ? { trustState } : {}),
|
||||
installedVersion: raw.installedVersion,
|
||||
installedAt: raw.installedAt,
|
||||
...(sourceUrl ? { sourceUrl } : {}),
|
||||
...(artifact ? { artifact } : {}),
|
||||
...(skillFile ? { skillFile } : {}),
|
||||
...(fileTreeSha256 ? { fileTreeSha256 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function readClawHubSkillsLockfile(
|
||||
workspaceDir: string,
|
||||
): Promise<ClawHubSkillsLockfile> {
|
||||
for (const candidate of metadataPaths(workspaceDir, "lock.json")) {
|
||||
try {
|
||||
const raw = await tryReadJson<Partial<ClawHubSkillsLockfile>>(candidate);
|
||||
if (raw?.version === 1 && raw.skills && typeof raw.skills === "object") {
|
||||
return { version: 1, skills: raw.skills };
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return { version: 1, skills: {} };
|
||||
}
|
||||
|
||||
export async function writeClawHubSkillsLockfile(
|
||||
workspaceDir: string,
|
||||
lockfile: ClawHubSkillsLockfile,
|
||||
): Promise<void> {
|
||||
await writeJson(path.join(workspaceDir, DOT_DIR, "lock.json"), lockfile, {
|
||||
trailingNewline: true,
|
||||
});
|
||||
}
|
||||
|
||||
function readJsonIfExistsSync(
|
||||
candidate: string,
|
||||
): { exists: false } | { exists: true; value: unknown } {
|
||||
try {
|
||||
return { exists: true, value: JSON.parse(fsSync.readFileSync(candidate, "utf8")) };
|
||||
} catch (err) {
|
||||
if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") {
|
||||
return { exists: false };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export function readClawHubSkillsLockfileStatusSync(
|
||||
workspaceDir: string,
|
||||
): ClawHubSkillsLockfileStatusRead {
|
||||
for (const candidate of metadataPaths(workspaceDir, "lock.json")) {
|
||||
try {
|
||||
const read = readJsonIfExistsSync(candidate);
|
||||
if (!read.exists) {
|
||||
continue;
|
||||
}
|
||||
const raw = read.value as Partial<ClawHubSkillsLockfile>;
|
||||
return raw?.version === 1 && raw.skills && typeof raw.skills === "object"
|
||||
? { kind: "found", path: candidate, lock: { version: 1, skills: raw.skills } }
|
||||
: {
|
||||
kind: "malformed",
|
||||
path: candidate,
|
||||
error: "expected version 1 lockfile with skills",
|
||||
};
|
||||
} catch (err) {
|
||||
return { kind: "malformed", path: candidate, error: formatErrorMessage(err) };
|
||||
}
|
||||
}
|
||||
return { kind: "missing" };
|
||||
}
|
||||
|
||||
function originResult(
|
||||
raw: Partial<ClawHubSkillOrigin> | null,
|
||||
candidate: string,
|
||||
): StrictOriginReadResult {
|
||||
const origin = normalizeClawHubSkillOrigin(raw);
|
||||
return origin
|
||||
? { kind: "found", origin, path: candidate }
|
||||
: {
|
||||
kind: "malformed",
|
||||
path: candidate,
|
||||
error: "expected version 1 origin with registry, slug, installedVersion, and installedAt",
|
||||
};
|
||||
}
|
||||
|
||||
export async function readClawHubSkillOrigin(skillDir: string): Promise<ClawHubSkillOrigin | null> {
|
||||
for (const candidate of metadataPaths(skillDir, "origin.json")) {
|
||||
try {
|
||||
const origin = normalizeClawHubSkillOrigin(
|
||||
await tryReadJson<Partial<ClawHubSkillOrigin>>(candidate),
|
||||
);
|
||||
if (origin) {
|
||||
return origin;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function readClawHubSkillOriginStatusSync(skillDir: string): StrictOriginReadResult {
|
||||
for (const candidate of metadataPaths(skillDir, "origin.json")) {
|
||||
try {
|
||||
const read = readJsonIfExistsSync(candidate);
|
||||
if (read.exists) {
|
||||
return originResult(read.value as Partial<ClawHubSkillOrigin>, candidate);
|
||||
}
|
||||
} catch (err) {
|
||||
return { kind: "malformed", path: candidate, error: formatErrorMessage(err) };
|
||||
}
|
||||
}
|
||||
return { kind: "missing" };
|
||||
}
|
||||
|
||||
export async function readClawHubSkillOriginStrict(
|
||||
skillDir: string,
|
||||
): Promise<StrictOriginReadResult> {
|
||||
for (const candidate of metadataPaths(skillDir, "origin.json")) {
|
||||
try {
|
||||
const raw = await readJsonIfExists<Partial<ClawHubSkillOrigin>>(candidate);
|
||||
if (raw) {
|
||||
return originResult(raw, candidate);
|
||||
}
|
||||
} catch (err) {
|
||||
return { kind: "malformed", path: candidate, error: formatErrorMessage(err) };
|
||||
}
|
||||
}
|
||||
return { kind: "missing" };
|
||||
}
|
||||
|
||||
export async function writeClawHubSkillOrigin(
|
||||
skillDir: string,
|
||||
origin: ClawHubSkillOrigin,
|
||||
): Promise<void> {
|
||||
await writeJson(path.join(skillDir, DOT_DIR, "origin.json"), origin, { trailingNewline: true });
|
||||
}
|
||||
|
||||
export async function readTrackedClawHubSkillSlugs(workspaceDir: string): Promise<string[]> {
|
||||
return Object.keys((await readClawHubSkillsLockfile(workspaceDir)).skills).toSorted();
|
||||
}
|
||||
|
||||
export async function untrackClawHubSkill(
|
||||
workspaceDir: string,
|
||||
slug: string,
|
||||
): Promise<() => Promise<void>> {
|
||||
const trackedSlug = normalizeTrackedSkillSlug(slug);
|
||||
const lock = await readClawHubSkillsLockfile(workspaceDir);
|
||||
const previous = lock.skills[trackedSlug];
|
||||
if (!previous) {
|
||||
return async () => undefined;
|
||||
}
|
||||
delete lock.skills[trackedSlug];
|
||||
await writeClawHubSkillsLockfile(workspaceDir, lock);
|
||||
return async () => {
|
||||
const current = await readClawHubSkillsLockfile(workspaceDir);
|
||||
if (current.skills[trackedSlug]) {
|
||||
throw new Error(`Skill ${JSON.stringify(trackedSlug)} was retracked during rollback.`);
|
||||
}
|
||||
current.skills[trackedSlug] = previous;
|
||||
await writeClawHubSkillsLockfile(workspaceDir, current);
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user