mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 11:55:47 -06:00
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:
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user