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 } : {}),
};
}