diff --git a/docs/cli/skills.md b/docs/cli/skills.md index 7e9e233c36a..3d360a79866 100644 --- a/docs/cli/skills.md +++ b/docs/cli/skills.md @@ -107,6 +107,10 @@ Notes: in the shared managed skills directory when combined with `--global`. - `verify ` 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 diff --git a/src/cli/skills-cli.commands.test.ts b/src/cli/skills-cli.commands.test.ts index 880f37d491e..0d81b77b313 100644 --- a/src/cli/skills-cli.commands.test.ts +++ b/src/cli/skills-cli.commands.test.ts @@ -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", diff --git a/src/cli/skills-cli.ts b/src/cli/skills-cli.ts index 5382122f0d6..a7083ad23e2 100644 --- a/src/cli/skills-cli.ts +++ b/src/cli/skills-cli.ts @@ -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 { + const verifiedSourceUrl = readVerifiedClawHubSkillSourceUrl(result.provenance); return { ...result, openclaw: { @@ -160,6 +162,7 @@ function buildSkillVerificationOutput( registry: target.resolution.registry, installedVersion: target.resolution.installedVersion, }, + ...(verifiedSourceUrl ? { verifiedSourceUrl } : {}), }, }; } diff --git a/src/cli/skills-cli.verify.test.ts b/src/cli/skills-cli.verify.test.ts index 431952fa263..64ba2845d74 100644 --- a/src/cli/skills-cli.verify.test.ts +++ b/src/cli/skills-cli.verify.test.ts @@ -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([]); + }); }); diff --git a/src/skills/lifecycle/clawhub.test.ts b/src/skills/lifecycle/clawhub.test.ts index 27574a63730..0da2fca67da 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, + 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> }; 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; - 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({ diff --git a/src/skills/lifecycle/clawhub.ts b/src/skills/lifecycle/clawhub.ts index 623c07d3da2..a63f5eae63e 100644 --- a/src/skills/lifecycle/clawhub.ts +++ b/src/skills/lifecycle/clawhub.ts @@ -282,14 +282,55 @@ function asRecord(raw: unknown): Record | 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),