fix(skills): preserve ClawHub origin provenance on readback (#93314)

Merged via squash.

Prepared head SHA: 8bd8df1549
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
This commit is contained in:
Alix-007
2026-06-16 18:33:40 +08:00
committed by GitHub
parent fa33f5bbb8
commit a6dd20ae9d
2 changed files with 220 additions and 1 deletions
+158
View File
@@ -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<string, unknown>;
lockSkill?: Record<string, unknown>;
}) {
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 });
}
});
});
+62 -1
View File
@@ -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<ClawHubSkillDownloadedArtifactLock>;
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<ClawHubSkillFileLock>;
if (isNonEmptyString(candidate.path) && isNonEmptyString(candidate.sha256)) {
return { path: candidate.path, sha256: candidate.sha256 };
}
return undefined;
}
function normalizeClawHubSkillOrigin(
raw: Partial<ClawHubSkillOrigin> | 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 } : {}),
};
}