Skip to content

Commit dd7f2ef

Browse files
Persist ClawHub skill install provenance (#93283)
Summary: - The PR adds artifact, installed skill-file, source URL, and verification-envelope fields to ClawHub skill origin/lock metadata while keeping install telemetry restricted to the older version/registry shape. - PR surface: Source +144, Tests +139. Total +283 across 2 files. - Reproducibility: not applicable. as a bug reproduction. Source inspection shows current main lacks the richer `.clawhub` provenance fields, and the PR body provides after-patch live output from a ClawHub install. Automerge notes: - PR branch already contained follow-up commit before automerge: Persist ClawHub skill install provenance Validation: - ClawSweeper review passed for head 65774f4. - Required merge gates passed before the squash merge. Prepared head SHA: 65774f4 Review: #93283 (comment) Co-authored-by: momothemage <[email protected]> Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> Approved-by: momothemage
1 parent 018d279 commit dd7f2ef

2 files changed

Lines changed: 295 additions & 12 deletions

File tree

src/skills/lifecycle/clawhub.test.ts

Lines changed: 142 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
// ClawHub lifecycle tests cover registry metadata lookup and error handling.
2+
import { createHash } from "node:crypto";
23
import fs from "node:fs/promises";
34
import os from "node:os";
45
import path from "node:path";
5-
import { beforeEach, describe, expect, it, vi } from "vitest";
6+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7+
import { createTrackedTempDirs } from "../../test-utils/tracked-temp-dirs.js";
68

79
const fetchClawHubSkillDetailMock = vi.fn();
810
const fetchClawHubSkillInstallResolutionMock = vi.fn();
11+
const fetchClawHubSkillVerificationMock = vi.fn();
912
const downloadClawHubSkillArchiveMock = vi.fn();
1013
const downloadClawHubSkillArchiveUrlMock = vi.fn();
1114
const downloadClawHubGitHubSkillArchiveMock = vi.fn();
@@ -19,10 +22,12 @@ const withExtractedArchiveRootMock = vi.fn();
1922
const installPackageDirMock = vi.fn();
2023
const evaluateSkillInstallPolicyMock = vi.fn();
2124
const pathExistsMock = vi.fn();
25+
const tempDirs = createTrackedTempDirs();
2226

2327
vi.mock("../../infra/clawhub.js", () => ({
2428
fetchClawHubSkillDetail: fetchClawHubSkillDetailMock,
2529
fetchClawHubSkillInstallResolution: fetchClawHubSkillInstallResolutionMock,
30+
fetchClawHubSkillVerification: fetchClawHubSkillVerificationMock,
2631
downloadClawHubSkillArchive: downloadClawHubSkillArchiveMock,
2732
downloadClawHubSkillArchiveUrl: downloadClawHubSkillArchiveUrlMock,
2833
downloadClawHubGitHubSkillArchive: downloadClawHubGitHubSkillArchiveMock,
@@ -162,9 +167,14 @@ async function writeClawHubOriginFixture(params: {
162167
}
163168

164169
describe("skills-clawhub", () => {
170+
afterEach(async () => {
171+
await tempDirs.cleanup();
172+
});
173+
165174
beforeEach(() => {
166175
fetchClawHubSkillDetailMock.mockReset();
167176
fetchClawHubSkillInstallResolutionMock.mockReset();
177+
fetchClawHubSkillVerificationMock.mockReset();
168178
downloadClawHubSkillArchiveMock.mockReset();
169179
downloadClawHubSkillArchiveUrlMock.mockReset();
170180
downloadClawHubGitHubSkillArchiveMock.mockReset();
@@ -205,19 +215,36 @@ describe("skills-clawhub", () => {
205215
downloadUrl: "https://clawhub.ai/api/v1/download?slug=agentreceipt&version=1.0.0",
206216
},
207217
});
218+
fetchClawHubSkillVerificationMock.mockResolvedValue({
219+
schema: "clawhub.skill.verify.v1",
220+
ok: true,
221+
decision: "pass",
222+
reasons: [],
223+
card: { available: true, sha256: "card-sha" },
224+
artifact: { sourceFingerprint: "source-fp" },
225+
provenance: { source: "unavailable" },
226+
security: { status: "clean", signals: { staticScan: { engineVersion: "v2.4.24" } } },
227+
signature: { status: "unsigned" },
228+
});
208229
downloadClawHubSkillArchiveMock.mockResolvedValue({
209230
archivePath: "/tmp/agentreceipt.zip",
210231
integrity: "sha256-test",
232+
sha256Hex: "a".repeat(64),
233+
artifact: "archive",
211234
cleanup: archiveCleanupMock,
212235
});
213236
downloadClawHubSkillArchiveUrlMock.mockResolvedValue({
214237
archivePath: "/tmp/agentreceipt.zip",
215238
integrity: "sha256-test",
239+
sha256Hex: "a".repeat(64),
240+
artifact: "archive",
216241
cleanup: archiveCleanupMock,
217242
});
218243
downloadClawHubGitHubSkillArchiveMock.mockResolvedValue({
219244
archivePath: "/tmp/github-agentreceipt.zip",
220245
integrity: "sha256-github-test",
246+
sha256Hex: "b".repeat(64),
247+
artifact: "archive",
221248
cleanup: archiveCleanupMock,
222249
});
223250
reportClawHubSkillInstallTelemetryMock.mockResolvedValue(undefined);
@@ -263,13 +290,125 @@ describe("skills-clawhub", () => {
263290
baseUrl: undefined,
264291
root: "/tmp/workspace",
265292
skills: expect.objectContaining({
266-
agentreceipt: {
293+
agentreceipt: expect.objectContaining({
267294
version: "1.0.0",
268295
installedAt: expect.any(Number),
269296
registry: "https://clawhub.ai",
270-
},
297+
}),
271298
}),
272299
});
300+
const telemetrySkills = reportClawHubSkillInstallTelemetryMock.mock.calls[0]?.[0]?.skills as
301+
| Record<string, Record<string, unknown>>
302+
| undefined;
303+
expect(Object.keys(telemetrySkills?.agentreceipt ?? {}).toSorted()).toEqual([
304+
"installedAt",
305+
"registry",
306+
"version",
307+
]);
308+
});
309+
310+
it("persists install artifact and verification provenance in the ClawHub lockfile", async () => {
311+
const workspaceDir = await tempDirs.make("openclaw-skills-lock-");
312+
const skillContent = "---\nname: agentreceipt\ndescription: Receipt helper\n---\n";
313+
const skillSha256 = createHash("sha256").update(skillContent).digest("hex");
314+
installPackageDirMock.mockImplementationOnce(async (params: { targetDir: string }) => {
315+
await fs.mkdir(params.targetDir, { recursive: true });
316+
await fs.writeFile(path.join(params.targetDir, "SKILL.md"), skillContent, "utf8");
317+
return { ok: true, targetDir: params.targetDir };
318+
});
319+
320+
try {
321+
const result = await installSkillFromClawHub({
322+
workspaceDir,
323+
slug: "agentreceipt",
324+
});
325+
326+
expectInstalledSkill(result, {
327+
slug: "agentreceipt",
328+
version: "1.0.0",
329+
targetDir: path.join(workspaceDir, "skills", "agentreceipt"),
330+
});
331+
expect(fetchClawHubSkillVerificationMock).toHaveBeenCalledWith({
332+
slug: "agentreceipt",
333+
version: "1.0.0",
334+
baseUrl: undefined,
335+
});
336+
const lock = JSON.parse(
337+
await fs.readFile(path.join(workspaceDir, ".clawhub", "lock.json"), "utf8"),
338+
) as { skills: Record<string, Record<string, unknown>> };
339+
expect(lock.skills.agentreceipt).toMatchObject({
340+
version: "1.0.0",
341+
registry: "https://clawhub.ai",
342+
artifact: {
343+
kind: "archive",
344+
sha256: "a".repeat(64),
345+
integrity: "sha256-test",
346+
},
347+
skillFile: {
348+
path: "SKILL.md",
349+
sha256: skillSha256,
350+
},
351+
verification: {
352+
schema: "clawhub.skill.verify.v1",
353+
ok: true,
354+
decision: "pass",
355+
reasons: [],
356+
provenance: { source: "unavailable" },
357+
security: { status: "clean", signals: { staticScan: { engineVersion: "v2.4.24" } } },
358+
},
359+
});
360+
const origin = JSON.parse(
361+
await fs.readFile(
362+
path.join(workspaceDir, "skills", "agentreceipt", ".clawhub", "origin.json"),
363+
"utf8",
364+
),
365+
) as Record<string, unknown>;
366+
expect(origin).toMatchObject({
367+
version: 1,
368+
slug: "agentreceipt",
369+
installedVersion: "1.0.0",
370+
artifact: {
371+
kind: "archive",
372+
sha256: "a".repeat(64),
373+
integrity: "sha256-test",
374+
},
375+
skillFile: {
376+
path: "SKILL.md",
377+
sha256: skillSha256,
378+
},
379+
});
380+
} finally {
381+
await fs.rm(workspaceDir, { recursive: true, force: true });
382+
}
383+
});
384+
385+
it("keeps installing when the ClawHub verification snapshot is unavailable", async () => {
386+
const workspaceDir = await tempDirs.make("openclaw-skills-lock-");
387+
fetchClawHubSkillVerificationMock.mockRejectedValueOnce(new Error("verification down"));
388+
installPackageDirMock.mockImplementationOnce(async (params: { targetDir: string }) => {
389+
await fs.mkdir(params.targetDir, { recursive: true });
390+
await fs.writeFile(path.join(params.targetDir, "SKILL.md"), "# AgentReceipt\n", "utf8");
391+
return { ok: true, targetDir: params.targetDir };
392+
});
393+
394+
try {
395+
const result = await installSkillFromClawHub({
396+
workspaceDir,
397+
slug: "agentreceipt",
398+
});
399+
400+
expectInstalledSkill(result, { slug: "agentreceipt", version: "1.0.0" });
401+
const lock = JSON.parse(
402+
await fs.readFile(path.join(workspaceDir, ".clawhub", "lock.json"), "utf8"),
403+
) as { skills: Record<string, Record<string, unknown>> };
404+
expect(lock.skills.agentreceipt?.verification).toBeUndefined();
405+
expect(lock.skills.agentreceipt?.artifact).toMatchObject({
406+
sha256: "a".repeat(64),
407+
integrity: "sha256-test",
408+
});
409+
} finally {
410+
await fs.rm(workspaceDir, { recursive: true, force: true });
411+
}
273412
});
274413

275414
it("installs GitHub-backed ClawHub skills from the pinned resolver source path", async () => {

0 commit comments

Comments
 (0)