Expose verified ClawHub source in skill verify output (#93532)

* fix(skills): expose verified ClawHub source in verify output

* fix(ci): repair verify check regressions

* fix(ci): refresh prompt snapshots

* fix(skills): require pinned ClawHub verify commits
This commit is contained in:
Momo
2026-06-17 16:35:36 +08:00
committed by GitHub
parent 745b011632
commit db4bcd7d09
6 changed files with 201 additions and 8 deletions

View File

@@ -107,6 +107,10 @@ Notes:
in the shared managed skills directory when combined with `--global`.
- `verify <slug>` prints ClawHub's `clawhub.skill.verify.v1` JSON envelope by
default. There is no `--json` flag because JSON is already the default.
- When ClawHub returns server-resolved source provenance, verify JSON also
includes a commit-pinned `openclaw.verifiedSourceUrl`. Unavailable or
self-declared source URLs stay only in the raw provenance envelope and are not
promoted.
- `verify` uses `.clawhub/origin.json` for installed ClawHub skills, so it
verifies the installed version against the registry it came from. `--version`
and `--tag` override the version selector but keep that installed registry

View File

@@ -85,6 +85,7 @@ const mocks = vi.hoisted(() => {
installSkillFromSourceMock: vi.fn(),
updateSkillsFromClawHubMock: vi.fn(),
readTrackedClawHubSkillSlugsMock: vi.fn(),
readVerifiedClawHubSkillSourceUrlMock: vi.fn(),
resolveClawHubSkillVerificationTargetMock: vi.fn(),
readClawHubSkillsLockfileStatusSyncMock: vi.fn((..._args: unknown[]) => ({ kind: "missing" })),
resolveClawHubSkillStatusLinkSyncMock: vi.fn(),
@@ -110,6 +111,7 @@ const {
installSkillFromSourceMock,
updateSkillsFromClawHubMock,
readTrackedClawHubSkillSlugsMock,
readVerifiedClawHubSkillSourceUrlMock,
resolveClawHubSkillVerificationTargetMock,
readClawHubSkillsLockfileStatusSyncMock,
resolveClawHubSkillStatusLinkSyncMock,
@@ -191,6 +193,8 @@ vi.mock("../skills/lifecycle/clawhub.js", () => ({
updateSkillsFromClawHub: (...args: unknown[]) => mocks.updateSkillsFromClawHubMock(...args),
readTrackedClawHubSkillSlugs: (...args: unknown[]) =>
mocks.readTrackedClawHubSkillSlugsMock(...args),
readVerifiedClawHubSkillSourceUrl: (...args: unknown[]) =>
mocks.readVerifiedClawHubSkillSourceUrlMock(...args),
resolveClawHubSkillVerificationTarget: (...args: unknown[]) =>
mocks.resolveClawHubSkillVerificationTargetMock(...args),
readClawHubSkillsLockfileStatusSync: (...args: unknown[]) =>
@@ -255,6 +259,7 @@ describe("skills cli commands", () => {
installSkillFromSourceMock.mockReset();
updateSkillsFromClawHubMock.mockReset();
readTrackedClawHubSkillSlugsMock.mockReset();
readVerifiedClawHubSkillSourceUrlMock.mockReset();
resolveClawHubSkillVerificationTargetMock.mockReset();
readClawHubSkillsLockfileStatusSyncMock.mockReset();
resolveClawHubSkillStatusLinkSyncMock.mockReset();
@@ -278,6 +283,7 @@ describe("skills cli commands", () => {
});
updateSkillsFromClawHubMock.mockResolvedValue([]);
readTrackedClawHubSkillSlugsMock.mockResolvedValue([]);
readVerifiedClawHubSkillSourceUrlMock.mockReturnValue(undefined);
readClawHubSkillsLockfileStatusSyncMock.mockReturnValue({ kind: "missing" });
resolveClawHubSkillStatusLinkSyncMock.mockReturnValue(undefined);
resolveLocalSkillCardStatusSyncMock.mockReturnValue(undefined);
@@ -844,6 +850,47 @@ describe("skills cli commands", () => {
});
});
it("includes verified ClawHub source URLs in verify JSON output", async () => {
const provenance = {
source: "server-resolved-github-import",
repo: "openclaw/skills",
commit: "0123456789abcdef0123456789abcdef01234567",
path: "agentreceipt",
};
const verifiedSourceUrl =
"https://github.com/openclaw/skills/tree/0123456789abcdef0123456789abcdef01234567/agentreceipt";
readVerifiedClawHubSkillSourceUrlMock.mockReturnValueOnce(verifiedSourceUrl);
fetchClawHubSkillVerificationMock.mockResolvedValueOnce({
schema: "clawhub.skill.verify.v1",
ok: true,
decision: "pass",
reasons: [],
skill: { slug: "agentreceipt", displayName: "Agent Receipt" },
publisher: { handle: "openclaw" },
version: { version: "1.2.3" },
card: {
available: true,
url: "https://private.example.com/clawhub/api/v1/skills/agentreceipt/card?version=1.2.3",
},
artifact: {
sourceFingerprint: "source-fingerprint",
bundleFingerprints: ["generated-bundle-fingerprint"],
},
provenance,
security: { status: "clean" },
signature: { status: "unsigned" },
});
await runCommand(["skills", "verify", "agentreceipt"]);
expect(readVerifiedClawHubSkillSourceUrlMock).toHaveBeenCalledWith(provenance);
const payload = JSON.parse(runtimeStdout.at(-1) ?? "{}") as {
openclaw?: { verifiedSourceUrl?: string };
};
expect(payload.openclaw?.verifiedSourceUrl).toBe(verifiedSourceUrl);
expect(defaultRuntime.exit).not.toHaveBeenCalled();
});
it("fetches generated Skill Card markdown for --card", async () => {
fetchClawHubSkillVerificationMock.mockResolvedValueOnce({
schema: "clawhub.skill.verify.v1",

View File

@@ -17,6 +17,7 @@ import {
import { defaultRuntime } from "../runtime.js";
import {
installSkillFromClawHub,
readVerifiedClawHubSkillSourceUrl,
readTrackedClawHubSkillSlugs,
resolveClawHubSkillVerificationTarget,
searchSkillsFromClawHub,
@@ -151,6 +152,7 @@ function buildSkillVerificationOutput(
result: ClawHubSkillVerificationResponse,
target: ResolvedClawHubSkillVerificationTarget,
): Record<string, unknown> {
const verifiedSourceUrl = readVerifiedClawHubSkillSourceUrl(result.provenance);
return {
...result,
openclaw: {
@@ -160,6 +162,7 @@ function buildSkillVerificationOutput(
registry: target.resolution.registry,
installedVersion: target.resolution.installedVersion,
},
...(verifiedSourceUrl ? { verifiedSourceUrl } : {}),
},
};
}

View File

@@ -193,4 +193,70 @@ describe("skills verify CLI", () => {
expect(mocks.defaultRuntime.exit).not.toHaveBeenCalled();
expect(mocks.runtimeErrors).toStrictEqual([]);
});
it("surfaces only server-verified source provenance in verify JSON", async () => {
const sourceUrl = "https://github.com/openclaw/skills/tree/main/agentreceipt";
const verifiedSourceUrl =
"https://github.com/openclaw/skills/tree/0123456789abcdef0123456789abcdef01234567/agentreceipt";
mocks.fetchClawHubSkillVerificationMock.mockResolvedValueOnce({
schema: "clawhub.skill.verify.v1",
ok: true,
decision: "pass",
reasons: [],
skill: { slug: "agentreceipt" },
publisher: { handle: "openclaw" },
version: { version: "1.0.0" },
card: { available: true },
artifact: { sourceFingerprint: "source-fp" },
provenance: {
source: "server-resolved-github-import",
kind: "github",
url: sourceUrl,
repo: "openclaw/skills",
ref: "main",
commit: "0123456789abcdef0123456789abcdef01234567",
path: "agentreceipt",
},
security: { status: "clean" },
signature: { status: "unsigned" },
});
await runCommand(["skills", "verify", "agentreceipt"]);
const payload = JSON.parse(mocks.runtimeStdout.at(-1) ?? "{}") as {
openclaw?: { verifiedSourceUrl?: string };
};
expect(payload.openclaw?.verifiedSourceUrl).toBe(verifiedSourceUrl);
expect(mocks.defaultRuntime.exit).not.toHaveBeenCalled();
expect(mocks.runtimeErrors).toStrictEqual([]);
});
it("does not promote unavailable provenance URLs in verify JSON", async () => {
mocks.fetchClawHubSkillVerificationMock.mockResolvedValueOnce({
schema: "clawhub.skill.verify.v1",
ok: true,
decision: "pass",
reasons: [],
skill: { slug: "agentreceipt" },
publisher: { handle: "openclaw" },
version: { version: "1.0.0" },
card: { available: true },
artifact: { sourceFingerprint: "source-fp" },
provenance: {
source: "unavailable",
url: "https://github.com/openclaw/skills/tree/unverified/agentreceipt",
},
security: { status: "clean" },
signature: { status: "unsigned" },
});
await runCommand(["skills", "verify", "agentreceipt"]);
const payload = JSON.parse(mocks.runtimeStdout.at(-1) ?? "{}") as {
openclaw?: { verifiedSourceUrl?: string };
};
expect(payload.openclaw?.verifiedSourceUrl).toBeUndefined();
expect(mocks.defaultRuntime.exit).not.toHaveBeenCalled();
expect(mocks.runtimeErrors).toStrictEqual([]);
});
});

View File

@@ -60,6 +60,7 @@ vi.mock("../../infra/fs-safe.js", () => ({
const {
installSkillFromClawHub,
readVerifiedClawHubSkillSourceUrl,
resolveClawHubSkillStatusLinkSync,
resolveClawHubSkillVerificationTarget,
searchSkillsFromClawHub,
@@ -385,7 +386,9 @@ describe("skills-clawhub", () => {
it("persists the source URL from server-resolved verification provenance", async () => {
const workspaceDir = await tempDirs.make("openclaw-skills-source-");
const sourceUrl = "https://github.com/openclaw/skills/tree/def456/agentreceipt";
const sourceUrl = "https://github.com/openclaw/skills/tree/main/agentreceipt";
const verifiedSourceUrl =
"https://github.com/openclaw/skills/tree/0123456789abcdef0123456789abcdef01234567/agentreceipt";
fetchClawHubSkillDetailMock.mockResolvedValueOnce({
skill: {
slug: "agentreceipt",
@@ -411,7 +414,7 @@ describe("skills-clawhub", () => {
url: sourceUrl,
repo: "openclaw/skills",
ref: "main",
commit: "def456",
commit: "0123456789abcdef0123456789abcdef01234567",
path: "agentreceipt",
importedAt: 4,
},
@@ -440,7 +443,7 @@ describe("skills-clawhub", () => {
await fs.readFile(path.join(workspaceDir, ".clawhub", "lock.json"), "utf8"),
) as { skills: Record<string, Record<string, unknown>> };
expect(lock.skills.agentreceipt).toMatchObject({
sourceUrl,
sourceUrl: verifiedSourceUrl,
verification: {
provenance: {
source: "server-resolved-github-import",
@@ -448,7 +451,7 @@ describe("skills-clawhub", () => {
url: sourceUrl,
repo: "openclaw/skills",
ref: "main",
commit: "def456",
commit: "0123456789abcdef0123456789abcdef01234567",
path: "agentreceipt",
importedAt: 4,
},
@@ -460,12 +463,41 @@ describe("skills-clawhub", () => {
"utf8",
),
) as Record<string, unknown>;
expect(origin.sourceUrl).toBe(sourceUrl);
expect(origin.sourceUrl).toBe(verifiedSourceUrl);
} finally {
await fs.rm(workspaceDir, { recursive: true, force: true });
}
});
it("requires a full commit SHA before promoting verified source provenance", () => {
const baseProvenance = {
source: "server-resolved-github-import",
repo: "openclaw/skills",
path: "agentreceipt",
};
expect(
readVerifiedClawHubSkillSourceUrl({
...baseProvenance,
commit: "0123456789abcdef0123456789abcdef01234567",
}),
).toBe(
"https://github.com/openclaw/skills/tree/0123456789abcdef0123456789abcdef01234567/agentreceipt",
);
expect(
readVerifiedClawHubSkillSourceUrl({
...baseProvenance,
commit: "main",
}),
).toBeUndefined();
expect(
readVerifiedClawHubSkillSourceUrl({
...baseProvenance,
commit: "0123456",
}),
).toBeUndefined();
});
it("does not treat detail metadata as verified source provenance", async () => {
const workspaceDir = await tempDirs.make("openclaw-skills-source-");
fetchClawHubSkillDetailMock.mockResolvedValueOnce({

View File

@@ -282,14 +282,55 @@ function asRecord(raw: unknown): Record<string, unknown> | undefined {
: undefined;
}
function readVerifiedProvenanceSourceUrl(raw: unknown): string | undefined {
function normalizeGitHubRepoName(raw: unknown): string | undefined {
const repo = normalizeOptionalStringValue(raw);
if (!repo) {
return undefined;
}
const parts = repo.split("/");
if (parts.length !== 2 || parts.some((part) => !/^[A-Za-z0-9._-]+$/.test(part))) {
return undefined;
}
return repo;
}
function normalizeGitHubCommitSegment(raw: unknown): string | undefined {
const commit = normalizeOptionalStringValue(raw);
if (!commit || !/^[0-9a-f]{40}$/i.test(commit)) {
return undefined;
}
return commit;
}
function buildGitHubTreeUrl(params: { repo: string; commit: string; sourcePath?: string }): string {
const [owner, name] = params.repo.split("/") as [string, string];
const pathParts = params.sourcePath ? params.sourcePath.split("/") : [];
const segments = [owner, name, "tree", params.commit, ...pathParts];
return `https://github.com/${segments.map(encodeURIComponent).join("/")}`;
}
export function readVerifiedClawHubSkillSourceUrl(raw: unknown): string | undefined {
const provenance = asRecord(raw);
// Only this ClawHub variant is server-resolved; other provenance metadata
// must not become a trusted source link.
if (provenance?.source !== "server-resolved-github-import") {
return undefined;
}
return normalizeOptionalStringValue(provenance.url);
const repo = normalizeGitHubRepoName(provenance.repo);
const commit = normalizeGitHubCommitSegment(provenance.commit);
if (!repo || !commit) {
return undefined;
}
const pathValue = normalizeOptionalStringValue(provenance.path);
let sourcePath: string | undefined;
if (pathValue) {
try {
sourcePath = normalizeGitHubSourcePath(pathValue);
} catch {
return undefined;
}
}
return buildGitHubTreeUrl({ repo, commit, ...(sourcePath ? { sourcePath } : {}) });
}
function readInstallResolutionSourceUrl(
@@ -1196,7 +1237,7 @@ async function performClawHubSkillInstall(
]);
const sourceUrl =
readInstallResolutionSourceUrl(latestResolution) ??
readVerifiedProvenanceSourceUrl(verification?.provenance);
readVerifiedClawHubSkillSourceUrl(verification?.provenance);
await writeClawHubSkillOrigin(install.targetDir, {
version: 1,
registry: resolveClawHubBaseUrl(params.baseUrl),