diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 3a3166a778fd..c33043f72769 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -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 diff --git a/src/skills/lifecycle/clawhub-install-core.ts b/src/skills/lifecycle/clawhub-install-core.ts new file mode 100644 index 000000000000..0dfc3f02ba13 --- /dev/null +++ b/src/skills/lifecycle/clawhub-install-core.ts @@ -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; + 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; +}): 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 { + 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) + : 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 { + 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 { + 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 { + 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 { + 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 | 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) }; + } +} diff --git a/src/skills/lifecycle/clawhub-status.ts b/src/skills/lifecycle/clawhub-status.ts new file mode 100644 index 000000000000..5d30e94bf716 --- /dev/null +++ b/src/skills/lifecycle/clawhub-status.ts @@ -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, "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 { + 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 { + 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) }; + } +} diff --git a/src/skills/lifecycle/clawhub-store.ts b/src/skills/lifecycle/clawhub-store.ts new file mode 100644 index 000000000000..b83bfddd1091 --- /dev/null +++ b/src/skills/lifecycle/clawhub-store.ts @@ -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; +}; + +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; + 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; + return isNonEmptyString(candidate.path) && isNonEmptyString(candidate.sha256) + ? { path: candidate.path, sha256: candidate.sha256 } + : undefined; +} + +function normalizeClawHubSkillOrigin( + raw: Partial | 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 { + for (const candidate of metadataPaths(workspaceDir, "lock.json")) { + try { + const raw = await tryReadJson>(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 { + 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; + 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 | 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 { + for (const candidate of metadataPaths(skillDir, "origin.json")) { + try { + const origin = normalizeClawHubSkillOrigin( + await tryReadJson>(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, candidate); + } + } catch (err) { + return { kind: "malformed", path: candidate, error: formatErrorMessage(err) }; + } + } + return { kind: "missing" }; +} + +export async function readClawHubSkillOriginStrict( + skillDir: string, +): Promise { + for (const candidate of metadataPaths(skillDir, "origin.json")) { + try { + const raw = await readJsonIfExists>(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 { + await writeJson(path.join(skillDir, DOT_DIR, "origin.json"), origin, { trailingNewline: true }); +} + +export async function readTrackedClawHubSkillSlugs(workspaceDir: string): Promise { + return Object.keys((await readClawHubSkillsLockfile(workspaceDir)).skills).toSorted(); +} + +export async function untrackClawHubSkill( + workspaceDir: string, + slug: string, +): Promise<() => Promise> { + 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); + }; +} diff --git a/src/skills/lifecycle/clawhub.ts b/src/skills/lifecycle/clawhub.ts index 52407c25aa43..cd442445046f 100644 --- a/src/skills/lifecycle/clawhub.ts +++ b/src/skills/lifecycle/clawhub.ts @@ -1,168 +1,56 @@ -// ClawHub lifecycle helpers fetch skill registry metadata and package details. -import fsSync from "node:fs"; -import fs from "node:fs/promises"; -import path from "node:path"; +// ClawHub lifecycle facade: public API plus install/update coordination. import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { - type ClawHubTrustErrorCode, - ensureClawHubPackageTrustAcknowledged, - type ClawHubRiskAcknowledgementRequest, +import type { + ClawHubRiskAcknowledgementRequest, + ClawHubTrustErrorCode, } 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, - searchClawHubSkills, - type ClawHubDownloadResult, - type ClawHubSkillDetail, - type ClawHubSkillInstallResolutionResponse, - type ClawHubSkillSearchResult, - 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 { readJsonIfExists, tryReadJson, writeJson } from "../../infra/json-files.js"; -import { markClawPackageIndependentlyOwned } from "../../state/claw-package-adoption.js"; import { withClawPackageLifecycleLease } from "../../state/claw-package-lifecycle-lease.js"; import { - CLAWHUB_SKILL_ARCHIVE_ROOT_MARKERS, - installExtractedSkillRoot, normalizeTrackedSkillSlug, resolveWorkspaceSkillInstallDir, validateRequestedSkillSlug, } from "./archive-install.js"; -import { digestClawHubSkillTree } from "./skill-tree-digest.js"; +import { + ensureClawHubSkillTrustAcknowledged, + isDefaultOfficialClawHubSkillSource, + normalizeExpectedArtifactIntegrity, + performClawHubSkillInstall, + resolveInstallVersion, + type ClawHubInstallParams, + type InstallClawHubSkillResult, + type Logger, +} from "./clawhub-install-core.js"; +import { resolveClawHubSkillStatusLinkSync } from "./clawhub-status.js"; +import { + parseRequestedClawHubSkillRef, + readClawHubSkillOrigin, + readClawHubSkillsLockfile, + type ClawHubSkillRef, + type ClawHubSkillsLockfile, +} from "./clawhub-store.js"; -const DOT_DIR = ".clawhub"; -const LEGACY_DOT_DIR = ".clawdhub"; -const SKILL_ORIGIN_RELATIVE_PATH = path.join(DOT_DIR, "origin.json"); -const LOCAL_SKILL_CARD_FILENAME = "skill-card.md"; -const LOCAL_SKILL_CARD_MAX_BYTES = 256 * 1024; - -type ClawHubSkillDownloadedArtifactLock = { - kind: ClawHubDownloadResult["artifact"]; - sha256: string; - integrity: string; -}; - -type ClawHubSkillFileLock = { - path: string; - sha256: string; -}; - -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; -}; - -type ClawHubSkillsLockfile = { - version: 1; - skills: Record; -}; - -export type ClawHubSkillsLockfileStatusRead = - | { kind: "found"; lock: ClawHubSkillsLockfile; path: string } - | { kind: "missing" } - | { kind: "malformed"; path: string; error: string }; - -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 InstallClawHubSkillResult = - | { - ok: true; - slug: string; - version: string; - targetDir: string; - detail?: ClawHubSkillDetail; - warning?: string; - } - | { ok: false; error: string; code?: ClawHubTrustErrorCode; version?: string; warning?: string }; +export { readVerifiedClawHubSkillSourceUrl } from "./clawhub-install-core.js"; +export { + readLocalSkillCardContentSync, + resolveClawHubSkillStatusLinkSync, + resolveClawHubSkillVerificationTarget, + resolveLocalSkillCardStatusSync, + searchSkillsFromClawHub, + type ClawHubSkillStatusLink, + type LocalSkillCardStatus, +} from "./clawhub-status.js"; +export { + readClawHubSkillsLockfileStatusSync, + readTrackedClawHubSkillSlugs, + untrackClawHubSkill, + type ClawHubSkillsLockfileStatusRead, +} from "./clawhub-store.js"; type UpdateClawHubSkillResult = | { @@ -176,79 +64,21 @@ type UpdateClawHubSkillResult = } | { ok: false; error: string; code?: ClawHubTrustErrorCode; version?: string; warning?: string }; -type Logger = { - info?: (message: string) => void; - warn?: (message: string) => void; - terminalLinks?: boolean; -}; - -type ClawHubSkillRef = { - slug: string; - ownerHandle?: string; - requestedReference?: string; - trustState?: ClawHubSkillsShTrustState; -}; - -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}$/; - -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; -} - -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}`); +type TrackedUpdateTarget = + | { + ok: true; + slug: string; + ownerHandle?: string; + requestedReference?: string; + trustState?: ClawHubInstallParams["trustState"]; + baseUrl?: string; + previousVersion: string | null; } - 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), - }; -} + | { ok: false; slug: string; error: string }; -function formatClawHubSkillRef(ref: ClawHubSkillRef): string { - return ref.ownerHandle ? `@${ref.ownerHandle}/${ref.slug}` : ref.slug; -} +type ClawHubSkillInstallPreflightResult = + | { ok: true; action: "install" | "reuse"; integrity: string; warning?: string } + | { ok: false; code: string; error: string }; async function resolveRequestedUpdateSlug(params: { workspaceDir: string; @@ -261,1495 +91,31 @@ async function resolveRequestedUpdateSlug(params: { ? parseRequestedClawHubSkillRef(requested) : { slug: normalizeTrackedSkillSlug(requested) }; const trackedSlug = requestedRef.slug; - const trackedTargetDir = resolveWorkspaceSkillInstallDir(params.workspaceDir, trackedSlug); - const trackedOrigin = await readClawHubSkillOrigin(trackedTargetDir); + const trackedOrigin = await readClawHubSkillOrigin( + resolveWorkspaceSkillInstallDir(params.workspaceDir, trackedSlug), + ); const trackedLockEntry = params.lock.skills[trackedSlug]; - if (trackedOrigin || trackedLockEntry) { - const trackedOwnerHandle = trackedOrigin?.ownerHandle ?? trackedLockEntry?.ownerHandle; - if (requestedRef.ownerHandle && trackedOwnerHandle !== requestedRef.ownerHandle) { - const trackedRef = trackedOwnerHandle ? `@${trackedOwnerHandle}/${trackedSlug}` : trackedSlug; - throw new Error( - `Skill "${trackedSlug}" is tracked as ${trackedRef}, not @${requestedRef.ownerHandle}/${trackedSlug}.`, - ); - } - const trackedRequestedReference = - trackedOrigin?.requestedReference ?? trackedLockEntry?.requestedReference; - if ( - requestedRef.requestedReference && - trackedRequestedReference !== requestedRef.requestedReference - ) { - throw new Error( - `Skill "${trackedSlug}" is not tracked from ${requestedRef.requestedReference}.`, - ); - } - return trackedSlug; + if (!trackedOrigin && !trackedLockEntry) { + return validateRequestedSkillSlug(requestedRef.slug); } - return validateRequestedSkillSlug(requestedRef.slug); -} - -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; - logger?: Logger; - config?: OpenClawConfig; - clawManaged?: boolean; -}; - -function normalizeExpectedArtifactIntegrity(expectedIntegrity: string): string; -function normalizeExpectedArtifactIntegrity(expectedIntegrity: undefined): undefined; -function normalizeExpectedArtifactIntegrity( - expectedIntegrity: string | undefined, -): string | undefined; -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) { + const trackedOwnerHandle = trackedOrigin?.ownerHandle ?? trackedLockEntry?.ownerHandle; + if (requestedRef.ownerHandle && trackedOwnerHandle !== requestedRef.ownerHandle) { + const trackedRef = trackedOwnerHandle ? `@${trackedOwnerHandle}/${trackedSlug}` : trackedSlug; throw new Error( - `ClawHub archive integrity mismatch: expected ${normalizedExpected}, got ${archive.integrity}.`, + `Skill "${trackedSlug}" is tracked as ${trackedRef}, not @${requestedRef.ownerHandle}/${trackedSlug}.`, ); } -} - -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; -} - -function isDefaultOfficialClawHubSkillSource(params: { - baseUrl?: string; - detail?: ClawHubSkillDetail; - resolution?: Extract; -}): 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 { - 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; - } -} - -type TrackedUpdateTarget = - | { - ok: true; - slug: string; - ownerHandle?: string; - requestedReference?: string; - trustState?: ClawHubSkillsShTrustState; - baseUrl?: string; - previousVersion: string | null; - } - | { - ok: false; - slug: string; - error: string; - }; - -type ClawHubSkillVerificationResolutionSource = "installed" | "registry"; -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: ClawHubSkillVerificationResolutionSource; - selector: ClawHubSkillVerificationSelector; - registry: string; - skillDir: string | undefined; - installedVersion: string | undefined; - }; - } - | { - ok: false; - error: string; - }; - -async function readClawHubSkillsLockfile(workspaceDir: string): Promise { - const candidates = [ - path.join(workspaceDir, DOT_DIR, "lock.json"), - path.join(workspaceDir, LEGACY_DOT_DIR, "lock.json"), - ]; - for (const candidate of candidates) { - try { - const raw = await tryReadJson>(candidate); - if (raw?.version === 1 && raw.skills && typeof raw.skills === "object") { - return { - version: 1, - skills: raw.skills, - }; - } - } catch { - // ignore - } - } - return { version: 1, skills: {} }; -} - -async function writeClawHubSkillsLockfile( - workspaceDir: string, - lockfile: ClawHubSkillsLockfile, -): Promise { - const targetPath = path.join(workspaceDir, DOT_DIR, "lock.json"); - await writeJson(targetPath, 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; - } -} - -function normalizeStoredRegistry(registry: string): string { - const trimmed = registry.trim(); - return trimmed.replace(/\/+$/, "") || trimmed; -} - -function readRealPathSync(candidate: string): string | undefined { - try { - return fsSync.realpathSync.native(candidate); - } catch { - return undefined; - } -} - -function normalizeOptionalStringValue(raw: unknown): string | undefined { - return typeof raw === "string" && raw.trim() ? raw.trim() : undefined; -} - -function asRecord(raw: unknown): Record | undefined { - return raw && typeof raw === "object" && !Array.isArray(raw) - ? (raw as Record) - : undefined; -} - -function normalizeGitHubRepoName(raw: unknown): string | undefined { - const repo = normalizeOptionalStringValue(raw); - if (!repo) { - return undefined; - } - const parts = repo.split("/"); - if (parts.length !== 2 || parts.some((part) => !/^[A-Za-z0-9._-]+$/.test(part))) { - return undefined; - } - return repo; -} - -function normalizeGitHubCommitSegment(raw: unknown): string | undefined { - const commit = normalizeOptionalStringValue(raw); - if (!commit || !/^[0-9a-f]{40}$/i.test(commit)) { - return undefined; - } - return commit; -} - -function buildGitHubTreeUrl(params: { repo: string; commit: string; sourcePath?: string }): string { - const [owner, name] = params.repo.split("/") as [string, string]; - const pathParts = params.sourcePath ? params.sourcePath.split("/") : []; - const segments = [owner, name, "tree", params.commit, ...pathParts]; - return `https://github.com/${segments.map(encodeURIComponent).join("/")}`; -} - -export function readVerifiedClawHubSkillSourceUrl(raw: unknown): string | undefined { - const provenance = asRecord(raw); - // 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 = normalizeGitHubRepoName(provenance.repo); - const commit = normalizeGitHubCommitSegment(provenance.commit); - if (!repo || !commit) { - return undefined; - } - const pathValue = normalizeOptionalStringValue(provenance.path); - let sourcePath: string | undefined; - if (pathValue) { - try { - sourcePath = normalizeGitHubSourcePath(pathValue); - } catch { - return undefined; - } - } - return buildGitHubTreeUrl({ repo, commit, ...(sourcePath ? { sourcePath } : {}) }); -} - -function readInstallResolutionSourceUrl( - resolution: Extract | undefined, -): string | undefined { - if (resolution?.installKind !== "github") { - return undefined; - } - return normalizeOptionalStringValue(resolution.github.sourceUrl); -} - -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 { - 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 { - for (const marker of CLAWHUB_SKILL_ARCHIVE_ROOT_MARKERS) { - const candidate = path.join(skillDir, marker); - try { - const content = await fs.readFile(candidate); - return { - path: marker, - sha256: sha256Hex(content), - }; - } catch { - continue; - } - } - return undefined; -} - -export function readClawHubSkillsLockfileStatusSync( - workspaceDir: string, -): ClawHubSkillsLockfileStatusRead { - const candidates = [ - path.join(workspaceDir, DOT_DIR, "lock.json"), - path.join(workspaceDir, LEGACY_DOT_DIR, "lock.json"), - ]; - for (const candidate of candidates) { - let raw: Partial | null; - try { - const read = readJsonIfExistsSync(candidate); - if (!read.exists) { - continue; - } - raw = read.value as Partial; - } catch (err) { - return { - kind: "malformed", - path: candidate, - error: formatErrorMessage(err), - }; - } - if (raw?.version === 1 && raw.skills && typeof raw.skills === "object") { - return { - kind: "found", - path: candidate, - lock: { - version: 1, - skills: raw.skills, - }, - }; - } - return { - kind: "malformed", - path: candidate, - error: "expected version 1 lockfile with skills", - }; - } - return { kind: "missing" }; -} - -function normalizeOptionalSelector(value: string | undefined): string | undefined { - const trimmed = value?.trim(); - return trimmed ? trimmed : undefined; -} - -function isNonEmptyString(value: unknown): value is string { - return typeof value === "string" && value.trim().length > 0; -} - -function normalizeDownloadedArtifactLock( - raw: unknown, -): ClawHubSkillDownloadedArtifactLock | undefined { - if (!raw || typeof raw !== "object") { - return undefined; - } - const candidate = raw as Partial; + const trackedRequestedReference = + trackedOrigin?.requestedReference ?? trackedLockEntry?.requestedReference; if ( - (candidate.kind === "archive" || candidate.kind === "clawpack") && - isNonEmptyString(candidate.sha256) && - isNonEmptyString(candidate.integrity) + requestedRef.requestedReference && + trackedRequestedReference !== requestedRef.requestedReference ) { - return { - kind: candidate.kind, - sha256: candidate.sha256, - integrity: candidate.integrity, - }; - } - return undefined; -} - -function normalizeSkillFileLock(raw: unknown): ClawHubSkillFileLock | undefined { - if (!raw || typeof raw !== "object") { - return undefined; - } - const candidate = raw as Partial; - if (isNonEmptyString(candidate.path) && isNonEmptyString(candidate.sha256)) { - return { path: candidate.path, sha256: candidate.sha256 }; - } - return undefined; -} - -function normalizeClawHubSkillOrigin( - raw: Partial | null, -): ClawHubSkillOrigin | null { - if ( - raw?.version === 1 && - typeof raw.registry === "string" && - raw.registry.trim().length > 0 && - typeof raw.slug === "string" && - raw.slug.trim().length > 0 && - typeof raw.installedVersion === "string" && - raw.installedVersion.trim().length > 0 && - typeof raw.installedAt === "number" - ) { - 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 } : {}), - }; - } - return null; -} - -async function readClawHubSkillOrigin(skillDir: string): Promise { - const candidates = [ - path.join(skillDir, DOT_DIR, "origin.json"), - path.join(skillDir, LEGACY_DOT_DIR, "origin.json"), - ]; - for (const candidate of candidates) { - try { - const raw = await tryReadJson>(candidate); - const origin = normalizeClawHubSkillOrigin(raw); - if (origin) { - return origin; - } - } catch { - // ignore - } - } - return null; -} - -function readClawHubSkillOriginStatusSync(skillDir: string): StrictOriginReadResult { - const candidates = [ - path.join(skillDir, DOT_DIR, "origin.json"), - path.join(skillDir, LEGACY_DOT_DIR, "origin.json"), - ]; - for (const candidate of candidates) { - let raw: Partial | null; - try { - const read = readJsonIfExistsSync(candidate); - if (!read.exists) { - continue; - } - raw = read.value as Partial; - } catch (err) { - return { - kind: "malformed", - path: candidate, - error: formatErrorMessage(err), - }; - } - const origin = normalizeClawHubSkillOrigin(raw); - if (origin) { - return { kind: "found", origin, path: candidate }; - } - return { - kind: "malformed", - path: candidate, - error: "expected version 1 origin with registry, slug, installedVersion, and installedAt", - }; - } - return { kind: "missing" }; -} - -type StrictOriginReadResult = - | { kind: "found"; origin: ClawHubSkillOrigin; path: string } - | { kind: "missing" } - | { kind: "malformed"; path: string; error: string }; - -async function readClawHubSkillOriginStrict(skillDir: string): Promise { - const candidates = [ - path.join(skillDir, DOT_DIR, "origin.json"), - path.join(skillDir, LEGACY_DOT_DIR, "origin.json"), - ]; - for (const candidate of candidates) { - let raw: Partial | null; - try { - raw = await readJsonIfExists>(candidate); - } catch (err) { - return { - kind: "malformed", - path: candidate, - error: formatErrorMessage(err), - }; - } - if (!raw) { - continue; - } - const origin = normalizeClawHubSkillOrigin(raw); - if (origin) { - return { kind: "found", origin, path: candidate }; - } - return { - kind: "malformed", - path: candidate, - error: "expected version 1 origin with registry, slug, installedVersion, and installedAt", - }; - } - return { kind: "missing" }; -} - -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 { - status: "invalid", - valid: false, - reason: `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 { - status: "invalid", - valid: false, - reason: `Malformed ClawHub origin metadata at ${originRead.path}: ${originRead.error}`, - originPath: originRead.path, - lockPath: lockRead.kind === "found" ? lockRead.path : undefined, - }; - } - - let trackedSlug: string; - try { - trackedSlug = normalizeTrackedSkillSlug(originRead.origin.slug); - } catch (err) { - return { - status: "invalid", - valid: false, - reason: `Invalid ClawHub origin slug "${originRead.origin.slug}": ${formatErrorMessage(err)}`, - registry: originRead.origin.registry, - slug: originRead.origin.slug, - installedVersion: originRead.origin.installedVersion, - installedAt: originRead.origin.installedAt, - originPath: originRead.path, - lockPath: lockRead.kind === "found" ? lockRead.path : undefined, - }; - } - - if (lockRead.kind === "missing") { - return { - status: "invalid", - valid: false, - reason: `Skill "${trackedSlug}" has ClawHub origin metadata but is not tracked by the ${lockfileLabel}.`, - registry: originRead.origin.registry, - slug: trackedSlug, - installedVersion: originRead.origin.installedVersion, - installedAt: originRead.origin.installedAt, - originPath: originRead.path, - }; - } - if (lockRead.kind === "malformed") { - return { - status: "invalid", - valid: false, - reason: `Malformed ${lockfileLabel} at ${lockRead.path}: ${lockRead.error}`, - registry: originRead.origin.registry, - slug: trackedSlug, - installedVersion: originRead.origin.installedVersion, - installedAt: originRead.origin.installedAt, - originPath: originRead.path, - lockPath: lockRead.path, - }; - } - const locked = lockRead.lock.skills[trackedSlug]; - if (!locked) { - return { - status: "invalid", - valid: false, - reason: `Skill "${trackedSlug}" has ClawHub origin metadata but is not tracked by the ${lockfileLabel}.`, - registry: originRead.origin.registry, - slug: trackedSlug, - installedVersion: originRead.origin.installedVersion, - installedAt: originRead.origin.installedAt, - originPath: originRead.path, - lockPath: lockRead.path, - }; - } - const expectedSkillDir = resolveWorkspaceSkillInstallDir(params.workspaceDir, trackedSlug); - const expectedSkillDirRealPath = readRealPathSync(expectedSkillDir); - const actualSkillDirRealPath = readRealPathSync(params.skillDir); - if (!expectedSkillDirRealPath || actualSkillDirRealPath !== expectedSkillDirRealPath) { - return { - status: "invalid", - valid: false, - reason: `Skill "${trackedSlug}" ClawHub origin metadata is not in the expected ClawHub install directory.`, - registry: originRead.origin.registry, - slug: trackedSlug, - installedVersion: originRead.origin.installedVersion, - installedAt: originRead.origin.installedAt, - originPath: originRead.path, - lockPath: lockRead.path, - }; - } - const originRegistry = normalizeStoredRegistry(originRead.origin.registry); - const lockedRegistry = - locked.registry === undefined ? originRegistry : normalizeStoredRegistry(locked.registry); - const lockedSourceUrl = normalizeOptionalStringValue(locked.sourceUrl); - const lockedOwnerHandle = normalizeOptionalStringValue(locked.ownerHandle); - const lockedRequestedReference = normalizeOptionalStringValue(locked.requestedReference); - const lockedTrustState = - locked.trustState === CLAWHUB_SKILLS_SH_TRUST_STATE ? CLAWHUB_SKILLS_SH_TRUST_STATE : undefined; - const lockedArtifact = normalizeDownloadedArtifactLock(locked.artifact); - const lockedSkillFile = normalizeSkillFileLock(locked.skillFile); - const lockedFileTreeSha256 = normalizeOptionalStringValue(locked.fileTreeSha256); - const provenanceMatches = - originRead.origin.ownerHandle === lockedOwnerHandle && - originRead.origin.requestedReference === lockedRequestedReference && - originRead.origin.trustState === lockedTrustState && - originRead.origin.sourceUrl === lockedSourceUrl && - originRead.origin.artifact?.kind === lockedArtifact?.kind && - originRead.origin.artifact?.sha256 === lockedArtifact?.sha256 && - originRead.origin.artifact?.integrity === lockedArtifact?.integrity && - originRead.origin.skillFile?.path === lockedSkillFile?.path && - originRead.origin.skillFile?.sha256 === lockedSkillFile?.sha256 && - originRead.origin.fileTreeSha256 === lockedFileTreeSha256; - // A linked status is a trust signal. Only expose provenance when both - // install records agree, so a one-sided origin edit cannot become trusted. - if ( - locked.version !== originRead.origin.installedVersion || - locked.installedAt !== originRead.origin.installedAt || - lockedRegistry !== originRegistry || - !provenanceMatches - ) { - return { - status: "invalid", - valid: false, - reason: `Skill "${trackedSlug}" ClawHub origin metadata does not match the ${lockfileLabel}.`, - registry: lockedRegistry, - slug: trackedSlug, - installedVersion: originRead.origin.installedVersion, - installedAt: originRead.origin.installedAt, - originPath: originRead.path, - lockPath: lockRead.path, - }; - } - return { - status: "linked", - valid: true, - registry: lockedRegistry, - slug: trackedSlug, - ...(lockedOwnerHandle ? { ownerHandle: lockedOwnerHandle } : {}), - ...(lockedRequestedReference ? { requestedReference: lockedRequestedReference } : {}), - ...(lockedTrustState ? { trustState: lockedTrustState } : {}), - installedVersion: locked.version, - installedAt: locked.installedAt, - originPath: originRead.path, - lockPath: lockRead.path, - ...(lockedSourceUrl ? { sourceUrl: lockedSourceUrl } : {}), - ...(lockedArtifact ? { artifact: lockedArtifact } : {}), - ...(lockedSkillFile ? { skillFile: lockedSkillFile } : {}), - ...(lockedFileTreeSha256 ? { fileTreeSha256: lockedFileTreeSha256 } : {}), - }; -} - -export function resolveLocalSkillCardStatusSync( - skillDir: string, -): LocalSkillCardStatus | undefined { - return readLocalSkillCardSync(skillDir); -} - -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 (err) { - if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") { - return undefined; - } - 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; - } - const noFollowFlag = fsSync.constants.O_NOFOLLOW ?? 0; - fd = fsSync.openSync(cardPath, fsSync.constants.O_RDONLY | noFollowFlag); - 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 readLocalSkillCardContentSync(skillDir: string): string | undefined { - return readLocalSkillCardSync(skillDir, true)?.content; -} - -async function writeClawHubSkillOrigin( - skillDir: string, - origin: ClawHubSkillOrigin, -): Promise { - const targetPath = path.join(skillDir, SKILL_ORIGIN_RELATIVE_PATH); - await writeJson(targetPath, origin, { trailingNewline: true }); -} - -export async function searchSkillsFromClawHub(params: { - query?: string; - limit?: number; - baseUrl?: string; -}): Promise { - 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 { - 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 lock = await readClawHubSkillsLockfile(params.workspaceDir); - const locked = lock.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 lockedOwnerHandle = normalizeOptionalStringValue(locked.ownerHandle); - const lockedRequestedReference = normalizeOptionalStringValue(locked.requestedReference); - const lockedTrustState = - 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 !== lockedOwnerHandle || - originRead.origin.requestedReference !== lockedRequestedReference || - originRead.origin.trustState !== lockedTrustState - ) { - 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 (lockedRequestedReference && (version || tag)) { - return { - ok: false, - error: "--version and --tag are not supported for skills-sh references.", - }; - } - if (requestedRef.ownerHandle && lockedOwnerHandle !== requestedRef.ownerHandle) { - const trackedRef = lockedOwnerHandle ? `@${lockedOwnerHandle}/${trackedSlug}` : trackedSlug; - return { - ok: false, - error: `Skill "${trackedSlug}" is tracked as ${trackedRef}, not @${requestedRef.ownerHandle}/${trackedSlug}.`, - }; - } - if ( - requestedRef.requestedReference && - lockedRequestedReference !== 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 = lockedRequestedReference - ? undefined - : (version ?? (tag ? undefined : locked.version)); - return { - ok: true, - slug: trackedSlug, - ...(lockedOwnerHandle ? { ownerHandle: lockedOwnerHandle } : {}), - ...(lockedRequestedReference ? { requestedReference: lockedRequestedReference } : {}), - ...(lockedTrustState ? { trustState: lockedTrustState } : {}), - baseUrl: lockedRegistry, - version: verificationVersion, - tag: lockedRequestedReference ? 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) }; - } -} - -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 resolvedVersion = params.version ?? detail.latestVersion?.version; - if (!resolvedVersion) { - throw new Error(`Skill "${params.slug}" has no installable version.`); - } - return { - detail, - version: resolvedVersion, - }; -} - -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 resolveGitHubSkillSourceDir(repoRoot: string, sourcePath: string): string { - const normalized = normalizeGitHubSourcePath(sourcePath); - return path.join(repoRoot, ...normalized.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 { - 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.`, + `Skill "${trackedSlug}" is not tracked from ${requestedRef.requestedReference}.`, ); } - return { ...resolution, github: { ...resolution.github, commit } }; -} - -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 } : {}), - }; -} - -async function performClawHubSkillInstall( - params: ClawHubInstallParams, -): Promise { - try { - normalizeExpectedArtifactIntegrity(params.expectedIntegrity); - const targetDir = resolveWorkspaceSkillInstallDir(params.workspaceDir, params.slug); - const registry = resolveClawHubBaseUrl(params.baseUrl); - const clawhubAuthority = isDefaultClawHubBaseUrl(params.baseUrl) ? "openclaw" : "third-party"; - 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 latestResolution: Extract | undefined; - let install: Awaited>; - let trustWarning: string | undefined; - let officialClawHubSkill = false; - - let archive: ClawHubDownloadResult; - if (params.version) { - const resolved = await resolveInstallVersion({ - slug: params.slug, - ...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}), - version: params.version, - baseUrl: params.baseUrl, - }); - detail = resolved.detail; - version = resolved.version; - officialClawHubSkill = isDefaultOfficialClawHubSkillSource({ - baseUrl: params.baseUrl, - detail, - }); - const trust = await ensureClawHubSkillTrustAcknowledged({ - ...params, - version, - skipClawHubTrustCheck: officialClawHubSkill, - }); - 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 { - latestResolution = 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 ( - latestResolution.installKind !== "github" || - latestResolution.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 resolutionOfficialClawHubSkill = isDefaultOfficialClawHubSkillSource({ - baseUrl: params.baseUrl, - resolution: latestResolution, - }); - detail = resolutionOfficialClawHubSkill - ? undefined - : await fetchDefaultClawHubSkillDetailIfOfficial({ - baseUrl: params.baseUrl, - slug: params.slug, - ...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}), - }); - if (latestResolution.installKind === "github") { - version = latestResolution.github.commit; - officialClawHubSkill = isDefaultOfficialClawHubSkillSource({ - baseUrl: params.baseUrl, - detail, - resolution: latestResolution, - }); - // 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: latestResolution.github.repo, - commit: latestResolution.github.commit, - }); - } else { - version = latestResolution.archive.version; - officialClawHubSkill = isDefaultOfficialClawHubSkillSource({ - baseUrl: params.baseUrl, - detail, - resolution: latestResolution, - }); - const trust = await ensureClawHubSkillTrustAcknowledged({ - ...params, - version, - skipClawHubTrustCheck: officialClawHubSkill, - }); - if (!trust.ok) { - return { ...trust, version }; - } - trustWarning = trust.warning; - params.logger?.info?.(`Downloading ${params.slug}@${version} from ClawHub…`); - archive = await downloadClawHubSkillArchiveUrl({ - url: latestResolution.archive.downloadUrl, - baseUrl: params.baseUrl, - }); - } - } - try { - assertDownloadedArtifactIntegrity(archive, params.expectedIntegrity); - if (!params.version) { - if (!latestResolution) { - throw new Error(`Skill "${params.slug}" has no install resolution.`); - } - install = - latestResolution.installKind === "github" - ? await installGitHubResolution({ - workspaceDir: params.workspaceDir, - slug: params.slug, - ...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}), - sourcePath: latestResolution.github.path, - archivePath: archive.archivePath, - registry, - authority: officialClawHubSkill ? "official" : "third-party", - repo: latestResolution.github.repo, - commit: latestResolution.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: officialClawHubSkill ? "official" : clawhubAuthority, - force: params.force, - logger: params.logger, - config: params.config, - }); - } else { - install = await installArchiveResolution({ - workspaceDir: params.workspaceDir, - slug: params.slug, - ...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}), - version, - archivePath: archive.archivePath, - registry, - authority: officialClawHubSkill ? "official" : clawhubAuthority, - 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 = - latestResolution?.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 = - readInstallResolutionSourceUrl(latestResolution) ?? - readVerifiedClawHubSkillSourceUrl(verification?.provenance); - await writeClawHubSkillOrigin(install.targetDir, { - version: 1, - registry: resolveClawHubBaseUrl(params.baseUrl), - slug: params.slug, - ...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}), - ...(params.requestedReference ? { requestedReference: params.requestedReference } : {}), - ...(params.trustState ? { trustState: params.trustState } : {}), - installedVersion: version, - installedAt, - ...(sourceUrl ? { sourceUrl } : {}), - artifact, - ...(skillFile ? { skillFile } : {}), - fileTreeSha256, - }); - const lock = await readClawHubSkillsLockfile(params.workspaceDir); - lock.skills[params.slug] = { - version, - installedAt, - registry: resolveClawHubBaseUrl(params.baseUrl), - ...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}), - ...(params.requestedReference ? { requestedReference: params.requestedReference } : {}), - ...(params.trustState ? { trustState: params.trustState } : {}), - ...(sourceUrl ? { sourceUrl } : {}), - artifact, - ...(skillFile ? { skillFile } : {}), - fileTreeSha256, - ...(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), - }; - } + return trackedSlug; } async function installRequestedSkillFromClawHub( @@ -1768,10 +134,7 @@ async function installRequestedSkillFromClawHub( ...(ref.trustState ? { trustState: ref.trustState } : {}), }); } catch (err) { - return { - ok: false, - error: formatErrorMessage(err), - }; + return { ok: false, error: formatErrorMessage(err) }; } } @@ -1784,17 +147,10 @@ async function installTrackedSkillFromClawHub( slug: normalizeTrackedSkillSlug(params.slug), }); } catch (err) { - return { - ok: false, - error: formatErrorMessage(err), - }; + return { ok: false, error: formatErrorMessage(err) }; } } -type ClawHubSkillInstallPreflightResult = - | { ok: true; action: "install" | "reuse"; integrity: string; warning?: string } - | { ok: false; code: string; error: string }; - async function preflightSkillOwnerState(params: { workspaceDir: string; requested: ClawHubSkillRef; @@ -1851,10 +207,6 @@ export async function preflightSkillFromClawHub(params: { error: `Skill ${params.slug}@${params.version} resolved to ${resolved.version}.`, }; } - const official = isDefaultOfficialClawHubSkillSource({ - baseUrl: params.baseUrl, - detail: resolved.detail, - }); const trust = await ensureClawHubSkillTrustAcknowledged({ workspaceDir: params.workspaceDir, slug: requested.slug, @@ -1864,7 +216,10 @@ export async function preflightSkillFromClawHub(params: { acknowledgeClawHubRisk: params.acknowledgeClawHubRisk, onClawHubRisk: params.onClawHubRisk, logger: params.logger, - skipClawHubTrustCheck: official, + skipClawHubTrustCheck: isDefaultOfficialClawHubSkillSource({ + baseUrl: params.baseUrl, + detail: resolved.detail, + }), }); if (!trust.ok) { return { @@ -1923,16 +278,17 @@ async function resolveTrackedUpdateTarget(params: { lock: ClawHubSkillsLockfile; baseUrl?: string; }): Promise { - const targetDir = resolveWorkspaceSkillInstallDir(params.workspaceDir, params.slug); - const origin = (await readClawHubSkillOrigin(targetDir)) ?? null; - if (!origin && !params.lock.skills[params.slug]) { + const origin = await readClawHubSkillOrigin( + resolveWorkspaceSkillInstallDir(params.workspaceDir, params.slug), + ); + const lockEntry = params.lock.skills[params.slug]; + if (!origin && !lockEntry) { return { ok: false, slug: params.slug, error: `Skill "${params.slug}" is not tracked as a ClawHub install.`, }; } - const lockEntry = params.lock.skills[params.slug]; const ownerHandle = origin?.ownerHandle ?? lockEntry?.ownerHandle; const requestedReference = origin?.requestedReference ?? lockEntry?.requestedReference; const trustState = origin?.trustState ?? lockEntry?.trustState; @@ -1966,12 +322,7 @@ export async function installSkillFromClawHub(params: { return await installRequestedSkillFromClawHub(params); } return await withClawPackageLifecycleLease( - { - kind: "skill", - source: "clawhub", - ref: params.slug, - workspace: params.workspaceDir, - }, + { kind: "skill", source: "clawhub", ref: params.slug, workspace: params.workspaceDir }, () => installRequestedSkillFromClawHub(params), ); } @@ -2005,19 +356,11 @@ export async function updateSkillsFromClawHub(params: { baseUrl: params.baseUrl, }); if (!tracked.ok) { - results.push({ - ok: false, - error: tracked.error, - }); + results.push({ ok: false, error: tracked.error }); continue; } const install = await withClawPackageLifecycleLease( - { - kind: "skill", - source: "clawhub", - ref: tracked.slug, - workspace: params.workspaceDir, - }, + { kind: "skill", source: "clawhub", ref: tracked.slug, workspace: params.workspaceDir }, () => installTrackedSkillFromClawHub({ workspaceDir: params.workspaceDir, @@ -2035,47 +378,19 @@ export async function updateSkillsFromClawHub(params: { }), { required: true }, ); - if (!install.ok) { - results.push(install); - continue; - } - results.push({ - ok: true, - slug: tracked.slug, - previousVersion: tracked.previousVersion, - version: install.version, - changed: tracked.previousVersion !== install.version, - targetDir: install.targetDir, - ...(install.warning ? { warning: install.warning } : {}), - }); + results.push( + install.ok + ? { + ok: true, + slug: tracked.slug, + previousVersion: tracked.previousVersion, + version: install.version, + changed: tracked.previousVersion !== install.version, + targetDir: install.targetDir, + ...(install.warning ? { warning: install.warning } : {}), + } + : install, + ); } return results; } - -export async function readTrackedClawHubSkillSlugs(workspaceDir: string): Promise { - const lock = await readClawHubSkillsLockfile(workspaceDir); - return Object.keys(lock.skills).toSorted(); -} - -export async function untrackClawHubSkill( - workspaceDir: string, - slug: string, -): Promise<() => Promise> { - 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); - }; -} -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */