From 7aff40d26a898b934dc9553a35683e52f9aa20c7 Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Wed, 22 Jul 2026 22:37:34 -0700 Subject: [PATCH] feat: support npx skills discovery (#3233) --- convex/_generated/api.d.ts | 4 + convex/agentSkillsHttp.test.ts | 358 ++++++++++++++++ convex/agentSkillsHttp.ts | 405 ++++++++++++++++++ convex/downloads.ts | 2 +- convex/githubSkillSync.ts | 15 + convex/http.ts | 8 + convex/lib/agentSkillsDiscovery.test.ts | 48 +++ convex/lib/agentSkillsDiscovery.ts | 79 ++++ e2e/agent-skills-discovery.e2e.test.ts | 131 ++++++ package.json | 2 +- .../agent-skills-discovery-route.test.ts | 44 ++ src/__tests__/skill-detail-page.test.tsx | 7 + src/components/SkillInstallSurface.tsx | 43 +- src/components/skillDetailUtils.test.ts | 4 + src/components/skillDetailUtils.ts | 4 + src/routeTree.gen.ts | 23 + .../agent-skills/index[.]json.ts | 36 ++ 17 files changed, 1203 insertions(+), 10 deletions(-) create mode 100644 convex/agentSkillsHttp.test.ts create mode 100644 convex/agentSkillsHttp.ts create mode 100644 convex/lib/agentSkillsDiscovery.test.ts create mode 100644 convex/lib/agentSkillsDiscovery.ts create mode 100644 e2e/agent-skills-discovery.e2e.test.ts create mode 100644 src/__tests__/agent-skills-discovery-route.test.ts create mode 100644 src/routes/$owner/skills/$slug/[.]well-known/agent-skills/index[.]json.ts diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index a8556687..fc6ad4d2 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -8,6 +8,7 @@ * @module */ +import type * as agentSkillsHttp from "../agentSkillsHttp.js"; import type * as appMeta from "../appMeta.js"; import type * as auth from "../auth.js"; import type * as catalogClassification from "../catalogClassification.js"; @@ -50,6 +51,7 @@ import type * as httpApiV1_whoamiV1 from "../httpApiV1/whoamiV1.js"; import type * as httpPreflight from "../httpPreflight.js"; import type * as leaderboards from "../leaderboards.js"; import type * as lib_access from "../lib/access.js"; +import type * as lib_agentSkillsDiscovery from "../lib/agentSkillsDiscovery.js"; import type * as lib_apiTokenAuth from "../lib/apiTokenAuth.js"; import type * as lib_artifactModeration from "../lib/artifactModeration.js"; import type * as lib_artifactText from "../lib/artifactText.js"; @@ -184,6 +186,7 @@ import type { } from "convex/server"; declare const fullApi: ApiFromModules<{ + agentSkillsHttp: typeof agentSkillsHttp; appMeta: typeof appMeta; auth: typeof auth; catalogClassification: typeof catalogClassification; @@ -226,6 +229,7 @@ declare const fullApi: ApiFromModules<{ httpPreflight: typeof httpPreflight; leaderboards: typeof leaderboards; "lib/access": typeof lib_access; + "lib/agentSkillsDiscovery": typeof lib_agentSkillsDiscovery; "lib/apiTokenAuth": typeof lib_apiTokenAuth; "lib/artifactModeration": typeof lib_artifactModeration; "lib/artifactText": typeof lib_artifactText; diff --git a/convex/agentSkillsHttp.test.ts b/convex/agentSkillsHttp.test.ts new file mode 100644 index 00000000..9f64dac5 --- /dev/null +++ b/convex/agentSkillsHttp.test.ts @@ -0,0 +1,358 @@ +/* @vitest-environment node */ + +import { unzipSync } from "fflate"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ActionCtx } from "./_generated/server"; +import { stripGitHubZipRoot } from "./lib/githubImport"; +import { computeGitHubSkillFolderContentHash } from "./lib/githubSkillSync"; +import { buildDeterministicZip } from "./lib/skillZip"; + +vi.mock("./lib/githubImport", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + fetchGitHubZipBytes: vi.fn(), + }; +}); + +const { agentSkillsHttpHandler } = await import("./agentSkillsHttp"); +const { fetchGitHubZipBytes } = await import("./lib/githubImport"); + +const baseSkill = { + _id: "skills:demo", + slug: "demo", + displayName: "Demo", + latestVersionId: "skillVersions:demo", + latestVersionSummary: { version: "1.0.0" }, +}; + +function makeCtx(partial: Record) { + return partial as unknown as ActionCtx; +} + +function hostedRunQuery({ + publicSkill = true, + moderationInfo, + llmAnalysis, +}: { + publicSkill?: boolean; + moderationInfo?: { + isPendingScan?: boolean; + isMalwareBlocked?: boolean; + isHiddenByMod?: boolean; + isRemoved?: boolean; + sourceVersionId?: string; + }; + llmAnalysis?: { + status?: string; + verdict?: string; + }; +} = {}) { + return vi + .fn() + .mockResolvedValueOnce(baseSkill) + .mockResolvedValueOnce( + publicSkill + ? { + skill: { + _id: "skills:demo", + displayName: "Demo", + summary: "A demo skill.", + }, + latestVersion: { + version: "1.0.0", + files: [ + { path: "SKILL.md", size: 25, sha256: "skill-hash" }, + { path: "references/proof.txt", size: 5, sha256: "proof-hash" }, + ], + }, + moderationInfo, + } + : null, + ) + .mockResolvedValueOnce({ + _id: "skillVersions:demo", + skillId: "skills:demo", + version: "1.0.0", + files: [ + { path: "SKILL.md", storageId: "_storage:skill" }, + { path: "references/proof.txt", storageId: "_storage:proof" }, + ], + llmAnalysis, + }); +} + +describe("Agent Skills discovery HTTP handler", () => { + afterEach(() => { + vi.mocked(fetchGitHubZipBytes).mockReset(); + }); + + it("returns a discovery document for a public hosted skill", async () => { + const response = await agentSkillsHttpHandler( + makeCtx({ + runQuery: hostedRunQuery(), + storage: { + get: vi + .fn() + .mockResolvedValueOnce(new Blob(["---\nname: demo\n---\n"])) + .mockResolvedValueOnce(new Blob(["proof"])), + }, + }), + new Request("https://api.example/api/v1/agent-skills/openclaw/demo/index.json"), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toContain("max-age=60"); + expect(await response.json()).toEqual({ + $schema: "https://schemas.agentskills.io/discovery/0.2.0/schema.json", + skills: [ + { + name: "demo", + type: "archive", + description: "A demo skill.", + url: "https://api.example/api/v1/agent-skills/openclaw/demo/archive?version=1.0.0", + digest: expect.stringMatching(/^sha256:[a-f0-9]{64}$/), + }, + ], + }); + }); + + it("serves a normalized hosted archive only for the pinned version", async () => { + const storageGet = vi + .fn() + .mockResolvedValueOnce(new Blob(["---\nname: demo\n---\n"])) + .mockResolvedValueOnce(new Blob(["proof"])); + const response = await agentSkillsHttpHandler( + makeCtx({ + runQuery: hostedRunQuery(), + storage: { get: storageGet }, + }), + new Request("https://api.example/api/v1/agent-skills/openclaw/demo/archive?version=1.0.0"), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe("application/zip"); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + expect(Object.keys(unzipSync(new Uint8Array(await response.arrayBuffer()))).sort()).toEqual([ + "SKILL.md", + "references/proof.txt", + ]); + + const staleResponse = await agentSkillsHttpHandler( + makeCtx({ + runQuery: hostedRunQuery(), + storage: { get: vi.fn() }, + }), + new Request("https://api.example/api/v1/agent-skills/openclaw/demo/archive?version=0.9.0"), + ); + expect(staleResponse.status).toBe(404); + expect(await staleResponse.text()).toBe("Skill version not available"); + }); + + it("serves a cached hosted archive after a newer version becomes current", async () => { + const runQuery = vi + .fn() + .mockResolvedValueOnce({ + ...baseSkill, + latestVersionId: "skillVersions:current", + latestVersionSummary: { version: "2.0.0" }, + }) + .mockResolvedValueOnce({ + skill: { + _id: "skills:demo", + displayName: "Demo", + summary: "A demo skill.", + }, + latestVersion: { version: "2.0.0" }, + }) + .mockResolvedValueOnce({ + _id: "skillVersions:historical", + skillId: "skills:demo", + version: "1.0.0", + files: [{ path: "SKILL.md", storageId: "_storage:historical" }], + }); + const response = await agentSkillsHttpHandler( + makeCtx({ + runQuery, + storage: { + get: vi.fn().mockResolvedValue(new Blob(["---\nname: demo\n---\n"])), + }, + }), + new Request("https://api.example/api/v1/agent-skills/openclaw/demo/archive?version=1.0.0"), + ); + + expect(response.status).toBe(200); + expect(Object.keys(unzipSync(new Uint8Array(await response.arrayBuffer())))).toEqual([ + "SKILL.md", + ]); + }); + + it("does not count archive HEAD probes as downloads", async () => { + const runAfter = vi.fn(); + const response = await agentSkillsHttpHandler( + makeCtx({ + runQuery: hostedRunQuery(), + auth: { getUserIdentity: vi.fn().mockResolvedValue(null) }, + scheduler: { runAfter }, + storage: { + get: vi + .fn() + .mockResolvedValueOnce(new Blob(["---\nname: demo\n---\n"])) + .mockResolvedValueOnce(new Blob(["proof"])), + }, + }), + new Request("https://api.example/api/v1/agent-skills/openclaw/demo/archive?version=1.0.0", { + method: "HEAD", + headers: { "x-forwarded-for": "203.0.113.10" }, + }), + ); + + expect(response.status).toBe(200); + expect(runAfter).not.toHaveBeenCalled(); + }); + + it("fails instead of caching an incomplete hosted archive", async () => { + const response = await agentSkillsHttpHandler( + makeCtx({ + runQuery: hostedRunQuery(), + storage: { + get: vi + .fn() + .mockResolvedValueOnce(new Blob(["---\nname: demo\n---\n"])) + .mockResolvedValueOnce(null), + }, + }), + new Request("https://api.example/api/v1/agent-skills/openclaw/demo/archive?version=1.0.0"), + ); + + expect(response.status).toBe(410); + expect(response.headers.get("Cache-Control")).toBeNull(); + expect(await response.text()).toBe("Skill archive file missing from storage"); + }); + + it("returns not found for malformed percent escapes", async () => { + const runQuery = vi.fn(); + const response = await agentSkillsHttpHandler( + makeCtx({ runQuery, storage: { get: vi.fn() } }), + new Request("https://api.example/api/v1/agent-skills/openclaw/%ZZ/index.json"), + ); + + expect(response.status).toBe(404); + expect(runQuery).not.toHaveBeenCalled(); + }); + + it("does not expose a skill that is unavailable through the public query", async () => { + const response = await agentSkillsHttpHandler( + makeCtx({ + runQuery: hostedRunQuery({ publicSkill: false }), + storage: { get: vi.fn() }, + }), + new Request("https://api.example/api/v1/agent-skills/openclaw/demo/index.json"), + ); + + expect(response.status).toBe(404); + expect(await response.text()).toBe("Skill not found"); + }); + + it("does not expose a hosted version blocked by moderation or security scanning", async () => { + const pendingResponse = await agentSkillsHttpHandler( + makeCtx({ + runQuery: hostedRunQuery({ + moderationInfo: { + isPendingScan: true, + sourceVersionId: "skillVersions:demo", + }, + }), + storage: { get: vi.fn() }, + }), + new Request("https://api.example/api/v1/agent-skills/openclaw/demo/index.json"), + ); + + expect(pendingResponse.status).toBe(423); + expect(await pendingResponse.text()).toContain("pending a ClawScan security review"); + + const maliciousResponse = await agentSkillsHttpHandler( + makeCtx({ + runQuery: hostedRunQuery({ + llmAnalysis: { verdict: "malicious" }, + }), + storage: { get: vi.fn() }, + }), + new Request("https://api.example/api/v1/agent-skills/openclaw/demo/index.json"), + ); + + expect(maliciousResponse.status).toBe(403); + expect(await maliciousResponse.text()).toContain("flagged as malicious"); + }); + + it("normalizes a pinned GitHub-backed skill subtree", async () => { + const sourceArchive = buildDeterministicZip([ + { + path: "repo-abc123/skills/demo/SKILL.md", + bytes: new TextEncoder().encode("---\nname: demo\n---\n"), + }, + { + path: "repo-abc123/skills/demo/references/proof.txt", + bytes: new TextEncoder().encode("proof"), + }, + { + path: "repo-abc123/README.md", + bytes: new TextEncoder().encode("outside the skill"), + }, + ]); + const archiveEntries = stripGitHubZipRoot(unzipSync(sourceArchive)); + const contentHash = await computeGitHubSkillFolderContentHash(archiveEntries, "skills/demo"); + vi.mocked(fetchGitHubZipBytes).mockResolvedValue(sourceArchive); + const githubSkill = { + ...baseSkill, + installKind: "github", + githubSourceId: "githubSkillSources:demo", + githubPath: "skills/demo", + githubCurrentCommit: "def456", + githubCurrentContentHash: "current-content", + githubCurrentStatus: "present", + githubScanStatus: "clean", + }; + const runQuery = vi + .fn() + .mockResolvedValueOnce(githubSkill) + .mockResolvedValueOnce({ + skill: { + _id: "skills:demo", + displayName: "Demo", + summary: "A GitHub-backed demo skill.", + }, + latestVersion: null, + }) + .mockResolvedValueOnce({ + githubSourceId: "githubSkillSources:demo", + contentHash, + commit: "def456", + path: "skills/demo", + status: "clean", + }) + .mockResolvedValueOnce({ repo: "openclaw/openclaw", defaultBranch: "main" }); + + const response = await agentSkillsHttpHandler( + makeCtx({ runQuery, storage: { get: vi.fn() } }), + new Request( + `https://api.example/api/v1/agent-skills/openclaw/demo/archive?commit=abc123&contentHash=${contentHash}`, + ), + ); + + expect(response.status).toBe(200); + expect(Object.keys(unzipSync(new Uint8Array(await response.arrayBuffer()))).sort()).toEqual([ + "SKILL.md", + "references/proof.txt", + ]); + expect(fetchGitHubZipBytes).toHaveBeenCalledWith( + expect.objectContaining({ + owner: "openclaw", + repo: "openclaw", + commit: "abc123", + path: "skills/demo", + }), + fetch, + ); + }); +}); diff --git a/convex/agentSkillsHttp.ts b/convex/agentSkillsHttp.ts new file mode 100644 index 00000000..8d469dd8 --- /dev/null +++ b/convex/agentSkillsHttp.ts @@ -0,0 +1,405 @@ +import { unzipSync } from "fflate"; +import { api, internal } from "./_generated/api"; +import type { Id } from "./_generated/dataModel"; +import type { ActionCtx } from "./_generated/server"; +import { scheduleSkillDownloadMetric } from "./downloads"; +import { httpAction } from "./functions"; +import { + buildAgentSkillsDiscoveryDocument, + buildNormalizedAgentSkillArchive, +} from "./lib/agentSkillsDiscovery"; +import { fetchGitHubZipBytes, stripGitHubZipRoot } from "./lib/githubImport"; +import { computeGitHubSkillFolderContentHash } from "./lib/githubSkillSync"; +import { + buildSkillInstallResolution, + type InstallResolverSkill, + type InstallResolverSource, + type SkillInstallResolution, +} from "./lib/installResolver"; +import { + getPublicSkillFileAccessBlock, + getPublicSkillVersionDownloadBlock, + type SkillFileModerationInfo, +} from "./lib/skillFileAccess"; + +const ROUTE_PREFIX = "/api/v1/agent-skills/"; + +type AgentSkillsCtx = ActionCtx; + +type HostedVersion = { + _id: Id<"skillVersions">; + skillId: Id<"skills">; + version: string; + files: Array<{ path: string; storageId: Id<"_storage"> }>; + publicationStatus?: "pending" | "published" | "blocked"; + softDeletedAt?: number; + ownerDeletedAt?: number; + llmAnalysis?: { + status?: string | null; + verdict?: string | null; + } | null; +}; + +type ResolvedSkill = { + skillId: Id<"skills">; + displayName: string; + description?: string | null; + resolution: Extract; + hostedVersion: HostedVersion | null; +}; + +export async function agentSkillsHttpHandler(ctx: AgentSkillsCtx, request: Request) { + const route = parseRoute(request); + if (!route) return text("Not found", 404); + + const archivePin = route.action === "archive" ? parseArchivePin(request) : null; + if (route.action === "archive" && !archivePin) { + return text("Archive pin is missing or invalid; fetch the discovery document again.", 409); + } + + const resolved = await resolveSkill(ctx, request, route.ownerHandle, route.slug, archivePin); + if (!resolved.ok) return text(resolved.message, resolved.status); + + if (route.action === "index.json") { + const archiveResult = await buildArchive(ctx, resolved.skill); + if (!archiveResult.ok) return text(archiveResult.message, archiveResult.status); + const digest = await sha256Hex(archiveResult.archive); + const pin = + resolved.skill.resolution.installKind === "archive" + ? { version: resolved.skill.resolution.archive.version } + : { + commit: resolved.skill.resolution.github.commit, + contentHash: resolved.skill.resolution.github.contentHash, + }; + return Response.json( + buildAgentSkillsDiscoveryDocument({ + origin: new URL(request.url).origin, + ownerHandle: route.ownerHandle, + slug: route.slug, + displayName: resolved.skill.displayName, + description: resolved.skill.description, + digest, + ...pin, + }), + { + headers: { + "Cache-Control": "public, max-age=60, stale-while-revalidate=300", + "Content-Type": "application/json; charset=utf-8", + }, + }, + ); + } + + const archiveResult = await buildArchive(ctx, resolved.skill); + if (!archiveResult.ok) return text(archiveResult.message, archiveResult.status); + if (request.method !== "HEAD") { + await scheduleSkillDownloadMetric(ctx, request, resolved.skill.skillId); + } + return new Response(new Blob([archiveResult.archive], { type: "application/zip" }), { + headers: { + "Cache-Control": "no-store", + "Content-Disposition": `attachment; filename="${route.slug}.zip"`, + "Content-Type": "application/zip", + }, + }); +} + +export const agentSkillsHttp = httpAction(agentSkillsHttpHandler); + +function parseRoute(request: Request) { + const pathname = new URL(request.url).pathname; + if (!pathname.startsWith(ROUTE_PREFIX)) return null; + let segments: string[]; + try { + segments = pathname + .slice(ROUTE_PREFIX.length) + .split("/") + .filter(Boolean) + .map((segment) => decodeURIComponent(segment)); + } catch { + return null; + } + if (segments.length !== 3) return null; + const [ownerHandle, slug, action] = segments; + if (!ownerHandle || !slug || (action !== "index.json" && action !== "archive")) return null; + return { ownerHandle, slug: slug.toLowerCase(), action }; +} + +async function resolveSkill( + ctx: AgentSkillsCtx, + request: Request, + ownerHandle: string, + slug: string, + archivePin: ArchivePin | null, +): Promise<{ ok: true; skill: ResolvedSkill } | { ok: false; status: number; message: string }> { + const skill = (await ctx.runQuery(internal.skills.getSkillBySlugInternal, { + slug, + ownerHandle, + })) as + | (InstallResolverSkill & { + _id: Id<"skills">; + githubSourceId?: Id<"githubSkillSources">; + latestVersionId?: Id<"skillVersions">; + }) + | null; + if (!skill) return { ok: false, status: 404, message: "Skill not found" }; + + const publicResult = (await ctx.runQuery(api.skills.getBySlug, { + slug, + ownerHandle, + })) as { + skill: { _id: Id<"skills">; displayName: string; summary?: string | null } | null; + moderationInfo?: SkillFileModerationInfo | null; + latestVersion: { + version: string; + } | null; + } | null; + if (!publicResult?.skill || publicResult.skill._id !== skill._id) { + return { ok: false, status: 404, message: "Skill not found" }; + } + + let hostedVersion: ResolvedSkill["hostedVersion"] = null; + let resolution: Extract; + if (archivePin?.kind === "hosted") { + const version = (await ctx.runQuery(internal.skills.getVersionBySkillAndVersionInternal, { + skillId: skill._id, + version: archivePin.version, + })) as HostedVersion | null; + if ( + !version || + version.skillId !== skill._id || + version.version !== archivePin.version || + version.softDeletedAt || + version.ownerDeletedAt || + (version.publicationStatus !== undefined && version.publicationStatus !== "published") + ) { + return { ok: false, status: 404, message: "Skill version not available" }; + } + const moderationBlock = getPublicSkillVersionDownloadBlock( + publicResult.moderationInfo, + version, + skill.latestVersionId, + ); + if (moderationBlock) { + return { + ok: false, + status: moderationBlock.status, + message: moderationBlock.message, + }; + } + hostedVersion = version; + resolution = { + ok: true, + slug: skill.slug, + installKind: "archive", + archive: { + version: version.version, + downloadUrl: "", + }, + }; + } else if (archivePin?.kind === "github") { + const scan = (await ctx.runQuery( + internal.githubSkillSync.getArchiveScanBySkillAndContentHashInternal, + { + skillId: skill._id, + contentHash: archivePin.contentHash, + }, + )) as { + githubSourceId: Id<"githubSkillSources">; + contentHash: string; + commit: string; + path: string; + status: "clean" | "suspicious" | "malicious" | "pending" | "failed"; + } | null; + if ( + !scan || + scan.contentHash !== archivePin.contentHash || + (scan.status !== "clean" && scan.status !== "suspicious") + ) { + return { ok: false, status: 404, message: "GitHub skill archive not available" }; + } + const source = (await ctx.runQuery(internal.githubSkillSources.getByIdInternal, { + sourceId: scan.githubSourceId, + })) as InstallResolverSource | null; + if (!source) { + return { ok: false, status: 404, message: "GitHub skill archive not available" }; + } + const moderationBlock = getPublicSkillFileAccessBlock(publicResult.moderationInfo); + if (moderationBlock) { + return { + ok: false, + status: moderationBlock.status, + message: moderationBlock.message, + }; + } + resolution = { + ok: true, + slug: skill.slug, + installKind: "github", + github: { + repo: source.repo, + path: scan.path, + commit: archivePin.commit, + contentHash: scan.contentHash, + sourceUrl: `https://github.com/${source.repo}/tree/${archivePin.commit}/${scan.path}`, + }, + }; + } else { + const source = + skill.installKind === "github" && skill.githubSourceId + ? ((await ctx.runQuery(internal.githubSkillSources.getByIdInternal, { + sourceId: skill.githubSourceId, + })) as InstallResolverSource | null) + : null; + const currentResolution = buildSkillInstallResolution({ + origin: new URL(request.url).origin, + skill, + source, + ownerHandle, + }); + if (!currentResolution.ok) { + return { + ok: false, + status: currentResolution.status, + message: currentResolution.message, + }; + } + resolution = currentResolution; + if (resolution.installKind === "archive") { + if ( + !skill.latestVersionId || + publicResult.latestVersion?.version !== resolution.archive.version + ) { + return { ok: false, status: 404, message: "Skill version not available" }; + } + const version = (await ctx.runQuery(internal.skills.getVersionByIdInternal, { + versionId: skill.latestVersionId, + })) as HostedVersion | null; + if ( + !version || + version.skillId !== skill._id || + version.version !== resolution.archive.version || + version.softDeletedAt || + version.ownerDeletedAt || + (version.publicationStatus !== undefined && version.publicationStatus !== "published") + ) { + return { ok: false, status: 404, message: "Skill version not available" }; + } + const moderationBlock = getPublicSkillVersionDownloadBlock( + publicResult.moderationInfo, + version, + skill.latestVersionId, + ); + if (moderationBlock) { + return { + ok: false, + status: moderationBlock.status, + message: moderationBlock.message, + }; + } + hostedVersion = version; + } else { + const moderationBlock = getPublicSkillFileAccessBlock(publicResult.moderationInfo); + if (moderationBlock) { + return { + ok: false, + status: moderationBlock.status, + message: moderationBlock.message, + }; + } + } + } + + return { + ok: true, + skill: { + skillId: skill._id, + displayName: publicResult.skill.displayName, + description: publicResult.skill.summary, + resolution, + hostedVersion, + }, + }; +} + +async function buildArchive(ctx: AgentSkillsCtx, resolved: ResolvedSkill) { + if (resolved.resolution.installKind === "archive") { + const entries: Record = {}; + for (const file of resolved.hostedVersion?.files ?? []) { + const blob = await ctx.storage.get(file.storageId); + if (!blob) { + return { + ok: false as const, + status: 410, + message: "Skill archive file missing from storage", + }; + } + entries[file.path] = new Uint8Array(await blob.arrayBuffer()); + } + return { + ok: true as const, + archive: buildNormalizedAgentSkillArchive(entries), + }; + } + + const github = resolved.resolution.github; + const [owner, repo] = github.repo.split("/"); + if (!owner || !repo) throw new Error("GitHub-backed skill source metadata is incomplete"); + const zip = await fetchGitHubZipBytes( + { + owner, + repo, + ref: github.commit, + commit: github.commit, + path: github.path, + repoUrl: `https://github.com/${github.repo}`, + originalUrl: github.sourceUrl, + }, + fetch, + ); + const entries = stripGitHubZipRoot(unzipSync(zip)); + const contentHash = await computeGitHubSkillFolderContentHash(entries, github.path); + if (contentHash !== github.contentHash) { + return { + ok: false as const, + status: 409, + message: "GitHub skill archive no longer matches its pinned content hash", + }; + } + return { + ok: true as const, + archive: buildNormalizedAgentSkillArchive(entries, github.path), + }; +} + +type ArchivePin = + | { kind: "hosted"; version: string } + | { kind: "github"; commit: string; contentHash: string }; + +function parseArchivePin(request: Request): ArchivePin | null { + const url = new URL(request.url); + const version = url.searchParams.get("version")?.trim(); + const commit = url.searchParams.get("commit")?.trim(); + const contentHash = url.searchParams.get("contentHash")?.trim(); + if (version && !commit && !contentHash) { + return { kind: "hosted", version }; + } + if (!version && commit && contentHash) { + return { kind: "github", commit, contentHash }; + } + return null; +} + +async function sha256Hex(bytes: Uint8Array) { + const digest = await crypto.subtle.digest("SHA-256", new Uint8Array(bytes)); + return Array.from(new Uint8Array(digest)) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + +function text(message: string, status: number) { + return new Response(message, { + status, + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); +} diff --git a/convex/downloads.ts b/convex/downloads.ts index f8c81f85..7288e7a0 100644 --- a/convex/downloads.ts +++ b/convex/downloads.ts @@ -198,7 +198,7 @@ async function githubDownloadHandoffResponse( }); } -async function scheduleSkillDownloadMetric( +export async function scheduleSkillDownloadMetric( ctx: DownloadCtx, request: Request, skillId: Id<"skills">, diff --git a/convex/githubSkillSync.ts b/convex/githubSkillSync.ts index 669ea491..50d2199d 100644 --- a/convex/githubSkillSync.ts +++ b/convex/githubSkillSync.ts @@ -217,6 +217,21 @@ const githubSkillScanStatusValidator = v.union( v.literal("failed"), ); +export const getArchiveScanBySkillAndContentHashInternal = internalQuery({ + args: { + skillId: v.id("skills"), + contentHash: v.string(), + }, + handler: async (ctx, args) => { + return await ctx.db + .query("githubSkillScans") + .withIndex("by_skill_and_content_hash", (q) => + q.eq("skillId", args.skillId).eq("contentHash", args.contentHash), + ) + .unique(); + }, +}); + export const getSourceByRepoInternal = internalQuery({ args: { repo: v.string() }, handler: async (ctx, args): Promise => { diff --git a/convex/http.ts b/convex/http.ts index e73fffc7..25efdfdb 100644 --- a/convex/http.ts +++ b/convex/http.ts @@ -1,5 +1,6 @@ import { ApiRoutes, LegacyApiRoutes } from "clawhub-schema"; import { httpRouter } from "convex/server"; +import { agentSkillsHttp } from "./agentSkillsHttp"; import { auth } from "./auth"; import { downloadZip } from "./downloads"; import { @@ -74,6 +75,13 @@ const http = installRateLimitedRoutes(httpRouter()); auth.addHttpRoutes(http); +// Convex routes HEAD through the matching GET action and strips the body. +http.route({ + pathPrefix: "/api/v1/agent-skills/", + method: "GET", + handler: agentSkillsHttp, +}); + http.route({ path: ApiRoutes.download, method: "GET", diff --git a/convex/lib/agentSkillsDiscovery.test.ts b/convex/lib/agentSkillsDiscovery.test.ts new file mode 100644 index 00000000..453df201 --- /dev/null +++ b/convex/lib/agentSkillsDiscovery.test.ts @@ -0,0 +1,48 @@ +import { strFromU8, unzipSync } from "fflate"; +import { describe, expect, it } from "vitest"; +import { + buildAgentSkillsDiscoveryDocument, + buildNormalizedAgentSkillArchive, +} from "./agentSkillsDiscovery"; + +describe("Agent Skills discovery", () => { + it("builds a pinned v0.2 discovery document", () => { + expect( + buildAgentSkillsDiscoveryDocument({ + origin: "https://clawhub.ai", + ownerHandle: "openclaw", + slug: "demo", + displayName: "Demo", + description: "Install the demo skill.", + digest: "a".repeat(64), + version: "1.2.3", + }), + ).toEqual({ + $schema: "https://schemas.agentskills.io/discovery/0.2.0/schema.json", + skills: [ + { + name: "demo", + type: "archive", + description: "Install the demo skill.", + url: "https://clawhub.ai/api/v1/agent-skills/openclaw/demo/archive?version=1.2.3", + digest: `sha256:${"a".repeat(64)}`, + }, + ], + }); + }); + + it("normalizes a GitHub skill subtree into an installable root archive", () => { + const archive = buildNormalizedAgentSkillArchive( + { + "skills/demo/skills.md": new TextEncoder().encode("# Demo"), + "skills/demo/references/setup.md": new TextEncoder().encode("Setup"), + "skills/other/SKILL.md": new TextEncoder().encode("# Other"), + }, + "skills/demo", + ); + const files = unzipSync(archive); + + expect(Object.keys(files).sort()).toEqual(["SKILL.md", "references/setup.md"]); + expect(strFromU8(files["SKILL.md"]!)).toBe("# Demo"); + }); +}); diff --git a/convex/lib/agentSkillsDiscovery.ts b/convex/lib/agentSkillsDiscovery.ts new file mode 100644 index 00000000..d01035bc --- /dev/null +++ b/convex/lib/agentSkillsDiscovery.ts @@ -0,0 +1,79 @@ +import { buildDeterministicZip, validateFilePath } from "./skillZip"; + +const AGENT_SKILLS_DISCOVERY_SCHEMA = "https://schemas.agentskills.io/discovery/0.2.0/schema.json"; + +type DiscoveryPin = + | { version: string; commit?: never; contentHash?: never } + | { version?: never; commit: string; contentHash: string }; + +export function buildAgentSkillsDiscoveryDocument( + args: { + origin: string; + ownerHandle: string; + slug: string; + displayName: string; + description?: string | null; + digest: string; + } & DiscoveryPin, +) { + const archiveUrl = new URL( + `/api/v1/agent-skills/${encodeURIComponent(args.ownerHandle)}/${encodeURIComponent(args.slug)}/archive`, + args.origin, + ); + if (args.version !== undefined) { + archiveUrl.searchParams.set("version", args.version); + } else { + archiveUrl.searchParams.set("commit", args.commit!); + archiveUrl.searchParams.set("contentHash", args.contentHash!); + } + + return { + $schema: AGENT_SKILLS_DISCOVERY_SCHEMA, + skills: [ + { + name: args.slug, + type: "archive" as const, + description: normalizeDescription(args.description, args.displayName), + url: archiveUrl.toString(), + digest: `sha256:${args.digest}`, + }, + ], + }; +} + +export function buildNormalizedAgentSkillArchive( + entries: Record, + rootPath = "", +) { + const normalizedRoot = rootPath.replace(/^\/+|\/+$/g, ""); + const rootPrefix = normalizedRoot ? `${normalizedRoot}/` : ""; + const selected = Object.entries(entries) + .flatMap(([rawPath, bytes]) => { + const path = rawPath.replace(/^\/+/, ""); + if (rootPrefix && !path.startsWith(rootPrefix)) return []; + const relativePath = rootPrefix ? path.slice(rootPrefix.length) : path; + if (!relativePath || !validateFilePath(relativePath)) return []; + return [{ path: relativePath, bytes: new Uint8Array(bytes) }]; + }) + .sort((a, b) => a.path.localeCompare(b.path)); + + const skillFile = + selected.find((entry) => entry.path === "SKILL.md") ?? + selected.find((entry) => entry.path.toLowerCase() === "skill.md") ?? + selected.find((entry) => entry.path.toLowerCase() === "skills.md"); + if (!skillFile) { + throw new Error("Skill archive is missing SKILL.md"); + } + + const normalized = selected + .filter((entry) => entry !== skillFile && entry.path !== "SKILL.md") + .map((entry) => ({ path: entry.path, bytes: entry.bytes })); + normalized.push({ path: "SKILL.md", bytes: skillFile.bytes }); + + return buildDeterministicZip(normalized); +} + +function normalizeDescription(description: string | null | undefined, displayName: string) { + const normalized = description?.trim() || `${displayName} from ClawHub.`; + return normalized.slice(0, 1024); +} diff --git a/e2e/agent-skills-discovery.e2e.test.ts b/e2e/agent-skills-discovery.e2e.test.ts new file mode 100644 index 00000000..c9259a36 --- /dev/null +++ b/e2e/agent-skills-discovery.e2e.test.ts @@ -0,0 +1,131 @@ +/* @vitest-environment node */ + +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { afterEach, describe, expect, it } from "vitest"; +import { buildAgentSkillsDiscoveryDocument } from "../convex/lib/agentSkillsDiscovery"; +import { buildDeterministicZip } from "../convex/lib/skillZip"; + +const execFileAsync = promisify(execFile); +const tempDirs: string[] = []; +const servers: Array> = []; + +afterEach(async () => { + await Promise.all( + servers + .splice(0) + .map( + (server) => + new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ), + ), + ); + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe("Agent Skills CLI compatibility", () => { + it("installs a ClawHub skill page URL with the real npx skills CLI", async () => { + const skillMarkdown = `--- +name: demo +description: Demonstrates ClawHub Agent Skills discovery. +--- + +# Demo + +Installed from a ClawHub skill page URL. +`; + const archive = buildDeterministicZip([ + { path: "SKILL.md", bytes: new TextEncoder().encode(skillMarkdown) }, + { + path: "references/proof.txt", + bytes: new TextEncoder().encode("supporting file installed"), + }, + ]); + const digest = createHash("sha256").update(archive).digest("hex"); + + const server = createServer((request, response) => { + const origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + const url = new URL(request.url ?? "/", origin); + + if (url.pathname === "/openclaw/skills/demo/.well-known/agent-skills/index.json") { + response.writeHead(200, { "Content-Type": "application/json" }); + response.end( + JSON.stringify( + buildAgentSkillsDiscoveryDocument({ + origin, + ownerHandle: "openclaw", + slug: "demo", + displayName: "Demo", + description: "Demonstrates ClawHub Agent Skills discovery.", + digest, + version: "1.0.0", + }), + ), + ); + return; + } + + if ( + url.pathname === "/api/v1/agent-skills/openclaw/demo/archive" && + url.searchParams.get("version") === "1.0.0" + ) { + response.writeHead(200, { "Content-Type": "application/zip" }); + response.end(Buffer.from(archive)); + return; + } + + response.writeHead(404, { "Content-Type": "text/plain" }); + response.end("not found"); + }); + servers.push(server); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + const projectDir = await mkdtemp(join(tmpdir(), "clawhub-agent-skills-e2e-")); + tempDirs.push(projectDir); + await writeFile(join(projectDir, "package.json"), '{"private":true}\n', "utf8"); + + const origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + const result = await execFileAsync( + "npx", + [ + "--yes", + "skills@1.5.20", + "add", + `${origin}/openclaw/skills/demo`, + "--agent", + "codex", + "--skill", + "demo", + "--yes", + "--copy", + ], + { + cwd: projectDir, + encoding: "utf8", + timeout: 90_000, + maxBuffer: 1024 * 1024, + env: { + ...process.env, + CI: "1", + DO_NOT_TRACK: "1", + NO_COLOR: "1", + }, + }, + ); + + expect(result.stderr).not.toContain("Error"); + expect(await readFile(join(projectDir, ".agents/skills/demo/SKILL.md"), "utf8")).toContain( + "Installed from a ClawHub skill page URL.", + ); + expect( + await readFile(join(projectDir, ".agents/skills/demo/references/proof.txt"), "utf8"), + ).toBe("supporting file installed"); + }, 120_000); +}); diff --git a/package.json b/package.json index e87536db..e5441015 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "check": "bun run lint", "check:peers": "bun scripts/check-peer-deps.ts", "check:release-workflow-action-pins": "node scripts/check-release-workflow-action-pins.mjs", - "ci:e2e-http": "bun run test:e2e:prod-http && bunx vitest run -c vitest.e2e.config.ts e2e/clawhub.e2e.test.ts --testNamePattern \"prints CLI version|search endpoint returns a results array|cli search does not error|cli scan rejects local folders|cli scan download fetches a stored submitted-version scan report|package publish --dry-run from a GitHub repo|package publish --dry-run --json|package publish exits non-zero when Plugin Inspector hard errors block publish|package publish exits zero and prints Plugin Inspector warnings|package publish help shows|skill verify help omits the redundant json flag|skill verify accepts the legacy json flag\" && bunx vitest run -c vitest.e2e.config.ts e2e/permissions.e2e.test.ts", + "ci:e2e-http": "bun run test:e2e:prod-http && bunx vitest run -c vitest.e2e.config.ts e2e/agent-skills-discovery.e2e.test.ts && bunx vitest run -c vitest.e2e.config.ts e2e/clawhub.e2e.test.ts --testNamePattern \"prints CLI version|search endpoint returns a results array|cli search does not error|cli scan rejects local folders|cli scan download fetches a stored submitted-version scan report|package publish --dry-run from a GitHub repo|package publish --dry-run --json|package publish exits non-zero when Plugin Inspector hard errors block publish|package publish exits zero and prints Plugin Inspector warnings|package publish help shows|skill verify help omits the redundant json flag|skill verify accepts the legacy json flag\" && bunx vitest run -c vitest.e2e.config.ts e2e/permissions.e2e.test.ts", "ci:packages": "bun run --cwd packages/schema build && bun run --cwd packages/clawhub verify && bun run --cwd packages/clawhub-admin verify", "ci:playwright": "VITE_CONVEX_URL=https://wry-manatee-359.convex.cloud VITE_CONVEX_SITE_URL=https://wry-manatee-359.convex.site bun run build && VITE_CONVEX_URL=https://wry-manatee-359.convex.cloud VITE_CONVEX_SITE_URL=https://wry-manatee-359.convex.site bun run test:pw", "ci:playwright-smoke": "VITE_CONVEX_URL=https://wry-manatee-359.convex.cloud VITE_CONVEX_SITE_URL=https://wry-manatee-359.convex.site bun run build && VITE_CONVEX_URL=https://wry-manatee-359.convex.cloud VITE_CONVEX_SITE_URL=https://wry-manatee-359.convex.site bun run test:pw -- --project=chromium e2e/ci-smoke.pw.test.ts e2e/public-routes-smoke.pw.test.ts", diff --git a/src/__tests__/agent-skills-discovery-route.test.ts b/src/__tests__/agent-skills-discovery-route.test.ts new file mode 100644 index 00000000..55c2bc9a --- /dev/null +++ b/src/__tests__/agent-skills-discovery-route.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { proxyAgentSkillsDiscoveryResponse } from "../routes/$owner/skills/$slug/[.]well-known/agent-skills/index[.]json"; + +describe("Agent Skills discovery route", () => { + it("does not forward stale compression or transport headers", async () => { + const upstream = new Response('{"skills":[]}', { + status: 200, + headers: { + "Cache-Control": "public, max-age=60", + Connection: "keep-alive", + "Content-Encoding": "gzip", + "Content-Length": "123", + "Content-Type": "application/json; charset=utf-8", + }, + }); + + const response = await proxyAgentSkillsDiscoveryResponse(upstream); + + expect(response.status).toBe(200); + expect(await response.text()).toBe('{"skills":[]}'); + expect(response.headers.get("Cache-Control")).toBe("public, max-age=60"); + expect(response.headers.get("Content-Type")).toBe("application/json; charset=utf-8"); + expect(response.headers.get("Connection")).toBeNull(); + expect(response.headers.get("Content-Encoding")).toBeNull(); + expect(response.headers.get("Content-Length")).toBeNull(); + }); + + it("returns the discovery headers without a body for HEAD requests", async () => { + const upstream = new Response('{"skills":[]}', { + status: 200, + headers: { + "Cache-Control": "public, max-age=60", + "Content-Type": "application/json; charset=utf-8", + }, + }); + + const response = await proxyAgentSkillsDiscoveryResponse(upstream, false); + + expect(response.status).toBe(200); + expect(await response.text()).toBe(""); + expect(response.headers.get("Cache-Control")).toBe("public, max-age=60"); + expect(response.headers.get("Content-Type")).toBe("application/json; charset=utf-8"); + }); +}); diff --git a/src/__tests__/skill-detail-page.test.tsx b/src/__tests__/skill-detail-page.test.tsx index c2400bb7..e4c6f8ca 100644 --- a/src/__tests__/skill-detail-page.test.tsx +++ b/src/__tests__/skill-detail-page.test.tsx @@ -1218,7 +1218,14 @@ describe("SkillDetailPage", () => { expect(screen.queryByText("npx clawhub@latest install @steipete/weather")).toBeNull(); expect(screen.queryByRole("tab", { name: "ClawHub" })).toBeNull(); expect(screen.getByRole("button", { name: "CLI" }).getAttribute("aria-pressed")).toBe("true"); + const skillsCliButton = screen.getByRole("button", { name: "npx skills" }); + expect(skillsCliButton).toBeTruthy(); expect(screen.getByRole("button", { name: "Prompt" })).toBeTruthy(); + fireEvent.click(skillsCliButton); + expect( + screen.getByText("npx skills add https://clawhub.ai/steipete/skills/weather"), + ).toBeTruthy(); + expect(skillsCliButton.getAttribute("aria-pressed")).toBe("true"); expect(screen.queryByText(/After install, inspect the skill metadata/i)).toBeNull(); expect(screen.getAllByText("Security audit").length).toBeGreaterThan(0); expect(screen.getByRole("link", { name: "View Security Audit" }).getAttribute("href")).toBe( diff --git a/src/components/SkillInstallSurface.tsx b/src/components/SkillInstallSurface.tsx index 747e8fcf..98b5ed7c 100644 --- a/src/components/SkillInstallSurface.tsx +++ b/src/components/SkillInstallSurface.tsx @@ -5,8 +5,10 @@ import type { Id } from "../../convex/_generated/dataModel"; import { copyText, InstallCopyButton } from "./InstallCopyButton"; import { buildSkillInstallTarget, + buildSkillPageUrl, formatOpenClawInstallCommand, formatOpenClawPrompt, + formatSkillsCliInstallCommand, type SkillPromptMode, } from "./skillDetailUtils"; import { Button } from "./ui/button"; @@ -193,10 +195,13 @@ export function SkillCommandLineCard({ clawdis, }: SkillInstallSurfaceProps) { const headingId = useId(); - const [activeInstallTab, setActiveInstallTab] = useState<"cli" | "prompt">("cli"); + type InstallTab = "cli" | "skills" | "prompt"; + const [activeInstallTab, setActiveInstallTab] = useState("cli"); const [installTabDirection, setInstallTabDirection] = useState<"left" | "right">("right"); const installTarget = buildSkillInstallTarget(ownerHandle, ownerId, slug); const openClawCommand = formatOpenClawInstallCommand(installTarget); + const skillPageUrl = buildSkillPageUrl(ownerHandle, ownerId, slug); + const skillsCliCommand = skillPageUrl ? formatSkillsCliInstallCommand(skillPageUrl) : null; const promptPreview = formatOpenClawPrompt({ mode: "install-and-setup", skillName: displayName, @@ -205,13 +210,21 @@ export function SkillCommandLineCard({ ownerId, clawdis, }); - const activeInstallText = activeInstallTab === "prompt" ? promptPreview : openClawCommand; - const selectInstallTab = (tab: "cli" | "prompt") => { + const activeInstallText = + activeInstallTab === "prompt" + ? promptPreview + : activeInstallTab === "skills" && skillsCliCommand + ? skillsCliCommand + : openClawCommand; + const installTabOrder: InstallTab[] = ["cli", "skills", "prompt"]; + const selectInstallTab = (tab: InstallTab) => { if (tab === activeInstallTab) { return; } - setInstallTabDirection(tab === "prompt" ? "right" : "left"); + setInstallTabDirection( + installTabOrder.indexOf(tab) > installTabOrder.indexOf(activeInstallTab) ? "right" : "left", + ); setActiveInstallTab(tab); }; @@ -230,6 +243,16 @@ export function SkillCommandLineCard({ > CLI + {skillsCliCommand ? ( + + ) : null}