mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
feat(cli): support skills.sh install references (#3226)
* feat(cli): support skills.sh install references * fix(cli): preserve repo sync alias installs * fix(cli): verify skills.sh artifact identity on update * fix(cli): bind scanned verification to canonical alias
This commit is contained in:
@@ -290,7 +290,7 @@ registerCommand(program, ["search"])
|
||||
|
||||
registerCommand(program, ["install"])
|
||||
.description("Install a skill into <dir>")
|
||||
.argument("<skill>", "Skill to install, e.g. @openclaw/demo")
|
||||
.argument("<skill>", "Skill to install, e.g. @openclaw/demo or skills-sh:owner/repo/slug")
|
||||
.option("--version <version>", "Version to install")
|
||||
.option("--force", "Overwrite existing folder")
|
||||
.option("--force-install", "Install a pending GitHub-backed skill before ClawHub scan completes")
|
||||
@@ -301,7 +301,7 @@ registerCommand(program, ["install"])
|
||||
|
||||
registerCommand(program, ["update"])
|
||||
.description("Update installed skills")
|
||||
.argument("[skill]", "Skill to update, e.g. @openclaw/demo")
|
||||
.argument("[skill]", "Skill to update, e.g. @openclaw/demo or skills-sh:owner/repo/slug")
|
||||
.option("--all", "Update all installed skills")
|
||||
.option("--version <version>", "Update to specific version (single slug only)")
|
||||
.option("--force", "Overwrite when local files do not match any version")
|
||||
|
||||
@@ -360,13 +360,13 @@ describe("cmdInspect", () => {
|
||||
});
|
||||
|
||||
describe("cmdVerifySkill", () => {
|
||||
it("prints exact skills.sh catalog verification from the standard verify route", async () => {
|
||||
const sourceRef = "skills-sh/patrick-erichsen/skills/html";
|
||||
it("prints an explicit unscanned skills.sh verification from the standard verify route", async () => {
|
||||
const sourceRef = "skills-sh:patrick-erichsen/skills/html";
|
||||
const payload = {
|
||||
schema: "clawhub.skill.verify.v1",
|
||||
ok: true,
|
||||
decision: "pass",
|
||||
reasons: [],
|
||||
ok: false,
|
||||
decision: "fail",
|
||||
reasons: ["Not scanned by ClawHub"],
|
||||
slug: sourceRef,
|
||||
displayName: "HTML Artifact Chooser",
|
||||
pageUrl: "https://clawhub.ai/skills-sh/patrick-erichsen/skills/html",
|
||||
@@ -385,8 +385,14 @@ describe("cmdVerifySkill", () => {
|
||||
bundleFingerprints: ["c".repeat(64)],
|
||||
files: [{ path: "SKILL.md", size: 42, sha256: "d".repeat(64) }],
|
||||
},
|
||||
provenance: { source: "skills-sh-catalog" },
|
||||
security: { status: "clean", passed: true },
|
||||
provenance: {
|
||||
source: "skills.sh",
|
||||
reference: sourceRef,
|
||||
},
|
||||
security: {
|
||||
clawhubScan: "unscanned",
|
||||
label: "Not scanned by ClawHub",
|
||||
},
|
||||
signature: { status: "unsigned" },
|
||||
};
|
||||
httpMocks.apiRequest.mockResolvedValueOnce(payload);
|
||||
@@ -398,12 +404,150 @@ describe("cmdVerifySkill", () => {
|
||||
expect(url.pathname).toBe(`${ApiRoutes.skills}/html/verify`);
|
||||
expect(url.searchParams.get("reference")).toBe(sourceRef);
|
||||
expect(JSON.parse(String(mockLog.mock.calls[0]?.[0]))).toEqual(payload);
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("rejects colon-form skills.sh verification references", async () => {
|
||||
it("rejects legacy slash-form skills.sh verification references before network access", async () => {
|
||||
await expect(
|
||||
cmdVerifySkill(makeGlobalOpts(), "skills-sh:patrick-erichsen/skills/html"),
|
||||
).rejects.toThrow("Invalid skills.sh ref: use skills-sh/owner/repo/slug");
|
||||
cmdVerifySkill(makeGlobalOpts(), "skills-sh/patrick-erichsen/skills/html"),
|
||||
).rejects.toThrow("Invalid skills.sh ref: use skills-sh:owner/repo/slug");
|
||||
expect(authTokenMocks.getOptionalAuthToken).not.toHaveBeenCalled();
|
||||
expect(registryMocks.getRegistry).not.toHaveBeenCalled();
|
||||
expect(httpMocks.apiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an unscanned skills.sh verification that fabricates a pass", async () => {
|
||||
const sourceRef = "skills-sh:patrick-erichsen/skills/html";
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
schema: "clawhub.skill.verify.v1",
|
||||
ok: true,
|
||||
decision: "pass",
|
||||
reasons: [],
|
||||
slug: sourceRef,
|
||||
displayName: "HTML",
|
||||
pageUrl: "https://clawhub.ai/skills-sh/patrick-erichsen/skills/html",
|
||||
publisherHandle: null,
|
||||
publisherDisplayName: null,
|
||||
publisherProfileUrl: null,
|
||||
version: "a".repeat(40),
|
||||
resolvedFrom: "latest",
|
||||
tag: null,
|
||||
createdAt: 123,
|
||||
card: {},
|
||||
artifact: {},
|
||||
provenance: { source: "skills.sh", reference: sourceRef },
|
||||
security: { clawhubScan: "unscanned", label: "Not scanned by ClawHub" },
|
||||
signature: {},
|
||||
});
|
||||
|
||||
await expect(cmdVerifySkill(makeGlobalOpts(), sourceRef)).rejects.toThrow(
|
||||
'skills.sh verification must report "Not scanned by ClawHub" rather than pass',
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects scanned skills.sh verification without a canonical native alias", async () => {
|
||||
const sourceRef = "skills-sh:patrick-erichsen/skills/html";
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
schema: "clawhub.skill.verify.v1",
|
||||
ok: true,
|
||||
decision: "pass",
|
||||
reasons: [],
|
||||
slug: "html",
|
||||
displayName: "HTML",
|
||||
pageUrl: "https://clawhub.ai/openclaw/html",
|
||||
publisherHandle: "openclaw",
|
||||
publisherDisplayName: "OpenClaw",
|
||||
publisherProfileUrl: "https://clawhub.ai/openclaw",
|
||||
version: "a".repeat(40),
|
||||
resolvedFrom: "skills-sh-alias",
|
||||
tag: null,
|
||||
createdAt: 123,
|
||||
card: {},
|
||||
artifact: {},
|
||||
provenance: { source: "skills.sh", reference: sourceRef },
|
||||
security: { clawhubScan: "scanned", label: "Scanned by ClawHub" },
|
||||
signature: {},
|
||||
});
|
||||
|
||||
await expect(cmdVerifySkill(makeGlobalOpts(), sourceRef)).rejects.toThrow(
|
||||
"scanned skills.sh verification must return a canonical native reference",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects scanned skills.sh verification with an invalid trust label", async () => {
|
||||
const sourceRef = "skills-sh:patrick-erichsen/skills/html";
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
schema: "clawhub.skill.verify.v1",
|
||||
ok: true,
|
||||
decision: "pass",
|
||||
reasons: [],
|
||||
slug: "html",
|
||||
displayName: "HTML",
|
||||
pageUrl: "https://clawhub.ai/openclaw/html",
|
||||
publisherHandle: "openclaw",
|
||||
publisherDisplayName: "OpenClaw",
|
||||
publisherProfileUrl: "https://clawhub.ai/openclaw",
|
||||
version: "a".repeat(40),
|
||||
resolvedFrom: "skills-sh-alias",
|
||||
tag: null,
|
||||
createdAt: 123,
|
||||
card: {},
|
||||
artifact: {},
|
||||
provenance: { source: "skills.sh", reference: sourceRef },
|
||||
security: { clawhubScan: "scanned", label: "Trusted" },
|
||||
canonicalRef: "@openclaw/html",
|
||||
signature: {},
|
||||
});
|
||||
|
||||
await expect(cmdVerifySkill(makeGlobalOpts(), sourceRef)).rejects.toThrow(
|
||||
'scanned skills.sh verification must report "Scanned by ClawHub"',
|
||||
);
|
||||
});
|
||||
|
||||
it("prints scanned Repo Sync alias provenance and canonical verification", async () => {
|
||||
const sourceRef = "skills-sh:patrick-erichsen/skills/html";
|
||||
const payload = {
|
||||
schema: "clawhub.skill.verify.v1",
|
||||
ok: true,
|
||||
decision: "pass",
|
||||
reasons: [],
|
||||
slug: "html",
|
||||
displayName: "HTML",
|
||||
pageUrl: "https://clawhub.ai/openclaw/html",
|
||||
publisherHandle: "openclaw",
|
||||
publisherDisplayName: "OpenClaw",
|
||||
publisherProfileUrl: "https://clawhub.ai/openclaw",
|
||||
version: "a".repeat(40),
|
||||
resolvedFrom: "skills-sh-alias",
|
||||
tag: null,
|
||||
createdAt: 123,
|
||||
card: {},
|
||||
artifact: {
|
||||
sourceFingerprint: "b".repeat(64),
|
||||
bundleFingerprints: ["c".repeat(64)],
|
||||
files: [{ path: "SKILL.md", size: 42, sha256: "d".repeat(64) }],
|
||||
},
|
||||
provenance: {
|
||||
source: "skills.sh",
|
||||
reference: sourceRef,
|
||||
repository: "patrick-erichsen/skills",
|
||||
path: "skills/html",
|
||||
commit: "a".repeat(40),
|
||||
contentHash: "b".repeat(64),
|
||||
},
|
||||
security: {
|
||||
clawhubScan: "scanned",
|
||||
label: "Scanned by ClawHub",
|
||||
},
|
||||
canonicalRef: "@openclaw/html",
|
||||
signature: {},
|
||||
};
|
||||
httpMocks.apiRequest.mockResolvedValueOnce(payload);
|
||||
|
||||
await cmdVerifySkill(makeGlobalOpts(), sourceRef);
|
||||
|
||||
expect(JSON.parse(String(mockLog.mock.calls[0]?.[0]))).toEqual(payload);
|
||||
expect(process.exitCode).not.toBe(1);
|
||||
});
|
||||
|
||||
it("fetches and prints JSON verification by default", async () => {
|
||||
|
||||
@@ -12,6 +12,11 @@ import {
|
||||
} from "../../schema/index.js";
|
||||
import { getOptionalAuthToken } from "../authToken.js";
|
||||
import { getRegistry } from "../registry.js";
|
||||
import {
|
||||
parseSkillsShCliReference,
|
||||
SKILLS_SH_SCANNED_LABEL,
|
||||
SKILLS_SH_UNSCANNED_LABEL,
|
||||
} from "../skillReference.js";
|
||||
import type { GlobalOpts } from "../types.js";
|
||||
import { createCrabLoader, fail, formatError, styleText } from "../ui.js";
|
||||
|
||||
@@ -261,9 +266,6 @@ export async function cmdVerifySkill(
|
||||
slug: string,
|
||||
options: VerifySkillOptions = {},
|
||||
) {
|
||||
if (slug.trim().toLowerCase().startsWith("skills-sh:")) {
|
||||
fail("Invalid skills.sh ref: use skills-sh/owner/repo/slug");
|
||||
}
|
||||
const skillsShRef = parseSkillsShCatalogRef(slug);
|
||||
if (skillsShRef && (options.version || options.tag || options.card)) {
|
||||
fail("skills.sh verification does not support --version, --tag, or --card");
|
||||
@@ -279,7 +281,7 @@ export async function cmdVerifySkill(
|
||||
try {
|
||||
const url = registryUrl(`${ApiRoutes.skills}/${encodeURIComponent(trimmed)}/verify`, registry);
|
||||
if (skillsShRef) {
|
||||
url.searchParams.set("reference", slug.trim().toLowerCase());
|
||||
url.searchParams.set("reference", skillsShRef.sourceRef);
|
||||
} else if (requested.ownerHandle) {
|
||||
url.searchParams.set("ownerHandle", requested.ownerHandle);
|
||||
}
|
||||
@@ -294,6 +296,7 @@ export async function cmdVerifySkill(
|
||||
{ method: "GET", url: url.toString(), token },
|
||||
ApiV1SkillVerifyResponseSchema,
|
||||
);
|
||||
if (skillsShRef) validateSkillsShVerification(result, skillsShRef.sourceRef);
|
||||
|
||||
if (options.card) {
|
||||
const cardUrl = readSkillCardUrl(result);
|
||||
@@ -318,21 +321,67 @@ export async function cmdVerifySkill(
|
||||
}
|
||||
|
||||
function parseSkillsShCatalogRef(raw: string) {
|
||||
const value = raw.trim().toLowerCase();
|
||||
if (!value.startsWith("skills-sh/")) return null;
|
||||
const segments = value.split("/");
|
||||
if (
|
||||
segments.length !== 4 ||
|
||||
segments[0] !== "skills-sh" ||
|
||||
segments.slice(1).some((segment) => !segment || segment.includes(":") || segment.includes(".."))
|
||||
) {
|
||||
fail("Invalid skills.sh ref: use skills-sh/owner/repo/slug");
|
||||
return parseSkillsShCliReference(raw);
|
||||
}
|
||||
|
||||
function validateSkillsShVerification(result: unknown, requestedRef: string) {
|
||||
const record = asRecord(result);
|
||||
const provenance = asRecord(record.provenance);
|
||||
const security = asRecord(record.security);
|
||||
if (provenance.source !== "skills.sh" || provenance.reference !== requestedRef) {
|
||||
fail("skills.sh verification did not preserve the requested external provenance");
|
||||
}
|
||||
return {
|
||||
owner: segments[1]!,
|
||||
repo: segments[2]!,
|
||||
slug: segments[3]!,
|
||||
};
|
||||
const scanState = security.clawhubScan;
|
||||
const label = typeof security.label === "string" ? security.label.trim() : "";
|
||||
if (scanState !== "unscanned" && scanState !== "scanned") {
|
||||
fail("skills.sh verification did not return a ClawHub scan state");
|
||||
}
|
||||
if (!label) fail("skills.sh verification did not return a trust label");
|
||||
if (scanState === "unscanned") {
|
||||
const reasons = Array.isArray(record.reasons) ? record.reasons : [];
|
||||
if (
|
||||
record.ok !== false ||
|
||||
record.decision !== "fail" ||
|
||||
label !== SKILLS_SH_UNSCANNED_LABEL ||
|
||||
!reasons.includes(SKILLS_SH_UNSCANNED_LABEL)
|
||||
) {
|
||||
fail(`skills.sh verification must report "${SKILLS_SH_UNSCANNED_LABEL}" rather than pass`);
|
||||
}
|
||||
} else {
|
||||
if (label !== SKILLS_SH_SCANNED_LABEL) {
|
||||
fail(`scanned skills.sh verification must report "${SKILLS_SH_SCANNED_LABEL}"`);
|
||||
}
|
||||
if (!isCanonicalNativeSkillRef(record.canonicalRef)) {
|
||||
fail("scanned skills.sh verification must return a canonical native reference");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isCanonicalNativeSkillRef(value: unknown) {
|
||||
if (typeof value !== "string") return false;
|
||||
const canonicalRef = value.trim();
|
||||
if (!canonicalRef.startsWith("@")) return false;
|
||||
try {
|
||||
const parsed = parseSkillRef(canonicalRef);
|
||||
return Boolean(
|
||||
parsed.ownerHandle &&
|
||||
isSafeNativeSkillSegment(parsed.ownerHandle) &&
|
||||
isSafeNativeSkillSegment(parsed.slug) &&
|
||||
canonicalRef === `@${parsed.ownerHandle}/${parsed.slug}`,
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isSafeNativeSkillSegment(value: string) {
|
||||
return Boolean(value) && !value.includes("/") && !value.includes("\\") && !value.includes("..");
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
function parseSkillRef(raw: string) {
|
||||
|
||||
@@ -7,6 +7,13 @@ export async function reportInstalledSkillsTelemetryIfEnabled(params: {
|
||||
slug: string;
|
||||
ownerHandle?: string | null;
|
||||
sourceRef?: string | null;
|
||||
sourceKind?: "skills-sh" | null;
|
||||
sourceRepository?: string | null;
|
||||
sourcePath?: string | null;
|
||||
sourceUrl?: string | null;
|
||||
canonicalRef?: string | null;
|
||||
clawhubScan?: "unscanned" | "scanned" | null;
|
||||
trustLabel?: string | null;
|
||||
version?: string | null;
|
||||
}) {
|
||||
if (!params.token || isTelemetryDisabled()) return;
|
||||
@@ -25,6 +32,13 @@ export async function reportInstalledSkillsTelemetryIfEnabled(params: {
|
||||
slug,
|
||||
ownerHandle: params.ownerHandle ?? undefined,
|
||||
sourceRef: params.sourceRef ?? undefined,
|
||||
sourceKind: params.sourceKind ?? undefined,
|
||||
sourceRepository: params.sourceRepository ?? undefined,
|
||||
sourcePath: params.sourcePath ?? undefined,
|
||||
sourceUrl: params.sourceUrl ?? undefined,
|
||||
canonicalRef: params.canonicalRef ?? undefined,
|
||||
clawhubScan: params.clawhubScan ?? undefined,
|
||||
trustLabel: params.trustLabel ?? undefined,
|
||||
version: params.version ?? undefined,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -106,6 +106,14 @@ function makeOpts() {
|
||||
return makeGlobalOpts();
|
||||
}
|
||||
|
||||
function githubArtifactIdentity(repo: string, path: string, commit: string, contentHash: string) {
|
||||
return JSON.stringify({ installKind: "github", repo, path, commit, contentHash });
|
||||
}
|
||||
|
||||
function archiveArtifactIdentity(canonicalRef: string, version: string) {
|
||||
return JSON.stringify({ installKind: "archive", canonicalRef, version });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mkdtempMock.mockImplementation(async (prefix: string) => `${prefix}123`);
|
||||
mkdirMock.mockResolvedValue(undefined);
|
||||
@@ -490,24 +498,37 @@ describe("skill moderation commands", () => {
|
||||
});
|
||||
|
||||
describe("cmdUpdate", () => {
|
||||
it("updates a legacy slug-keyed skills.sh install through its stored sourceRef", async () => {
|
||||
const sourceRef = "skills-sh/patrick-erichsen/skills/html";
|
||||
it("updates and canonicalizes a legacy slash-form skills.sh lock entry", async () => {
|
||||
const sourceRef = "skills-sh:patrick-erichsen/skills/html";
|
||||
const legacySourceRef = "skills-sh/patrick-erichsen/skills/html";
|
||||
const previousCommit = "a".repeat(40);
|
||||
const nextCommit = "b".repeat(40);
|
||||
const installedFiles = [{ path: "SKILL.md", sha256: "c".repeat(64), size: 1 }];
|
||||
const contentHash = skillStore.buildGitHubFolderContentHash(installedFiles);
|
||||
mockApiRequest.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
slug: sourceRef,
|
||||
installKind: "github",
|
||||
github: {
|
||||
repo: "patrick-erichsen/skills",
|
||||
path: "skills/html",
|
||||
commit: nextCommit,
|
||||
contentHash,
|
||||
sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${nextCommit}/skills/html`,
|
||||
},
|
||||
});
|
||||
mockGetOptionalAuthToken.mockResolvedValue("tkn");
|
||||
mockApiRequest
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
slug: sourceRef,
|
||||
installKind: "github",
|
||||
github: {
|
||||
repo: "patrick-erichsen/skills",
|
||||
path: "skills/html",
|
||||
commit: nextCommit,
|
||||
contentHash,
|
||||
sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${nextCommit}/skills/html`,
|
||||
},
|
||||
provenance: {
|
||||
source: "skills.sh",
|
||||
reference: sourceRef,
|
||||
},
|
||||
trust: {
|
||||
clawhubScan: "unscanned",
|
||||
label: "Not scanned by ClawHub",
|
||||
},
|
||||
canonicalRef: null,
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
mockFetchBinary.mockResolvedValue(new Uint8Array([1, 2, 3]));
|
||||
vi.mocked(readLockfile).mockResolvedValue({
|
||||
version: 1,
|
||||
@@ -515,7 +536,7 @@ describe("cmdUpdate", () => {
|
||||
html: {
|
||||
version: previousCommit,
|
||||
installedAt: 123,
|
||||
sourceRef,
|
||||
sourceRef: legacySourceRef,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -523,7 +544,7 @@ describe("cmdUpdate", () => {
|
||||
version: 1,
|
||||
registry: "https://clawhub.ai",
|
||||
slug: "html",
|
||||
sourceRef,
|
||||
sourceRef: legacySourceRef,
|
||||
installedVersion: previousCommit,
|
||||
installedAt: 123,
|
||||
fingerprint: "hash",
|
||||
@@ -541,7 +562,7 @@ describe("cmdUpdate", () => {
|
||||
{
|
||||
method: "GET",
|
||||
path: `${ApiRoutes.skillsSh}/patrick-erichsen/skills/html/install`,
|
||||
token: undefined,
|
||||
token: "tkn",
|
||||
},
|
||||
expect.anything(),
|
||||
);
|
||||
@@ -553,6 +574,18 @@ describe("cmdUpdate", () => {
|
||||
registry: "https://clawhub.ai",
|
||||
slug: "html",
|
||||
sourceRef,
|
||||
sourceKind: "skills-sh",
|
||||
sourceRepository: "patrick-erichsen/skills",
|
||||
sourcePath: "skills/html",
|
||||
sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${nextCommit}/skills/html`,
|
||||
clawhubScan: "unscanned",
|
||||
trustLabel: "Not scanned by ClawHub",
|
||||
artifactIdentity: githubArtifactIdentity(
|
||||
"patrick-erichsen/skills",
|
||||
"skills/html",
|
||||
nextCommit,
|
||||
contentHash,
|
||||
),
|
||||
installedVersion: nextCommit,
|
||||
installedAt: 123,
|
||||
fingerprint: "hash",
|
||||
@@ -560,13 +593,269 @@ describe("cmdUpdate", () => {
|
||||
expect(writeLockfile).toHaveBeenCalledWith("/work", {
|
||||
version: 1,
|
||||
skills: {
|
||||
html: {
|
||||
[sourceRef]: {
|
||||
version: nextCommit,
|
||||
installedAt: 123,
|
||||
sourceRef,
|
||||
sourceKind: "skills-sh",
|
||||
sourceRepository: "patrick-erichsen/skills",
|
||||
sourcePath: "skills/html",
|
||||
sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${nextCommit}/skills/html`,
|
||||
clawhubScan: "unscanned",
|
||||
trustLabel: "Not scanned by ClawHub",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(mockApiRequest).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"https://clawhub.ai",
|
||||
expect.objectContaining({
|
||||
path: LegacyApiRoutes.cliTelemetryInstall,
|
||||
body: expect.objectContaining({
|
||||
event: "install",
|
||||
slug: "html",
|
||||
sourceRef,
|
||||
sourceKind: "skills-sh",
|
||||
sourceRepository: "patrick-erichsen/skills",
|
||||
sourcePath: "skills/html",
|
||||
clawhubScan: "unscanned",
|
||||
trustLabel: "Not scanned by ClawHub",
|
||||
version: nextCommit,
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("updates an external install through a scanned Repo Sync alias", async () => {
|
||||
const sourceRef = "skills-sh:patrick-erichsen/skills/html";
|
||||
const previousCommit = "a".repeat(40);
|
||||
const nextCommit = "b".repeat(40);
|
||||
const installedFiles = [{ path: "SKILL.md", sha256: "c".repeat(64), size: 1 }];
|
||||
const contentHash = skillStore.buildGitHubFolderContentHash(installedFiles);
|
||||
mockGetOptionalAuthToken.mockResolvedValue("tkn");
|
||||
mockApiRequest
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
slug: "html",
|
||||
installKind: "github",
|
||||
github: {
|
||||
repo: "patrick-erichsen/skills",
|
||||
path: "skills/html",
|
||||
commit: nextCommit,
|
||||
contentHash,
|
||||
sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${nextCommit}/skills/html`,
|
||||
},
|
||||
provenance: {
|
||||
source: "skills.sh",
|
||||
reference: sourceRef,
|
||||
repository: "patrick-erichsen/skills",
|
||||
path: "skills/html",
|
||||
sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${nextCommit}/skills/html`,
|
||||
},
|
||||
trust: {
|
||||
clawhubScan: "scanned",
|
||||
label: "Scanned by ClawHub",
|
||||
},
|
||||
canonicalRef: "@openclaw/html",
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
mockFetchBinary.mockResolvedValue(new Uint8Array([1, 2, 3]));
|
||||
vi.mocked(readLockfile).mockResolvedValue({
|
||||
version: 1,
|
||||
skills: {
|
||||
[sourceRef]: {
|
||||
version: previousCommit,
|
||||
installedAt: 123,
|
||||
sourceRef,
|
||||
sourceKind: "skills-sh",
|
||||
sourceRepository: "patrick-erichsen/skills",
|
||||
sourcePath: "skills/html",
|
||||
sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${previousCommit}/skills/html`,
|
||||
clawhubScan: "unscanned",
|
||||
trustLabel: "Not scanned by ClawHub",
|
||||
},
|
||||
},
|
||||
});
|
||||
vi.mocked(readSkillOrigin).mockResolvedValue({
|
||||
version: 1,
|
||||
registry: "https://clawhub.ai",
|
||||
slug: "html",
|
||||
sourceRef,
|
||||
sourceKind: "skills-sh",
|
||||
sourceRepository: "patrick-erichsen/skills",
|
||||
sourcePath: "skills/html",
|
||||
sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${previousCommit}/skills/html`,
|
||||
clawhubScan: "unscanned",
|
||||
trustLabel: "Not scanned by ClawHub",
|
||||
installedVersion: previousCommit,
|
||||
installedAt: 123,
|
||||
fingerprint: "hash",
|
||||
});
|
||||
vi.mocked(stat).mockResolvedValue({} as unknown as Awaited<ReturnType<typeof stat>>);
|
||||
vi.mocked(listSkillFiles).mockResolvedValue([
|
||||
{ relPath: "SKILL.md", bytes: new Uint8Array([1]) },
|
||||
]);
|
||||
hashSkillFilesMock.mockReturnValue({ fingerprint: "hash", files: installedFiles });
|
||||
|
||||
await cmdUpdate(makeOpts(), sourceRef, {}, false);
|
||||
|
||||
expect(mockFetchBinary).toHaveBeenCalledWith("https://clawhub.ai", {
|
||||
url: `https://codeload.github.com/patrick-erichsen/skills/zip/${nextCommit}`,
|
||||
});
|
||||
expect(writeSkillOrigin).toHaveBeenCalledWith("/work/skills/html", {
|
||||
version: 1,
|
||||
registry: "https://clawhub.ai",
|
||||
slug: "html",
|
||||
sourceRef,
|
||||
sourceKind: "skills-sh",
|
||||
sourceRepository: "patrick-erichsen/skills",
|
||||
sourcePath: "skills/html",
|
||||
sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${nextCommit}/skills/html`,
|
||||
canonicalRef: "@openclaw/html",
|
||||
clawhubScan: "scanned",
|
||||
trustLabel: "Scanned by ClawHub",
|
||||
artifactIdentity: githubArtifactIdentity(
|
||||
"patrick-erichsen/skills",
|
||||
"skills/html",
|
||||
nextCommit,
|
||||
contentHash,
|
||||
),
|
||||
installedVersion: nextCommit,
|
||||
installedAt: 123,
|
||||
fingerprint: "hash",
|
||||
});
|
||||
expect(writeLockfile).toHaveBeenCalledWith("/work", {
|
||||
version: 1,
|
||||
skills: {
|
||||
[sourceRef]: {
|
||||
version: nextCommit,
|
||||
installedAt: 123,
|
||||
sourceRef,
|
||||
sourceKind: "skills-sh",
|
||||
sourceRepository: "patrick-erichsen/skills",
|
||||
sourcePath: "skills/html",
|
||||
sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${nextCommit}/skills/html`,
|
||||
canonicalRef: "@openclaw/html",
|
||||
clawhubScan: "scanned",
|
||||
trustLabel: "Scanned by ClawHub",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(mockApiRequest).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"https://clawhub.ai",
|
||||
expect.objectContaining({
|
||||
path: LegacyApiRoutes.cliTelemetryInstall,
|
||||
body: {
|
||||
event: "install",
|
||||
slug: "html",
|
||||
sourceRef,
|
||||
sourceKind: "skills-sh",
|
||||
sourceRepository: "patrick-erichsen/skills",
|
||||
sourcePath: "skills/html",
|
||||
sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${nextCommit}/skills/html`,
|
||||
canonicalRef: "@openclaw/html",
|
||||
clawhubScan: "scanned",
|
||||
trustLabel: "Scanned by ClawHub",
|
||||
version: nextCommit,
|
||||
},
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("reinstalls before promoting same-version bytes to a scanned alias", async () => {
|
||||
const sourceRef = "skills-sh:patrick-erichsen/skills/html";
|
||||
const commit = "a".repeat(40);
|
||||
const installedFiles = [{ path: "SKILL.md", sha256: "c".repeat(64), size: 1 }];
|
||||
const contentHash = skillStore.buildGitHubFolderContentHash(installedFiles);
|
||||
mockGetOptionalAuthToken.mockResolvedValue("tkn");
|
||||
mockApiRequest.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
slug: "html",
|
||||
installKind: "github",
|
||||
github: {
|
||||
repo: "openclaw/skills",
|
||||
path: "skills/html",
|
||||
commit,
|
||||
contentHash,
|
||||
sourceUrl: `https://github.com/openclaw/skills/tree/${commit}/skills/html`,
|
||||
},
|
||||
provenance: {
|
||||
source: "skills.sh",
|
||||
reference: sourceRef,
|
||||
repository: "openclaw/skills",
|
||||
path: "skills/html",
|
||||
sourceUrl: `https://github.com/openclaw/skills/tree/${commit}/skills/html`,
|
||||
},
|
||||
trust: {
|
||||
clawhubScan: "scanned",
|
||||
label: "Scanned by ClawHub",
|
||||
},
|
||||
canonicalRef: "@openclaw/html",
|
||||
});
|
||||
mockFetchBinary.mockResolvedValue(new Uint8Array([1, 2, 3]));
|
||||
vi.mocked(readLockfile).mockResolvedValue({
|
||||
version: 1,
|
||||
skills: {
|
||||
[sourceRef]: {
|
||||
version: commit,
|
||||
installedAt: 123,
|
||||
sourceRef,
|
||||
sourceKind: "skills-sh",
|
||||
sourceRepository: "patrick-erichsen/skills",
|
||||
sourcePath: "skills/html",
|
||||
clawhubScan: "unscanned",
|
||||
trustLabel: "Not scanned by ClawHub",
|
||||
},
|
||||
},
|
||||
});
|
||||
vi.mocked(readSkillOrigin).mockResolvedValue({
|
||||
version: 1,
|
||||
registry: "https://clawhub.ai",
|
||||
slug: "html",
|
||||
sourceRef,
|
||||
sourceKind: "skills-sh",
|
||||
sourceRepository: "patrick-erichsen/skills",
|
||||
sourcePath: "skills/html",
|
||||
clawhubScan: "unscanned",
|
||||
trustLabel: "Not scanned by ClawHub",
|
||||
artifactIdentity: githubArtifactIdentity(
|
||||
"patrick-erichsen/skills",
|
||||
"skills/html",
|
||||
commit,
|
||||
contentHash,
|
||||
),
|
||||
installedVersion: commit,
|
||||
installedAt: 123,
|
||||
fingerprint: "hash",
|
||||
});
|
||||
vi.mocked(stat).mockResolvedValue({} as unknown as Awaited<ReturnType<typeof stat>>);
|
||||
vi.mocked(listSkillFiles).mockResolvedValue([
|
||||
{ relPath: "SKILL.md", bytes: new Uint8Array([1]) },
|
||||
]);
|
||||
hashSkillFilesMock.mockReturnValue({ fingerprint: "hash", files: installedFiles });
|
||||
|
||||
await cmdUpdate(makeOpts(), sourceRef, {}, false);
|
||||
|
||||
expect(mockFetchBinary).toHaveBeenCalledWith("https://clawhub.ai", {
|
||||
url: `https://codeload.github.com/openclaw/skills/zip/${commit}`,
|
||||
});
|
||||
expect(extractGitHubZipPathToDir).toHaveBeenCalledWith(
|
||||
new Uint8Array([1, 2, 3]),
|
||||
expect.stringContaining("/.html.tmp-"),
|
||||
"skills/html",
|
||||
);
|
||||
expect(writeSkillOrigin).toHaveBeenCalledWith(
|
||||
"/work/skills/html",
|
||||
expect.objectContaining({
|
||||
sourceRepository: "openclaw/skills",
|
||||
canonicalRef: "@openclaw/html",
|
||||
clawhubScan: "scanned",
|
||||
trustLabel: "Scanned by ClawHub",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails when directly updating a pinned skill", async () => {
|
||||
@@ -1413,11 +1702,9 @@ describe("pin commands", () => {
|
||||
});
|
||||
expect(mockLog).toHaveBeenCalledWith("Unpinned demo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdList", () => {
|
||||
it("does not report a tracked skills.sh install as a manual skill", async () => {
|
||||
const sourceRef = "skills-sh/patrick-erichsen/skills/html";
|
||||
it("unpin preserves skills.sh provenance and trust state", async () => {
|
||||
const sourceRef = "skills-sh:patrick-erichsen/skills/html";
|
||||
vi.mocked(readLockfile).mockResolvedValue({
|
||||
version: 1,
|
||||
skills: {
|
||||
@@ -1425,13 +1712,59 @@ describe("cmdList", () => {
|
||||
version: "a".repeat(40),
|
||||
installedAt: 123,
|
||||
sourceRef,
|
||||
sourceKind: "skills-sh",
|
||||
sourceRepository: "patrick-erichsen/skills",
|
||||
sourcePath: "skills/html",
|
||||
sourceUrl: "https://github.com/patrick-erichsen/skills/tree/abc/skills/html",
|
||||
clawhubScan: "unscanned",
|
||||
trustLabel: "Not scanned by ClawHub",
|
||||
pinned: true,
|
||||
pinReason: "hold",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await cmdUnpin(makeOpts(), sourceRef);
|
||||
|
||||
expect(writeLockfile).toHaveBeenCalledWith("/work", {
|
||||
version: 1,
|
||||
skills: {
|
||||
[sourceRef]: {
|
||||
version: "a".repeat(40),
|
||||
installedAt: 123,
|
||||
sourceRef,
|
||||
sourceKind: "skills-sh",
|
||||
sourceRepository: "patrick-erichsen/skills",
|
||||
sourcePath: "skills/html",
|
||||
sourceUrl: "https://github.com/patrick-erichsen/skills/tree/abc/skills/html",
|
||||
clawhubScan: "unscanned",
|
||||
trustLabel: "Not scanned by ClawHub",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdList", () => {
|
||||
it("does not report a tracked skills.sh install as a manual skill", async () => {
|
||||
const sourceRef = "skills-sh:patrick-erichsen/skills/html";
|
||||
vi.mocked(readLockfile).mockResolvedValue({
|
||||
version: 1,
|
||||
skills: {
|
||||
[sourceRef]: {
|
||||
version: "a".repeat(40),
|
||||
installedAt: 123,
|
||||
sourceRef,
|
||||
sourceKind: "skills-sh",
|
||||
clawhubScan: "unscanned",
|
||||
trustLabel: "Not scanned by ClawHub",
|
||||
},
|
||||
},
|
||||
});
|
||||
await cmdList(makeOpts());
|
||||
|
||||
expect(skillStore.listManualSkills).toHaveBeenCalledWith("/work/skills", new Set(["html"]));
|
||||
expect(mockLog).toHaveBeenCalledWith(`${sourceRef} ${"a".repeat(40)}`);
|
||||
expect(mockLog).toHaveBeenCalledWith(`${sourceRef} ${"a".repeat(40)} Not scanned by ClawHub`);
|
||||
});
|
||||
|
||||
it("shows pinned state in list output", async () => {
|
||||
@@ -1451,8 +1784,8 @@ describe("cmdList", () => {
|
||||
});
|
||||
|
||||
describe("cmdInstall", () => {
|
||||
it("installs a skills.sh catalog ref from the approved pinned GitHub resolver", async () => {
|
||||
const sourceRef = "skills-sh/patrick-erichsen/skills/html";
|
||||
it("installs a skills.sh catalog ref from the exact synchronized GitHub resolver", async () => {
|
||||
const sourceRef = "skills-sh:patrick-erichsen/skills/html";
|
||||
const commit = "a".repeat(40);
|
||||
const installedFiles = [{ path: "SKILL.md", sha256: "b".repeat(64), size: 1 }];
|
||||
const contentHash = skillStore.buildGitHubFolderContentHash(installedFiles);
|
||||
@@ -1469,6 +1802,15 @@ describe("cmdInstall", () => {
|
||||
contentHash,
|
||||
sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${commit}/skills/html`,
|
||||
},
|
||||
provenance: {
|
||||
source: "skills.sh",
|
||||
reference: sourceRef,
|
||||
},
|
||||
trust: {
|
||||
clawhubScan: "unscanned",
|
||||
label: "Not scanned by ClawHub",
|
||||
},
|
||||
canonicalRef: null,
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
mockFetchBinary.mockResolvedValue(new Uint8Array([1, 2, 3]));
|
||||
@@ -1503,6 +1845,18 @@ describe("cmdInstall", () => {
|
||||
registry: "https://clawhub.ai",
|
||||
slug: "html",
|
||||
sourceRef,
|
||||
sourceKind: "skills-sh",
|
||||
sourceRepository: "patrick-erichsen/skills",
|
||||
sourcePath: "skills/html",
|
||||
sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${commit}/skills/html`,
|
||||
clawhubScan: "unscanned",
|
||||
trustLabel: "Not scanned by ClawHub",
|
||||
artifactIdentity: githubArtifactIdentity(
|
||||
"patrick-erichsen/skills",
|
||||
"skills/html",
|
||||
commit,
|
||||
contentHash,
|
||||
),
|
||||
installedVersion: commit,
|
||||
installedAt: expect.any(Number),
|
||||
fingerprint: "hash",
|
||||
@@ -1514,6 +1868,12 @@ describe("cmdInstall", () => {
|
||||
version: commit,
|
||||
installedAt: expect.any(Number),
|
||||
sourceRef,
|
||||
sourceKind: "skills-sh",
|
||||
sourceRepository: "patrick-erichsen/skills",
|
||||
sourcePath: "skills/html",
|
||||
sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${commit}/skills/html`,
|
||||
clawhubScan: "unscanned",
|
||||
trustLabel: "Not scanned by ClawHub",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1526,6 +1886,215 @@ describe("cmdInstall", () => {
|
||||
event: "install",
|
||||
slug: "html",
|
||||
sourceRef,
|
||||
sourceKind: "skills-sh",
|
||||
sourceRepository: "patrick-erichsen/skills",
|
||||
sourcePath: "skills/html",
|
||||
sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${commit}/skills/html`,
|
||||
clawhubScan: "unscanned",
|
||||
trustLabel: "Not scanned by ClawHub",
|
||||
version: commit,
|
||||
},
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(mockSpinner.succeed).toHaveBeenCalledWith(
|
||||
expect.stringContaining("(Not scanned by ClawHub)"),
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves a scanned Hosted Mode alias through its canonical native archive", async () => {
|
||||
const sourceRef = "skills-sh:patrick-erichsen/skills/html";
|
||||
mockGetOptionalAuthToken.mockResolvedValue("tkn");
|
||||
mockApiRequest
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
slug: "html",
|
||||
installKind: "archive",
|
||||
archive: {
|
||||
version: "2.0.0",
|
||||
downloadUrl: "https://clawhub.ai/api/v1/download?slug=html&version=2.0.0",
|
||||
},
|
||||
provenance: {
|
||||
source: "skills.sh",
|
||||
reference: sourceRef,
|
||||
repository: "patrick-erichsen/skills",
|
||||
path: "skills/html",
|
||||
sourceUrl: "https://skills.sh/patrick-erichsen/skills/html",
|
||||
},
|
||||
trust: {
|
||||
clawhubScan: "scanned",
|
||||
label: "Scanned by ClawHub",
|
||||
},
|
||||
canonicalRef: "@openclaw/html",
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
mockDownloadZip.mockResolvedValue(new Uint8Array([1, 2, 3]));
|
||||
|
||||
await cmdInstall(makeOpts(), sourceRef);
|
||||
|
||||
expect(mockDownloadZip).toHaveBeenCalledWith("https://clawhub.ai", {
|
||||
slug: "html",
|
||||
ownerHandle: "openclaw",
|
||||
version: "2.0.0",
|
||||
token: "tkn",
|
||||
});
|
||||
expect(writeSkillOrigin).toHaveBeenCalledWith("/work/skills/html", {
|
||||
version: 1,
|
||||
registry: "https://clawhub.ai",
|
||||
slug: "html",
|
||||
sourceRef,
|
||||
sourceKind: "skills-sh",
|
||||
sourceRepository: "patrick-erichsen/skills",
|
||||
sourcePath: "skills/html",
|
||||
sourceUrl: "https://skills.sh/patrick-erichsen/skills/html",
|
||||
canonicalRef: "@openclaw/html",
|
||||
clawhubScan: "scanned",
|
||||
trustLabel: "Scanned by ClawHub",
|
||||
artifactIdentity: archiveArtifactIdentity("@openclaw/html", "2.0.0"),
|
||||
installedVersion: "2.0.0",
|
||||
installedAt: expect.any(Number),
|
||||
fingerprint: undefined,
|
||||
});
|
||||
expect(writeLockfile).toHaveBeenCalledWith("/work", {
|
||||
version: 1,
|
||||
skills: {
|
||||
[sourceRef]: {
|
||||
version: "2.0.0",
|
||||
installedAt: expect.any(Number),
|
||||
sourceRef,
|
||||
sourceKind: "skills-sh",
|
||||
sourceRepository: "patrick-erichsen/skills",
|
||||
sourcePath: "skills/html",
|
||||
sourceUrl: "https://skills.sh/patrick-erichsen/skills/html",
|
||||
canonicalRef: "@openclaw/html",
|
||||
clawhubScan: "scanned",
|
||||
trustLabel: "Scanned by ClawHub",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a skills.sh resolver response that omits external trust metadata", async () => {
|
||||
const sourceRef = "skills-sh:patrick-erichsen/skills/html";
|
||||
mockApiRequest.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
slug: sourceRef,
|
||||
installKind: "github",
|
||||
github: {
|
||||
repo: "patrick-erichsen/skills",
|
||||
path: "skills/html",
|
||||
commit: "a".repeat(40),
|
||||
contentHash: "b".repeat(64),
|
||||
sourceUrl: "https://github.com/patrick-erichsen/skills/tree/main/skills/html",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(cmdInstall(makeOpts(), sourceRef)).rejects.toThrow(
|
||||
"skills.sh catalog resolver did not preserve the requested external provenance",
|
||||
);
|
||||
expect(mockFetchBinary).not.toHaveBeenCalled();
|
||||
expect(writeSkillOrigin).not.toHaveBeenCalled();
|
||||
expect(writeLockfile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("installs a scanned Repo Sync alias from its canonical pinned GitHub descriptor", async () => {
|
||||
const sourceRef = "skills-sh:patrick-erichsen/skills/html";
|
||||
const commit = "a".repeat(40);
|
||||
const installedFiles = [{ path: "SKILL.md", sha256: "b".repeat(64), size: 1 }];
|
||||
const contentHash = skillStore.buildGitHubFolderContentHash(installedFiles);
|
||||
mockGetOptionalAuthToken.mockResolvedValue("tkn");
|
||||
mockApiRequest
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
slug: "html",
|
||||
installKind: "github",
|
||||
github: {
|
||||
repo: "patrick-erichsen/skills",
|
||||
path: "skills/html",
|
||||
commit,
|
||||
contentHash,
|
||||
sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${commit}/skills/html`,
|
||||
},
|
||||
provenance: {
|
||||
source: "skills.sh",
|
||||
reference: sourceRef,
|
||||
repository: "patrick-erichsen/skills",
|
||||
path: "skills/html",
|
||||
sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${commit}/skills/html`,
|
||||
},
|
||||
trust: {
|
||||
clawhubScan: "scanned",
|
||||
label: "Scanned by ClawHub",
|
||||
},
|
||||
canonicalRef: "@openclaw/html",
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
mockFetchBinary.mockResolvedValue(new Uint8Array([1, 2, 3]));
|
||||
vi.mocked(listSkillFiles).mockResolvedValue([
|
||||
{ relPath: "SKILL.md", bytes: new Uint8Array([1]) },
|
||||
]);
|
||||
hashSkillFilesMock.mockReturnValue({ fingerprint: "hash", files: installedFiles });
|
||||
|
||||
await cmdInstall(makeOpts(), sourceRef);
|
||||
|
||||
expect(mockFetchBinary).toHaveBeenCalledWith("https://clawhub.ai", {
|
||||
url: `https://codeload.github.com/patrick-erichsen/skills/zip/${commit}`,
|
||||
});
|
||||
expect(writeSkillOrigin).toHaveBeenCalledWith("/work/skills/html", {
|
||||
version: 1,
|
||||
registry: "https://clawhub.ai",
|
||||
slug: "html",
|
||||
sourceRef,
|
||||
sourceKind: "skills-sh",
|
||||
sourceRepository: "patrick-erichsen/skills",
|
||||
sourcePath: "skills/html",
|
||||
sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${commit}/skills/html`,
|
||||
canonicalRef: "@openclaw/html",
|
||||
clawhubScan: "scanned",
|
||||
trustLabel: "Scanned by ClawHub",
|
||||
artifactIdentity: githubArtifactIdentity(
|
||||
"patrick-erichsen/skills",
|
||||
"skills/html",
|
||||
commit,
|
||||
contentHash,
|
||||
),
|
||||
installedVersion: commit,
|
||||
installedAt: expect.any(Number),
|
||||
fingerprint: "hash",
|
||||
});
|
||||
expect(writeLockfile).toHaveBeenCalledWith("/work", {
|
||||
version: 1,
|
||||
skills: {
|
||||
[sourceRef]: {
|
||||
version: commit,
|
||||
installedAt: expect.any(Number),
|
||||
sourceRef,
|
||||
sourceKind: "skills-sh",
|
||||
sourceRepository: "patrick-erichsen/skills",
|
||||
sourcePath: "skills/html",
|
||||
sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${commit}/skills/html`,
|
||||
canonicalRef: "@openclaw/html",
|
||||
clawhubScan: "scanned",
|
||||
trustLabel: "Scanned by ClawHub",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(mockApiRequest).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"https://clawhub.ai",
|
||||
expect.objectContaining({
|
||||
path: LegacyApiRoutes.cliTelemetryInstall,
|
||||
body: {
|
||||
event: "install",
|
||||
slug: "html",
|
||||
sourceRef,
|
||||
sourceKind: "skills-sh",
|
||||
sourceRepository: "patrick-erichsen/skills",
|
||||
sourcePath: "skills/html",
|
||||
sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${commit}/skills/html`,
|
||||
canonicalRef: "@openclaw/html",
|
||||
clawhubScan: "scanned",
|
||||
trustLabel: "Scanned by ClawHub",
|
||||
version: commit,
|
||||
},
|
||||
}),
|
||||
@@ -1533,33 +2102,53 @@ describe("cmdInstall", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects the unsupported colon-form skills.sh reference", async () => {
|
||||
await expect(cmdInstall(makeOpts(), "skills-sh:patrick-erichsen/skills/html")).rejects.toThrow(
|
||||
"Invalid skills.sh ref: use skills-sh/owner/repo/slug",
|
||||
it("rejects the legacy slash-form skills.sh reference before auth or network access", async () => {
|
||||
await expect(cmdInstall(makeOpts(), "skills-sh/patrick-erichsen/skills/html")).rejects.toThrow(
|
||||
"Invalid skills.sh ref: use skills-sh:owner/repo/slug",
|
||||
);
|
||||
expect(mockGetOptionalAuthToken).not.toHaveBeenCalled();
|
||||
expect(registryMocks.getRegistry).not.toHaveBeenCalled();
|
||||
expect(mockApiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"skills-sh:",
|
||||
"skills-sh:owner/repo",
|
||||
"skills-sh:owner/repo/slug/extra",
|
||||
"skills-sh:owner/../slug",
|
||||
"skills-sh:./repo/slug",
|
||||
"skills-sh:owner/./slug",
|
||||
"skills-sh:owner/repo/.",
|
||||
"skills-sh:owner/repo/slug?ref=main",
|
||||
])("rejects invalid skills.sh reference %s before network access", async (sourceRef) => {
|
||||
await expect(cmdInstall(makeOpts(), sourceRef)).rejects.toThrow(
|
||||
"Invalid skills.sh ref: use skills-sh:owner/repo/slug",
|
||||
);
|
||||
expect(mockGetOptionalAuthToken).not.toHaveBeenCalled();
|
||||
expect(registryMocks.getRegistry).not.toHaveBeenCalled();
|
||||
expect(mockApiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks a skills.sh install when the slug target belongs to another source", async () => {
|
||||
const sourceRef = "skills-sh/patrick-erichsen/skills/html";
|
||||
const sourceRef = "skills-sh:patrick-erichsen/skills/html";
|
||||
statMock.mockResolvedValue({} as Awaited<ReturnType<typeof stat>>);
|
||||
readSkillOriginMock.mockResolvedValue({
|
||||
version: 1,
|
||||
registry: "https://clawhub.ai",
|
||||
slug: "html",
|
||||
sourceRef: "skills-sh/other/repo/html",
|
||||
sourceRef: "skills-sh:other/repo/html",
|
||||
installedVersion: "a".repeat(40),
|
||||
installedAt: 1,
|
||||
});
|
||||
|
||||
await expect(cmdInstall(makeOpts(), sourceRef, undefined, true)).rejects.toThrow(
|
||||
`Install target collision: /work/skills/html is owned by skills-sh/other/repo/html`,
|
||||
`Install target collision: /work/skills/html is owned by skills-sh:other/repo/html`,
|
||||
);
|
||||
expect(mockApiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a skills.sh install whose extracted folder hash differs from the resolver", async () => {
|
||||
const sourceRef = "skills-sh/patrick-erichsen/skills/html";
|
||||
const sourceRef = "skills-sh:patrick-erichsen/skills/html";
|
||||
const commit = "a".repeat(40);
|
||||
mockApiRequest.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
@@ -1572,6 +2161,56 @@ describe("cmdInstall", () => {
|
||||
contentHash: "b".repeat(64),
|
||||
sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${commit}/skills/html`,
|
||||
},
|
||||
provenance: {
|
||||
source: "skills.sh",
|
||||
reference: sourceRef,
|
||||
},
|
||||
trust: {
|
||||
clawhubScan: "unscanned",
|
||||
label: "Not scanned by ClawHub",
|
||||
},
|
||||
canonicalRef: null,
|
||||
});
|
||||
mockFetchBinary.mockResolvedValue(new Uint8Array([1, 2, 3]));
|
||||
listTextFilesMock.mockResolvedValue([{ relPath: "SKILL.md", bytes: new Uint8Array([1]) }]);
|
||||
hashSkillFilesMock.mockReturnValue({
|
||||
fingerprint: "local-fingerprint",
|
||||
files: [{ path: "SKILL.md", sha256: "c".repeat(64), size: 1 }],
|
||||
});
|
||||
|
||||
await expect(cmdInstall(makeOpts(), sourceRef)).rejects.toThrow(
|
||||
"Downloaded skills.sh folder hash does not match the approved ClawHub resolver",
|
||||
);
|
||||
expect(writeSkillOrigin).not.toHaveBeenCalled();
|
||||
expect(writeLockfile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a scanned Repo Sync alias whose pinned GitHub bytes differ from the resolver", async () => {
|
||||
const sourceRef = "skills-sh:patrick-erichsen/skills/html";
|
||||
const commit = "a".repeat(40);
|
||||
mockApiRequest.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
slug: "html",
|
||||
installKind: "github",
|
||||
github: {
|
||||
repo: "patrick-erichsen/skills",
|
||||
path: "skills/html",
|
||||
commit,
|
||||
contentHash: "b".repeat(64),
|
||||
sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${commit}/skills/html`,
|
||||
},
|
||||
provenance: {
|
||||
source: "skills.sh",
|
||||
reference: sourceRef,
|
||||
repository: "patrick-erichsen/skills",
|
||||
path: "skills/html",
|
||||
sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${commit}/skills/html`,
|
||||
},
|
||||
trust: {
|
||||
clawhubScan: "scanned",
|
||||
label: "Scanned by ClawHub",
|
||||
},
|
||||
canonicalRef: "@openclaw/html",
|
||||
});
|
||||
mockFetchBinary.mockResolvedValue(new Uint8Array([1, 2, 3]));
|
||||
listTextFilesMock.mockResolvedValue([{ relPath: "SKILL.md", bytes: new Uint8Array([1]) }]);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
ApiV1SkillResponseSchema,
|
||||
ApiV1SkillVersionResponseSchema,
|
||||
type ApiV1SkillInstallResolveResponse,
|
||||
type Lockfile,
|
||||
type SkillReportFinalAction,
|
||||
type SkillReportListStatus,
|
||||
type SkillReportStatus,
|
||||
@@ -32,6 +33,13 @@ import {
|
||||
} from "../../skills.js";
|
||||
import { getOptionalAuthToken, requireAuthToken } from "../authToken.js";
|
||||
import { getRegistry } from "../registry.js";
|
||||
import {
|
||||
parseSkillsShCliReference,
|
||||
parseStoredSkillsShReference,
|
||||
SKILLS_SH_SCANNED_LABEL,
|
||||
SKILLS_SH_UNSCANNED_LABEL,
|
||||
type SkillsShReference,
|
||||
} from "../skillReference.js";
|
||||
import type { GlobalOpts, ResolveResult } from "../types.js";
|
||||
import {
|
||||
createCrabLoader,
|
||||
@@ -70,10 +78,7 @@ type SkillRef = {
|
||||
slug: string;
|
||||
ownerHandle?: string;
|
||||
sourceRef?: string;
|
||||
skillsSh?: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
};
|
||||
skillsSh?: SkillsShReference;
|
||||
};
|
||||
|
||||
function normalizeOwnerHandle(raw: string | null | undefined) {
|
||||
@@ -89,6 +94,18 @@ type GitHubInstallResolution = Extract<
|
||||
ApiV1SkillInstallResolveResponse,
|
||||
{ ok: true; installKind: "github" }
|
||||
>;
|
||||
type SuccessfulInstallResolution = Extract<ApiV1SkillInstallResolveResponse, { ok: true }>;
|
||||
|
||||
type SkillsShState = {
|
||||
sourceRef: string;
|
||||
sourceKind: "skills-sh";
|
||||
sourceRepository?: string;
|
||||
sourcePath?: string;
|
||||
sourceUrl?: string;
|
||||
canonicalRef?: string;
|
||||
clawhubScan: "unscanned" | "scanned";
|
||||
trustLabel: string;
|
||||
};
|
||||
|
||||
function normalizeSkillSlugOrFail(raw: string) {
|
||||
const slug = raw.trim();
|
||||
@@ -110,21 +127,8 @@ function normalizeSkillSlugForRemote(raw: unknown) {
|
||||
function parseSkillRefOrFail(raw: string): SkillRef {
|
||||
const ref = raw.trim();
|
||||
if (!ref) fail("Slug required");
|
||||
if (ref.toLowerCase().startsWith("skills-sh:")) {
|
||||
fail("Invalid skills.sh ref: use skills-sh/owner/repo/slug");
|
||||
}
|
||||
if (ref.toLowerCase().startsWith("skills-sh/")) {
|
||||
const segments = ref.split("/");
|
||||
if (segments.length !== 4 || segments[0]?.toLowerCase() !== "skills-sh") {
|
||||
fail("Invalid skills.sh ref: use skills-sh/owner/repo/slug");
|
||||
}
|
||||
const [, rawOwner, rawRepo, rawSlug] = segments;
|
||||
const owner = normalizeSkillsShSegment(rawOwner, ref);
|
||||
const repo = normalizeSkillsShSegment(rawRepo, ref);
|
||||
const slug = normalizeSkillsShSegment(rawSlug, ref);
|
||||
const sourceRef = `skills-sh/${owner}/${repo}/${slug}`;
|
||||
return { slug, sourceRef, skillsSh: { owner, repo } };
|
||||
}
|
||||
const skillsSh = parseSkillsShCliReference(ref);
|
||||
if (skillsSh) return { slug: skillsSh.slug, sourceRef: skillsSh.sourceRef, skillsSh };
|
||||
const slashIndex = ref.indexOf("/");
|
||||
if (slashIndex < 0) {
|
||||
return { slug: normalizeSkillSlugOrFail(ref) };
|
||||
@@ -142,18 +146,10 @@ function parseSkillRefOrFail(raw: string): SkillRef {
|
||||
return { slug, ownerHandle };
|
||||
}
|
||||
|
||||
function normalizeSkillsShSegment(raw: string | undefined, ref: string) {
|
||||
const segment = raw?.trim().toLowerCase() ?? "";
|
||||
if (
|
||||
!segment ||
|
||||
segment.includes("\\") ||
|
||||
segment.includes(":") ||
|
||||
segment.includes("..") ||
|
||||
!isSafeSkillSlug(segment)
|
||||
) {
|
||||
fail(`Invalid skills.sh ref: ${ref}`);
|
||||
}
|
||||
return segment;
|
||||
function parseStoredSkillRefOrFail(raw: string): SkillRef {
|
||||
const skillsSh = parseStoredSkillsShReference(raw);
|
||||
if (skillsSh) return { slug: skillsSh.slug, sourceRef: skillsSh.sourceRef, skillsSh };
|
||||
return parseSkillRefOrFail(raw);
|
||||
}
|
||||
|
||||
function isSafeSkillSlug(slug: string) {
|
||||
@@ -171,13 +167,10 @@ function skillTarget(dir: string, ref: SkillRef) {
|
||||
}
|
||||
|
||||
function isSafeSkillIdentity(value: string) {
|
||||
if (value.toLowerCase().startsWith("skills-sh/")) {
|
||||
const segments = value.split("/");
|
||||
return (
|
||||
segments.length === 4 &&
|
||||
segments[0]?.toLowerCase() === "skills-sh" &&
|
||||
segments.slice(1).every((segment) => isSafeSkillSlug(segment) && !segment.includes(":"))
|
||||
);
|
||||
try {
|
||||
if (parseStoredSkillsShReference(value)) return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const slashIndex = value.indexOf("/");
|
||||
if (slashIndex < 0) return isSafeSkillSlug(value);
|
||||
@@ -194,8 +187,16 @@ function findExistingLockKey(
|
||||
const key = skillIdentity(ref);
|
||||
if (lock.skills[key]) return key;
|
||||
if (ref.sourceRef) {
|
||||
const legacyEntry = lock.skills[ref.slug];
|
||||
if (legacyEntry?.sourceRef === ref.sourceRef) return ref.slug;
|
||||
for (const [candidateKey, entry] of Object.entries(lock.skills)) {
|
||||
const candidate = entry.sourceRef ?? candidateKey;
|
||||
try {
|
||||
if (parseStoredSkillsShReference(candidate)?.sourceRef === ref.sourceRef) {
|
||||
return candidateKey;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return key;
|
||||
}
|
||||
if (ref.ownerHandle) {
|
||||
@@ -207,6 +208,16 @@ function findExistingLockKey(
|
||||
return key;
|
||||
}
|
||||
|
||||
function replaceLockEntry(
|
||||
lock: Lockfile,
|
||||
previousKey: string,
|
||||
canonicalKey: string,
|
||||
entry: Lockfile["skills"][string],
|
||||
) {
|
||||
if (previousKey !== canonicalKey) delete lock.skills[previousKey];
|
||||
lock.skills[canonicalKey] = entry;
|
||||
}
|
||||
|
||||
function ownerScopedUrl(registry: string, path: string, ownerHandle?: string) {
|
||||
if (!ownerHandle) return null;
|
||||
const url = registryUrl(path, registry);
|
||||
@@ -381,7 +392,10 @@ export async function cmdInstall(
|
||||
await mkdir(opts.dir, { recursive: true });
|
||||
const lock = await readLockfile(opts.workdir);
|
||||
const lockKey = findExistingLockKey(lock, requested);
|
||||
const localRef = lockKey === skillIdentity(requested) ? requested : parseSkillRefOrFail(lockKey);
|
||||
const localRef =
|
||||
requested.sourceRef || lockKey === skillIdentity(requested)
|
||||
? requested
|
||||
: parseStoredSkillRefOrFail(lockKey);
|
||||
const target = skillTarget(opts.dir, localRef);
|
||||
const targetExists = await fileExists(target);
|
||||
const existingOrigin = requested.sourceRef && targetExists ? await readSkillOrigin(target) : null;
|
||||
@@ -410,12 +424,25 @@ export async function cmdInstall(
|
||||
const spinner = createCrabLoader(`Resolving ${trimmed}`);
|
||||
try {
|
||||
if (requested.sourceRef && requested.skillsSh) {
|
||||
const resolvedInstall = await resolveSkillsShCatalogInstall(registry, requested, token);
|
||||
spinner.text = `Downloading ${trimmed} ${formatGitHubVersion(resolvedInstall.github.commit)}`;
|
||||
const { resolution: resolvedInstall, state } = await resolveSkillsShCatalogInstall(
|
||||
registry,
|
||||
requested,
|
||||
token,
|
||||
);
|
||||
const resolvedVersion = getInstallResolutionVersion(resolvedInstall);
|
||||
const artifactIdentity = getInstallResolutionArtifactIdentity(
|
||||
resolvedInstall,
|
||||
state.canonicalRef,
|
||||
);
|
||||
spinner.text = `Downloading ${trimmed} ${formatInstallResolutionVersion(resolvedInstall)}`;
|
||||
await installSkillWithOptionalStaging(target, targetExists, (installTarget) =>
|
||||
installGitHubSkill(registry, resolvedInstall, installTarget, {
|
||||
expectedContentHash: resolvedInstall.github.contentHash,
|
||||
}),
|
||||
installSkillsShResolution(
|
||||
registry,
|
||||
resolvedInstall,
|
||||
state.canonicalRef,
|
||||
installTarget,
|
||||
token,
|
||||
),
|
||||
);
|
||||
const installedFiles = await listSkillFiles(target);
|
||||
const installedFingerprint =
|
||||
@@ -425,31 +452,32 @@ export async function cmdInstall(
|
||||
version: 1,
|
||||
registry,
|
||||
slug: trimmed,
|
||||
sourceRef: requested.sourceRef,
|
||||
installedVersion: resolvedInstall.github.commit,
|
||||
...state,
|
||||
artifactIdentity,
|
||||
installedVersion: resolvedVersion,
|
||||
installedAt,
|
||||
fingerprint: installedFingerprint,
|
||||
});
|
||||
lock.skills[lockKey] = {
|
||||
...withPinnedMetadata(resolvedInstall.github.commit, installedAt, existingEntry),
|
||||
sourceRef: requested.sourceRef,
|
||||
};
|
||||
replaceLockEntry(lock, lockKey, requested.sourceRef, {
|
||||
...withPinnedMetadata(resolvedVersion, installedAt, existingEntry),
|
||||
...state,
|
||||
});
|
||||
await writeLockfile(opts.workdir, lock);
|
||||
await reportInstalledSkillsTelemetryIfEnabled({
|
||||
token,
|
||||
registry,
|
||||
slug: trimmed,
|
||||
sourceRef: requested.sourceRef,
|
||||
version: resolvedInstall.github.commit,
|
||||
...state,
|
||||
version: resolvedVersion,
|
||||
});
|
||||
spinner.succeed(
|
||||
`${styleText("Installed", "brand")} ${styleText(
|
||||
requested.sourceRef,
|
||||
"strong",
|
||||
)} ${styleText(formatGitHubVersion(resolvedInstall.github.commit), "muted")} -> ${styleText(
|
||||
)} ${styleText(formatInstallResolutionVersion(resolvedInstall), "muted")} -> ${styleText(
|
||||
target,
|
||||
"muted",
|
||||
)}`,
|
||||
)} (${state.trustLabel})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -639,7 +667,7 @@ export async function cmdUpdate(
|
||||
|
||||
for (const entry of slugs) {
|
||||
const entryLock = lock.skills[entry];
|
||||
const entryRef = parseSkillRefOrFail(entryLock?.sourceRef ?? entry);
|
||||
const entryRef = parseStoredSkillRefOrFail(entryLock?.sourceRef ?? entry);
|
||||
const spinner = createCrabLoader(`Checking ${entry}`);
|
||||
try {
|
||||
const target = skillTarget(opts.dir, entryRef);
|
||||
@@ -658,21 +686,30 @@ export async function cmdUpdate(
|
||||
const filesOnDisk = exists ? await listSkillFiles(target) : [];
|
||||
const localFingerprint =
|
||||
filesOnDisk.length > 0 ? hashSkillFiles(filesOnDisk).fingerprint : null;
|
||||
const latestInstall = await resolveSkillsShCatalogInstall(registry, entryRef, token);
|
||||
const targetVersion = latestInstall.github.commit;
|
||||
const originFingerprint =
|
||||
existingOrigin?.sourceRef === entryRef.sourceRef ? existingOrigin.fingerprint : undefined;
|
||||
const { resolution: latestInstall, state } = await resolveSkillsShCatalogInstall(
|
||||
registry,
|
||||
entryRef,
|
||||
token,
|
||||
);
|
||||
const targetVersion = getInstallResolutionVersion(latestInstall);
|
||||
const targetArtifactIdentity = getInstallResolutionArtifactIdentity(
|
||||
latestInstall,
|
||||
state.canonicalRef,
|
||||
);
|
||||
const originFingerprint = sameSkillsShSource(existingOrigin?.sourceRef, entryRef.sourceRef)
|
||||
? existingOrigin?.fingerprint
|
||||
: undefined;
|
||||
const hasLocalChanges = Boolean(
|
||||
exists &&
|
||||
localFingerprint &&
|
||||
(!originFingerprint || originFingerprint !== localFingerprint),
|
||||
);
|
||||
const matched =
|
||||
existingOrigin?.sourceRef === entryRef.sourceRef &&
|
||||
sameSkillsShSource(existingOrigin?.sourceRef, entryRef.sourceRef) &&
|
||||
originFingerprint &&
|
||||
localFingerprint &&
|
||||
originFingerprint === localFingerprint
|
||||
? existingOrigin.installedVersion
|
||||
? existingOrigin?.installedVersion
|
||||
: null;
|
||||
|
||||
if (hasLocalChanges && !options.force) {
|
||||
@@ -682,42 +719,83 @@ export async function cmdUpdate(
|
||||
continue;
|
||||
}
|
||||
const confirm = await promptConfirm(
|
||||
`${entry}: local changes (no match). Overwrite with ${formatGitHubVersion(
|
||||
targetVersion,
|
||||
`${entry}: local changes (no match). Overwrite with ${formatInstallResolutionVersion(
|
||||
latestInstall,
|
||||
)}?`,
|
||||
);
|
||||
if (!confirm) {
|
||||
console.log(`${entry}: skipped`);
|
||||
continue;
|
||||
}
|
||||
spinner.start(`Updating ${entry} -> ${formatGitHubVersion(targetVersion)}`);
|
||||
spinner.start(`Updating ${entry} -> ${formatInstallResolutionVersion(latestInstall)}`);
|
||||
}
|
||||
|
||||
if (matched === targetVersion && !options.force && !hasLocalChanges) {
|
||||
if (
|
||||
matched === targetVersion &&
|
||||
existingOrigin?.artifactIdentity === targetArtifactIdentity &&
|
||||
!options.force &&
|
||||
!hasLocalChanges
|
||||
) {
|
||||
const installedAt = existingOrigin?.installedAt ?? lock.skills[entry]?.installedAt;
|
||||
if (
|
||||
exists &&
|
||||
installedAt &&
|
||||
(!existingOrigin ||
|
||||
!sameSkillsShSource(existingOrigin.sourceRef, state.sourceRef) ||
|
||||
existingOrigin.sourceRepository !== state.sourceRepository ||
|
||||
existingOrigin.sourcePath !== state.sourcePath ||
|
||||
existingOrigin.sourceUrl !== state.sourceUrl ||
|
||||
existingOrigin.canonicalRef !== state.canonicalRef ||
|
||||
existingOrigin.clawhubScan !== state.clawhubScan ||
|
||||
existingOrigin.trustLabel !== state.trustLabel)
|
||||
) {
|
||||
await writeSkillOrigin(target, {
|
||||
version: 1,
|
||||
registry: existingOrigin?.registry ?? registry,
|
||||
slug: entryRef.slug,
|
||||
...state,
|
||||
artifactIdentity: targetArtifactIdentity,
|
||||
installedVersion: targetVersion,
|
||||
installedAt,
|
||||
fingerprint: localFingerprint ?? existingOrigin?.fingerprint,
|
||||
});
|
||||
}
|
||||
if (
|
||||
lock.skills[entry]?.version !== targetVersion ||
|
||||
lock.skills[entry]?.sourceRef !== entryRef.sourceRef
|
||||
lock.skills[entry]?.sourceRef !== entryRef.sourceRef ||
|
||||
lock.skills[entry]?.sourceRepository !== state.sourceRepository ||
|
||||
lock.skills[entry]?.sourcePath !== state.sourcePath ||
|
||||
lock.skills[entry]?.sourceUrl !== state.sourceUrl ||
|
||||
lock.skills[entry]?.canonicalRef !== state.canonicalRef ||
|
||||
lock.skills[entry]?.clawhubScan !== state.clawhubScan ||
|
||||
lock.skills[entry]?.trustLabel !== state.trustLabel
|
||||
) {
|
||||
lock.skills[entry] = {
|
||||
replaceLockEntry(lock, entry, entryRef.sourceRef, {
|
||||
...withPinnedMetadata(
|
||||
targetVersion,
|
||||
lock.skills[entry]?.installedAt ?? Date.now(),
|
||||
lock.skills[entry],
|
||||
),
|
||||
sourceRef: entryRef.sourceRef,
|
||||
};
|
||||
...state,
|
||||
});
|
||||
markLockDirty();
|
||||
await flushLockfile();
|
||||
}
|
||||
spinner.succeed(`${entry}: up to date (${formatGitHubVersion(targetVersion)})`);
|
||||
spinner.succeed(
|
||||
`${entry}: up to date (${formatInstallResolutionVersion(latestInstall)})`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
spinner.text = `Updating ${entry} -> ${formatGitHubVersion(targetVersion)}`;
|
||||
spinner.text = `Updating ${entry} -> ${formatInstallResolutionVersion(latestInstall)}`;
|
||||
await installSkillWithOptionalStaging(target, exists, (installTarget) =>
|
||||
installGitHubSkill(registry, latestInstall, installTarget, {
|
||||
expectedContentHash: latestInstall.github.contentHash,
|
||||
}),
|
||||
installSkillsShResolution(
|
||||
registry,
|
||||
latestInstall,
|
||||
state.canonicalRef,
|
||||
installTarget,
|
||||
token,
|
||||
),
|
||||
);
|
||||
const installedFiles = await listSkillFiles(target);
|
||||
const installedFingerprint =
|
||||
@@ -727,18 +805,28 @@ export async function cmdUpdate(
|
||||
version: 1,
|
||||
registry: existingOrigin?.registry ?? registry,
|
||||
slug: entryRef.slug,
|
||||
sourceRef: entryRef.sourceRef,
|
||||
...state,
|
||||
artifactIdentity: targetArtifactIdentity,
|
||||
installedVersion: targetVersion,
|
||||
installedAt,
|
||||
fingerprint: installedFingerprint,
|
||||
});
|
||||
lock.skills[entry] = {
|
||||
replaceLockEntry(lock, entry, entryRef.sourceRef, {
|
||||
...withPinnedMetadata(targetVersion, installedAt, lock.skills[entry]),
|
||||
sourceRef: entryRef.sourceRef,
|
||||
};
|
||||
...state,
|
||||
});
|
||||
markLockDirty();
|
||||
await flushLockfile();
|
||||
spinner.succeed(`${entry}: updated -> ${formatGitHubVersion(targetVersion)}`);
|
||||
await reportInstalledSkillsTelemetryIfEnabled({
|
||||
token,
|
||||
registry,
|
||||
slug: entryRef.slug,
|
||||
...state,
|
||||
version: targetVersion,
|
||||
});
|
||||
spinner.succeed(
|
||||
`${entry}: updated -> ${formatInstallResolutionVersion(latestInstall)} (${state.trustLabel})`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1049,7 +1137,7 @@ export async function cmdList(opts: GlobalOpts) {
|
||||
const entries = Object.entries(lock.skills);
|
||||
const trackedTargets = new Set(
|
||||
Object.keys(lock.skills).map((entry) => {
|
||||
const ref = parseSkillRefOrFail(entry);
|
||||
const ref = parseStoredSkillRefOrFail(lock.skills[entry]?.sourceRef ?? entry);
|
||||
return ref.sourceRef ? ref.slug : entry;
|
||||
}),
|
||||
);
|
||||
@@ -1059,8 +1147,11 @@ export async function cmdList(opts: GlobalOpts) {
|
||||
return;
|
||||
}
|
||||
for (const [slug, entry] of entries) {
|
||||
const storedRef = parseStoredSkillsShReference(entry.sourceRef ?? slug);
|
||||
const displaySlug = storedRef?.sourceRef ?? slug;
|
||||
const pinned = isPinnedSkillEntry(entry) ? ` pinned${formatPinnedDetails(entry)}` : "";
|
||||
console.log(`${slug} ${entry.version ?? "latest"}${pinned}`);
|
||||
const trust = entry.clawhubScan === "unscanned" ? ` ${entry.trustLabel}` : "";
|
||||
console.log(`${displaySlug} ${entry.version ?? "latest"}${pinned}${trust}`);
|
||||
}
|
||||
if (manualSkills.length > 0) {
|
||||
if (entries.length > 0) console.log();
|
||||
@@ -1103,11 +1194,8 @@ export async function cmdUnpin(opts: GlobalOpts, slug: string) {
|
||||
if (!existing) fail(`Not installed: ${label}`);
|
||||
if (!isPinnedSkillEntry(existing)) fail(`Skill "${label}" is not pinned`);
|
||||
|
||||
lock.skills[lockKey] = {
|
||||
version: existing.version,
|
||||
installedAt: existing.installedAt,
|
||||
...(existing.ownerHandle ? { ownerHandle: existing.ownerHandle } : {}),
|
||||
};
|
||||
const { pinned: _pinned, pinReason: _pinReason, ...unpinned } = existing;
|
||||
lock.skills[lockKey] = unpinned;
|
||||
await writeLockfile(opts.workdir, lock);
|
||||
console.log(`Unpinned ${label}`);
|
||||
}
|
||||
@@ -1136,7 +1224,8 @@ export async function cmdUninstall(
|
||||
}
|
||||
}
|
||||
|
||||
const localRef = lockKey === skillIdentity(requested) ? requested : parseSkillRefOrFail(lockKey);
|
||||
const localRef =
|
||||
lockKey === skillIdentity(requested) ? requested : parseStoredSkillRefOrFail(lockKey);
|
||||
const spinner = createCrabLoader(`Uninstalling ${skillIdentity(localRef)}`);
|
||||
try {
|
||||
const target = skillTarget(opts.dir, localRef);
|
||||
@@ -1458,7 +1547,7 @@ async function resolveLatestSkillInstall(
|
||||
|
||||
async function resolveSkillsShCatalogInstall(registry: string, ref: SkillRef, token?: string) {
|
||||
if (!ref.sourceRef || !ref.skillsSh) {
|
||||
fail("Invalid skills.sh ref: use skills-sh/owner/repo/slug");
|
||||
fail("Invalid skills.sh ref: use skills-sh:owner/repo/slug");
|
||||
}
|
||||
const path = `${ApiRoutes.skillsSh}/${encodeURIComponent(
|
||||
ref.skillsSh.owner,
|
||||
@@ -1469,10 +1558,168 @@ async function resolveSkillsShCatalogInstall(registry: string, ref: SkillRef, to
|
||||
ApiV1SkillInstallResolveResponseSchema,
|
||||
);
|
||||
if (!resolution.ok) fail(resolution.message);
|
||||
if (resolution.installKind !== "github") {
|
||||
fail("skills.sh catalog resolver did not return a GitHub install");
|
||||
const metadata = readSkillsShResolverMetadata(resolution, ref.sourceRef);
|
||||
if (metadata.clawhubScan === "unscanned") {
|
||||
if (resolution.installKind !== "github") {
|
||||
fail("unscanned skills.sh catalog entries must resolve to an exact GitHub install");
|
||||
}
|
||||
} else {
|
||||
if (!metadata.canonicalRef) {
|
||||
fail("adopted skills.sh aliases must return their canonical native reference");
|
||||
}
|
||||
validateCanonicalSkillsShAliasRef(metadata.canonicalRef);
|
||||
}
|
||||
return resolution;
|
||||
return {
|
||||
resolution,
|
||||
state: {
|
||||
sourceRef: ref.sourceRef,
|
||||
sourceKind: "skills-sh",
|
||||
...(resolution.installKind === "github"
|
||||
? {
|
||||
sourceRepository: resolution.github.repo,
|
||||
sourcePath: resolution.github.path,
|
||||
sourceUrl: resolution.github.sourceUrl,
|
||||
}
|
||||
: {
|
||||
...(metadata.sourceRepository ? { sourceRepository: metadata.sourceRepository } : {}),
|
||||
...(metadata.sourcePath ? { sourcePath: metadata.sourcePath } : {}),
|
||||
...(metadata.sourceUrl ? { sourceUrl: metadata.sourceUrl } : {}),
|
||||
}),
|
||||
...(metadata.canonicalRef ? { canonicalRef: metadata.canonicalRef } : {}),
|
||||
clawhubScan: metadata.clawhubScan,
|
||||
trustLabel: metadata.trustLabel,
|
||||
} satisfies SkillsShState,
|
||||
};
|
||||
}
|
||||
|
||||
function readSkillsShResolverMetadata(
|
||||
resolution: ApiV1SkillInstallResolveResponse,
|
||||
requestedRef: string,
|
||||
): {
|
||||
clawhubScan: "unscanned" | "scanned";
|
||||
trustLabel: string;
|
||||
canonicalRef?: string;
|
||||
sourceRepository?: string;
|
||||
sourcePath?: string;
|
||||
sourceUrl?: string;
|
||||
} {
|
||||
const record = asRecord(resolution);
|
||||
const provenance = asRecord(record.provenance);
|
||||
const trust = asRecord(record.trust);
|
||||
if (provenance.source !== "skills.sh" || provenance.reference !== requestedRef) {
|
||||
fail("skills.sh catalog resolver did not preserve the requested external provenance");
|
||||
}
|
||||
const clawhubScan = trust.clawhubScan;
|
||||
const trustLabel = typeof trust.label === "string" ? trust.label.trim() : "";
|
||||
if (clawhubScan !== "unscanned" && clawhubScan !== "scanned") {
|
||||
fail("skills.sh catalog resolver did not return a ClawHub scan state");
|
||||
}
|
||||
if (!trustLabel) {
|
||||
fail("skills.sh catalog resolver did not return a trust label");
|
||||
}
|
||||
if (clawhubScan === "unscanned" && trustLabel !== SKILLS_SH_UNSCANNED_LABEL) {
|
||||
fail(`skills.sh catalog resolver must label unscanned sources "${SKILLS_SH_UNSCANNED_LABEL}"`);
|
||||
}
|
||||
if (clawhubScan === "scanned" && trustLabel !== SKILLS_SH_SCANNED_LABEL) {
|
||||
fail(`skills.sh catalog resolver must label scanned sources "${SKILLS_SH_SCANNED_LABEL}"`);
|
||||
}
|
||||
const canonicalRef =
|
||||
record.canonicalRef === null || record.canonicalRef === undefined
|
||||
? undefined
|
||||
: typeof record.canonicalRef === "string"
|
||||
? record.canonicalRef.trim()
|
||||
: fail("skills.sh catalog resolver returned an invalid canonical reference");
|
||||
const sourceRepository =
|
||||
typeof provenance.repository === "string" ? provenance.repository.trim() : undefined;
|
||||
const sourcePath = typeof provenance.path === "string" ? provenance.path.trim() : undefined;
|
||||
const sourceUrl =
|
||||
typeof provenance.sourceUrl === "string" ? provenance.sourceUrl.trim() : undefined;
|
||||
return {
|
||||
clawhubScan,
|
||||
trustLabel,
|
||||
canonicalRef,
|
||||
sourceRepository,
|
||||
sourcePath,
|
||||
sourceUrl,
|
||||
};
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
function sameSkillsShSource(left: string | undefined, right: string | undefined) {
|
||||
if (!left || !right) return false;
|
||||
try {
|
||||
return parseStoredSkillsShReference(left)?.sourceRef === right;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function validateCanonicalSkillsShAliasRef(canonicalRef: string) {
|
||||
const canonical = parseSkillRefOrFail(canonicalRef);
|
||||
if (!canonical.ownerHandle || canonical.sourceRef) {
|
||||
fail("adopted skills.sh alias returned an invalid canonical native reference");
|
||||
}
|
||||
return canonical;
|
||||
}
|
||||
|
||||
function getInstallResolutionVersion(resolution: SuccessfulInstallResolution) {
|
||||
return resolution.installKind === "github"
|
||||
? resolution.github.commit
|
||||
: resolution.archive.version;
|
||||
}
|
||||
|
||||
function getInstallResolutionArtifactIdentity(
|
||||
resolution: SuccessfulInstallResolution,
|
||||
canonicalRef: string | undefined,
|
||||
) {
|
||||
return resolution.installKind === "github"
|
||||
? JSON.stringify({
|
||||
installKind: resolution.installKind,
|
||||
repo: resolution.github.repo,
|
||||
path: resolution.github.path,
|
||||
commit: resolution.github.commit,
|
||||
contentHash: resolution.github.contentHash,
|
||||
})
|
||||
: JSON.stringify({
|
||||
installKind: resolution.installKind,
|
||||
canonicalRef,
|
||||
version: resolution.archive.version,
|
||||
});
|
||||
}
|
||||
|
||||
function formatInstallResolutionVersion(resolution: SuccessfulInstallResolution) {
|
||||
return resolution.installKind === "github"
|
||||
? formatGitHubVersion(resolution.github.commit)
|
||||
: `v${resolution.archive.version}`;
|
||||
}
|
||||
|
||||
async function installSkillsShResolution(
|
||||
registry: string,
|
||||
resolution: SuccessfulInstallResolution,
|
||||
canonicalRef: string | undefined,
|
||||
target: string,
|
||||
token: string | undefined,
|
||||
) {
|
||||
if (resolution.installKind === "github") {
|
||||
await installGitHubSkill(registry, resolution, target, {
|
||||
expectedContentHash: resolution.github.contentHash,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!canonicalRef) fail("adopted skills.sh alias did not return a canonical native reference");
|
||||
const canonical = validateCanonicalSkillsShAliasRef(canonicalRef);
|
||||
const zip = await downloadZip(registry, {
|
||||
slug: canonical.slug,
|
||||
ownerHandle: canonical.ownerHandle,
|
||||
version: resolution.archive.version,
|
||||
token,
|
||||
});
|
||||
await extractZipToDir(zip, target);
|
||||
}
|
||||
|
||||
async function installGitHubSkill(
|
||||
@@ -1518,11 +1765,14 @@ function assertSkillsShTargetOwnership(args: {
|
||||
const lockedSource = args.lock.skills[args.lockKey]?.sourceRef;
|
||||
if (
|
||||
args.existingOrigin?.sourceRef &&
|
||||
args.existingOrigin.sourceRef !== args.requested.sourceRef
|
||||
!sameSkillsShSource(args.existingOrigin.sourceRef, args.requested.sourceRef)
|
||||
) {
|
||||
fail(`Install target collision: ${args.target} is owned by ${args.existingOrigin.sourceRef}`);
|
||||
}
|
||||
if (!args.existingOrigin?.sourceRef && lockedSource !== args.requested.sourceRef) {
|
||||
if (
|
||||
!args.existingOrigin?.sourceRef &&
|
||||
!sameSkillsShSource(lockedSource, args.requested.sourceRef)
|
||||
) {
|
||||
const owner = args.existingOrigin?.ownerHandle
|
||||
? `@${args.existingOrigin.ownerHandle}/${args.existingOrigin.slug}`
|
||||
: (args.existingOrigin?.slug ?? "another local skill");
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
const SKILLS_SH_PREFIX = "skills-sh:";
|
||||
const SKILLS_SH_LEGACY_PREFIX = "skills-sh/";
|
||||
export const SKILLS_SH_UNSCANNED_LABEL = "Not scanned by ClawHub";
|
||||
export const SKILLS_SH_SCANNED_LABEL = "Scanned by ClawHub";
|
||||
|
||||
export type SkillsShReference = {
|
||||
owner: string;
|
||||
repo: string;
|
||||
slug: string;
|
||||
sourceRef: string;
|
||||
};
|
||||
|
||||
export function parseSkillsShCliReference(raw: string): SkillsShReference | null {
|
||||
return parseSkillsShReference(raw, false);
|
||||
}
|
||||
|
||||
export function parseStoredSkillsShReference(raw: string): SkillsShReference | null {
|
||||
return parseSkillsShReference(raw, true);
|
||||
}
|
||||
|
||||
function parseSkillsShReference(raw: string, allowLegacySlash: boolean) {
|
||||
const value = raw.trim();
|
||||
const lower = value.toLowerCase();
|
||||
let payload: string;
|
||||
if (lower.startsWith(SKILLS_SH_PREFIX)) {
|
||||
payload = value.slice(SKILLS_SH_PREFIX.length);
|
||||
} else if (lower.startsWith(SKILLS_SH_LEGACY_PREFIX)) {
|
||||
if (!allowLegacySlash) {
|
||||
throw new Error(`Invalid skills.sh ref: use ${SKILLS_SH_PREFIX}owner/repo/slug`);
|
||||
}
|
||||
payload = value.slice(SKILLS_SH_LEGACY_PREFIX.length);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
const segments = payload.split("/");
|
||||
if (segments.length !== 3) {
|
||||
throw new Error(`Invalid skills.sh ref: use ${SKILLS_SH_PREFIX}owner/repo/slug`);
|
||||
}
|
||||
const [rawOwner, rawRepo, rawSlug] = segments;
|
||||
const owner = normalizeSegment(rawOwner);
|
||||
const repo = normalizeSegment(rawRepo);
|
||||
const slug = normalizeSegment(rawSlug);
|
||||
if (!owner || !repo || !slug) {
|
||||
throw new Error(`Invalid skills.sh ref: use ${SKILLS_SH_PREFIX}owner/repo/slug`);
|
||||
}
|
||||
return {
|
||||
owner,
|
||||
repo,
|
||||
slug,
|
||||
sourceRef: `${SKILLS_SH_PREFIX}${owner}/${repo}/${slug}`,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSegment(raw: string | undefined) {
|
||||
const segment = raw?.trim().toLowerCase() ?? "";
|
||||
if (
|
||||
!segment ||
|
||||
!/^[a-z0-9._-]+$/.test(segment) ||
|
||||
segment === "." ||
|
||||
segment.includes("/") ||
|
||||
segment.includes("\\") ||
|
||||
segment.includes(":") ||
|
||||
segment.includes("..")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return segment;
|
||||
}
|
||||
@@ -25,6 +25,13 @@ export const LockfileSchema = type({
|
||||
installedAt: "number",
|
||||
ownerHandle: "string?",
|
||||
sourceRef: "string?",
|
||||
sourceKind: '"skills-sh"?',
|
||||
sourceRepository: "string?",
|
||||
sourcePath: "string?",
|
||||
sourceUrl: "string?",
|
||||
canonicalRef: "string?",
|
||||
clawhubScan: '"unscanned"|"scanned"?',
|
||||
trustLabel: "string?",
|
||||
pinned: "boolean?",
|
||||
pinReason: "string?",
|
||||
},
|
||||
|
||||
@@ -81,6 +81,13 @@ describe("skills", () => {
|
||||
installedAt: 1,
|
||||
pinned: true,
|
||||
pinReason: "awaiting moderation review",
|
||||
sourceRef: "skills-sh:openclaw/skills/demo",
|
||||
sourceKind: "skills-sh",
|
||||
sourceRepository: "openclaw/skills",
|
||||
sourcePath: "skills/demo",
|
||||
sourceUrl: "https://github.com/openclaw/skills/tree/abc/skills/demo",
|
||||
clawhubScan: "unscanned",
|
||||
trustLabel: "Not scanned by ClawHub",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -88,6 +95,15 @@ describe("skills", () => {
|
||||
expect(read.skills.demo?.version).toBe("1.0.0");
|
||||
expect(read.skills.demo?.pinned).toBe(true);
|
||||
expect(read.skills.demo?.pinReason).toBe("awaiting moderation review");
|
||||
expect(read.skills.demo).toMatchObject({
|
||||
sourceRef: "skills-sh:openclaw/skills/demo",
|
||||
sourceKind: "skills-sh",
|
||||
sourceRepository: "openclaw/skills",
|
||||
sourcePath: "skills/demo",
|
||||
sourceUrl: "https://github.com/openclaw/skills/tree/abc/skills/demo",
|
||||
clawhubScan: "unscanned",
|
||||
trustLabel: "Not scanned by ClawHub",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns empty lockfile on invalid json", async () => {
|
||||
@@ -300,6 +316,16 @@ describe("skills", () => {
|
||||
version: 1,
|
||||
registry: "https://example.com",
|
||||
slug: "demo",
|
||||
sourceRef: "skills-sh:openclaw/skills/demo",
|
||||
sourceKind: "skills-sh",
|
||||
sourceRepository: "openclaw/skills",
|
||||
sourcePath: "skills/demo",
|
||||
sourceUrl: "https://github.com/openclaw/skills/tree/abc/skills/demo",
|
||||
canonicalRef: "@openclaw/demo",
|
||||
clawhubScan: "unscanned",
|
||||
trustLabel: "Not scanned by ClawHub",
|
||||
artifactIdentity:
|
||||
'{"installKind":"github","repo":"openclaw/skills","path":"skills/demo","commit":"abc","contentHash":"def"}',
|
||||
installedVersion: "1.2.3",
|
||||
installedAt: 123,
|
||||
};
|
||||
|
||||
@@ -17,6 +17,14 @@ export type SkillOrigin = {
|
||||
slug: string;
|
||||
ownerHandle?: string;
|
||||
sourceRef?: string;
|
||||
sourceKind?: "skills-sh";
|
||||
sourceRepository?: string;
|
||||
sourcePath?: string;
|
||||
sourceUrl?: string;
|
||||
canonicalRef?: string;
|
||||
clawhubScan?: "unscanned" | "scanned";
|
||||
trustLabel?: string;
|
||||
artifactIdentity?: string;
|
||||
installedVersion: string;
|
||||
installedAt: number;
|
||||
fingerprint?: string;
|
||||
@@ -217,6 +225,19 @@ export async function readSkillOrigin(skillFolder: string): Promise<SkillOrigin
|
||||
slug: parsed.slug,
|
||||
ownerHandle: typeof parsed.ownerHandle === "string" ? parsed.ownerHandle : undefined,
|
||||
sourceRef: typeof parsed.sourceRef === "string" ? parsed.sourceRef : undefined,
|
||||
sourceKind: parsed.sourceKind === "skills-sh" ? "skills-sh" : undefined,
|
||||
sourceRepository:
|
||||
typeof parsed.sourceRepository === "string" ? parsed.sourceRepository : undefined,
|
||||
sourcePath: typeof parsed.sourcePath === "string" ? parsed.sourcePath : undefined,
|
||||
sourceUrl: typeof parsed.sourceUrl === "string" ? parsed.sourceUrl : undefined,
|
||||
canonicalRef: typeof parsed.canonicalRef === "string" ? parsed.canonicalRef : undefined,
|
||||
clawhubScan:
|
||||
parsed.clawhubScan === "unscanned" || parsed.clawhubScan === "scanned"
|
||||
? parsed.clawhubScan
|
||||
: undefined,
|
||||
trustLabel: typeof parsed.trustLabel === "string" ? parsed.trustLabel : undefined,
|
||||
artifactIdentity:
|
||||
typeof parsed.artifactIdentity === "string" ? parsed.artifactIdentity : undefined,
|
||||
installedVersion: parsed.installedVersion,
|
||||
installedAt: parsed.installedAt,
|
||||
fingerprint: typeof parsed.fingerprint === "string" ? parsed.fingerprint : undefined,
|
||||
|
||||
Reference in New Issue
Block a user