From a6dd20ae9dd016c8fd1e6fdc728929fef30a135b Mon Sep 17 00:00:00 2001 From: Alix-007 Date: Tue, 16 Jun 2026 18:33:40 +0800 Subject: [PATCH] fix(skills): preserve ClawHub origin provenance on readback (#93314) Merged via squash. Prepared head SHA: 8bd8df1549cde9965f0ac97fac95c289f4843f38 Co-authored-by: Alix-007 <267018309+Alix-007@users.noreply.github.com> Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com> Reviewed-by: @vincentkoc --- src/skills/lifecycle/clawhub.test.ts | 158 +++++++++++++++++++++++++++ src/skills/lifecycle/clawhub.ts | 63 ++++++++++- 2 files changed, 220 insertions(+), 1 deletion(-) diff --git a/src/skills/lifecycle/clawhub.test.ts b/src/skills/lifecycle/clawhub.test.ts index f72751e58066..27574a637305 100644 --- a/src/skills/lifecycle/clawhub.test.ts +++ b/src/skills/lifecycle/clawhub.test.ts @@ -60,6 +60,7 @@ vi.mock("../../infra/fs-safe.js", () => ({ const { installSkillFromClawHub, + resolveClawHubSkillStatusLinkSync, resolveClawHubSkillVerificationTarget, searchSkillsFromClawHub, updateSkillsFromClawHub, @@ -1362,3 +1363,160 @@ describe("skills-clawhub", () => { expect(listClawHubSkillsMock).not.toHaveBeenCalled(); }); }); + +describe("ClawHub origin provenance readback", () => { + async function writeOriginWithProvenance(params: { + workspaceDir: string; + slug: string; + origin: Record; + lockSkill?: Record; + }) { + const skillDir = path.join(params.workspaceDir, "skills", params.slug); + await fs.mkdir(path.join(skillDir, ".clawhub"), { recursive: true }); + await fs.writeFile(path.join(skillDir, "SKILL.md"), "# Skill\n", "utf8"); + await fs.writeFile( + path.join(skillDir, ".clawhub", "origin.json"), + `${JSON.stringify(params.origin, null, 2)}\n`, + "utf8", + ); + await fs.mkdir(path.join(params.workspaceDir, ".clawhub"), { recursive: true }); + await fs.writeFile( + path.join(params.workspaceDir, ".clawhub", "lock.json"), + `${JSON.stringify( + { + version: 1, + skills: { + [params.slug]: params.lockSkill ?? { + version: params.origin.installedVersion, + installedAt: params.origin.installedAt, + registry: params.origin.registry, + }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + return skillDir; + } + + it("restores matching provenance and rejects one-sided origin edits", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-origin-prov-")); + try { + const artifact = { + kind: "clawpack" as const, + sha256: "a".repeat(64), + integrity: "sha256-test", + }; + const skillFile = { path: "SKILL.md", sha256: "b".repeat(64) }; + const sourceUrl = "https://github.com/acme/skills/tree/abc/agentreceipt"; + const origin = { + version: 1, + registry: "https://clawhub.ai", + slug: "agentreceipt", + installedVersion: "1.0.0", + installedAt: 123, + sourceUrl, + artifact, + skillFile, + }; + const skillDir = await writeOriginWithProvenance({ + workspaceDir, + slug: "agentreceipt", + origin, + lockSkill: { + version: "1.0.0", + installedAt: 123, + registry: "https://clawhub.ai", + sourceUrl, + artifact, + skillFile, + }, + }); + + const link = resolveClawHubSkillStatusLinkSync({ + workspaceDir, + skillDir, + skillKey: "agentreceipt", + }); + + expect(link?.status).toBe("linked"); + expect(link?.valid).toBe(true); + if (link?.status !== "linked") { + throw new Error(`expected linked status, got ${link?.status}`); + } + expect(link.artifact).toEqual(artifact); + expect(link.skillFile).toEqual(skillFile); + expect(link.sourceUrl).toBe(sourceUrl); + + const originPath = path.join(skillDir, ".clawhub", "origin.json"); + for (const override of [ + { sourceUrl: "https://github.com/acme/skills/tree/tampered/agentreceipt" }, + { + artifact: { + kind: "clawpack", + sha256: "c".repeat(64), + integrity: "sha256-tampered", + }, + }, + { skillFile: { path: "SKILL.md", sha256: "d".repeat(64) } }, + ]) { + await fs.writeFile( + originPath, + `${JSON.stringify({ ...origin, ...override }, null, 2)}\n`, + "utf8", + ); + expect( + resolveClawHubSkillStatusLinkSync({ + workspaceDir, + skillDir, + skillKey: "agentreceipt", + }), + ).toMatchObject({ + status: "invalid", + valid: false, + reason: expect.stringContaining("does not match the workspace ClawHub lockfile"), + }); + } + } finally { + await fs.rm(workspaceDir, { recursive: true, force: true }); + } + }); + + it("drops malformed provenance fields while keeping the link valid", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-origin-prov-")); + try { + const skillDir = await writeOriginWithProvenance({ + workspaceDir, + slug: "agentreceipt", + origin: { + version: 1, + registry: "https://clawhub.ai", + slug: "agentreceipt", + installedVersion: "1.0.0", + installedAt: 123, + sourceUrl: " ", + artifact: { kind: "bogus", sha256: 42, integrity: "" }, + skillFile: { path: "", sha256: "c".repeat(64) }, + }, + }); + + const link = resolveClawHubSkillStatusLinkSync({ + workspaceDir, + skillDir, + skillKey: "agentreceipt", + }); + + expect(link?.status).toBe("linked"); + if (link?.status !== "linked") { + throw new Error(`expected linked status, got ${link?.status}`); + } + expect(link.artifact).toBeUndefined(); + expect(link.skillFile).toBeUndefined(); + expect(link.sourceUrl).toBeUndefined(); + } finally { + await fs.rm(workspaceDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/skills/lifecycle/clawhub.ts b/src/skills/lifecycle/clawhub.ts index ed9203b3cbfc..9ce31ffd7252 100644 --- a/src/skills/lifecycle/clawhub.ts +++ b/src/skills/lifecycle/clawhub.ts @@ -103,6 +103,9 @@ export type ClawHubSkillStatusLink = installedAt: number; originPath: string; lockPath: string; + sourceUrl?: string; + artifact?: ClawHubSkillDownloadedArtifactLock; + skillFile?: ClawHubSkillFileLock; } | { status: "invalid"; @@ -420,6 +423,42 @@ function normalizeOptionalSelector(value: string | undefined): string | undefine 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; + 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; +} + +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 { @@ -433,12 +472,18 @@ function normalizeClawHubSkillOrigin( raw.installedVersion.trim().length > 0 && typeof raw.installedAt === "number" ) { + const sourceUrl = normalizeOptionalStringValue((raw as { sourceUrl?: unknown }).sourceUrl); + const artifact = normalizeDownloadedArtifactLock((raw as { artifact?: unknown }).artifact); + const skillFile = normalizeSkillFileLock((raw as { skillFile?: unknown }).skillFile); return { version: 1, registry: normalizeStoredRegistry(raw.registry), slug: raw.slug, installedVersion: raw.installedVersion, installedAt: raw.installedAt, + ...(sourceUrl ? { sourceUrl } : {}), + ...(artifact ? { artifact } : {}), + ...(skillFile ? { skillFile } : {}), }; } return null; @@ -650,10 +695,23 @@ export function resolveClawHubSkillStatusLinkSync(params: { const originRegistry = normalizeStoredRegistry(originRead.origin.registry); const lockedRegistry = locked.registry === undefined ? originRegistry : normalizeStoredRegistry(locked.registry); + const lockedSourceUrl = normalizeOptionalStringValue(locked.sourceUrl); + const lockedArtifact = normalizeDownloadedArtifactLock(locked.artifact); + const lockedSkillFile = normalizeSkillFileLock(locked.skillFile); + const provenanceMatches = + 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; + // 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 + lockedRegistry !== originRegistry || + !provenanceMatches ) { return { status: "invalid", @@ -676,6 +734,9 @@ export function resolveClawHubSkillStatusLinkSync(params: { installedAt: locked.installedAt, originPath: originRead.path, lockPath: lockRead.path, + ...(lockedSourceUrl ? { sourceUrl: lockedSourceUrl } : {}), + ...(lockedArtifact ? { artifact: lockedArtifact } : {}), + ...(lockedSkillFile ? { skillFile: lockedSkillFile } : {}), }; }