mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
feat: support npx skills discovery (#3233)
This commit is contained in:
Vendored
+4
@@ -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;
|
||||
|
||||
@@ -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<typeof import("./lib/githubImport")>();
|
||||
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<string, unknown>) {
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<SkillInstallResolution, { ok: true }>;
|
||||
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<SkillInstallResolution, { ok: true }>;
|
||||
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<string, Uint8Array> = {};
|
||||
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" },
|
||||
});
|
||||
}
|
||||
+1
-1
@@ -198,7 +198,7 @@ async function githubDownloadHandoffResponse(
|
||||
});
|
||||
}
|
||||
|
||||
async function scheduleSkillDownloadMetric(
|
||||
export async function scheduleSkillDownloadMetric(
|
||||
ctx: DownloadCtx,
|
||||
request: Request,
|
||||
skillId: Id<"skills">,
|
||||
|
||||
@@ -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<SourceForSync | null> => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<string, Uint8Array>,
|
||||
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);
|
||||
}
|
||||
@@ -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<ReturnType<typeof createServer>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
servers
|
||||
.splice(0)
|
||||
.map(
|
||||
(server) =>
|
||||
new Promise<void>((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<void>((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);
|
||||
});
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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<InstallTab>("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
|
||||
</button>
|
||||
{skillsCliCommand ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`skill-install-tab${activeInstallTab === "skills" ? " is-active" : ""}`}
|
||||
aria-pressed={activeInstallTab === "skills"}
|
||||
onClick={() => selectInstallTab("skills")}
|
||||
>
|
||||
npx skills
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={`skill-install-tab${activeInstallTab === "prompt" ? " is-active" : ""}`}
|
||||
@@ -244,10 +267,10 @@ export function SkillCommandLineCard({
|
||||
<div className="skill-install-command-wrap">
|
||||
<div
|
||||
className={`skill-install-command-shell${
|
||||
activeInstallTab === "cli" ? " skill-install-command-shell-cli" : ""
|
||||
activeInstallTab !== "prompt" ? " skill-install-command-shell-cli" : ""
|
||||
}`}
|
||||
>
|
||||
{activeInstallTab === "cli" ? (
|
||||
{activeInstallTab !== "prompt" ? (
|
||||
<span className="skill-install-command-prompt" aria-hidden="true">
|
||||
$
|
||||
</span>
|
||||
@@ -260,7 +283,7 @@ export function SkillCommandLineCard({
|
||||
} skill-install-command-reveal`}
|
||||
tabIndex={0}
|
||||
>
|
||||
{activeInstallTab === "cli" ? (
|
||||
{activeInstallTab !== "prompt" ? (
|
||||
<OpenClawCliInstallCommand command={activeInstallText} />
|
||||
) : (
|
||||
<code translate="no">{activeInstallText}</code>
|
||||
@@ -269,7 +292,11 @@ export function SkillCommandLineCard({
|
||||
<InstallCopyButton
|
||||
text={activeInstallText}
|
||||
ariaLabel={
|
||||
activeInstallTab === "prompt" ? "Copy OpenClaw prompt" : "Copy OpenClaw CLI command"
|
||||
activeInstallTab === "prompt"
|
||||
? "Copy OpenClaw prompt"
|
||||
: activeInstallTab === "skills"
|
||||
? "Copy npx skills command"
|
||||
: "Copy OpenClaw CLI command"
|
||||
}
|
||||
className="skill-install-command-inline-button"
|
||||
showLabel={false}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
formatClawHubInstallCommand,
|
||||
formatOpenClawInstallCommand,
|
||||
formatOpenClawPrompt,
|
||||
formatSkillsCliInstallCommand,
|
||||
} from "./skillDetailUtils";
|
||||
|
||||
describe("skill detail install helpers", () => {
|
||||
@@ -36,6 +37,9 @@ describe("skill detail install helpers", () => {
|
||||
expect(formatClawHubInstallCommand("@steipete/weather", "bun")).toBe(
|
||||
"bunx clawhub@latest install @steipete/weather",
|
||||
);
|
||||
expect(formatSkillsCliInstallCommand("https://clawhub.ai/steipete/skills/weather")).toBe(
|
||||
"npx skills add https://clawhub.ai/steipete/skills/weather",
|
||||
);
|
||||
});
|
||||
|
||||
it("builds the install-and-setup prompt from known metadata only", () => {
|
||||
|
||||
@@ -181,6 +181,10 @@ export function formatOpenClawInstallCommand(slug: string) {
|
||||
return `openclaw skills install ${slug}`;
|
||||
}
|
||||
|
||||
export function formatSkillsCliInstallCommand(skillPageUrl: string) {
|
||||
return `npx skills add ${skillPageUrl}`;
|
||||
}
|
||||
|
||||
export function formatClawHubInstallCommand(slug: string, pm: SkillPackageManager) {
|
||||
switch (pm) {
|
||||
case "npm":
|
||||
|
||||
@@ -61,6 +61,7 @@ import { Route as SkillsShOwnerRepoSlugRouteImport } from './routes/skills-sh/$o
|
||||
import { Route as OwnerPluginsSlugSecurityScannerRouteImport } from './routes/$owner/plugins/$slug/security/$scanner'
|
||||
import { Route as OwnerSkillsSlugSecurityScannerRouteImport } from './routes/$owner/skills/$slug/security/$scanner'
|
||||
import { Route as PluginsScopeNameSecurityScannerRouteImport } from './routes/plugins/$scope/$name/security/$scanner'
|
||||
import { Route as OwnerSkillsSlugDotwellKnownAgentSkillsIndexDotjsonRouteImport } from './routes/$owner/skills/$slug/[.]well-known/agent-skills/index[.]json'
|
||||
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
@@ -331,6 +332,12 @@ const PluginsScopeNameSecurityScannerRoute =
|
||||
path: '/security/$scanner',
|
||||
getParentRoute: () => PluginsScopeNameRoute,
|
||||
} as any)
|
||||
const OwnerSkillsSlugDotwellKnownAgentSkillsIndexDotjsonRoute =
|
||||
OwnerSkillsSlugDotwellKnownAgentSkillsIndexDotjsonRouteImport.update({
|
||||
id: '/.well-known/agent-skills/index.json',
|
||||
path: '/.well-known/agent-skills/index.json',
|
||||
getParentRoute: () => OwnerSkillsSlugRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
@@ -385,6 +392,7 @@ export interface FileRoutesByFullPath {
|
||||
'/$owner/plugins/$slug/security/$scanner': typeof OwnerPluginsSlugSecurityScannerRoute
|
||||
'/$owner/skills/$slug/security/$scanner': typeof OwnerSkillsSlugSecurityScannerRoute
|
||||
'/plugins/$scope/$name/security/$scanner': typeof PluginsScopeNameSecurityScannerRoute
|
||||
'/$owner/skills/$slug/.well-known/agent-skills/index.json': typeof OwnerSkillsSlugDotwellKnownAgentSkillsIndexDotjsonRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
@@ -439,6 +447,7 @@ export interface FileRoutesByTo {
|
||||
'/$owner/plugins/$slug/security/$scanner': typeof OwnerPluginsSlugSecurityScannerRoute
|
||||
'/$owner/skills/$slug/security/$scanner': typeof OwnerSkillsSlugSecurityScannerRoute
|
||||
'/plugins/$scope/$name/security/$scanner': typeof PluginsScopeNameSecurityScannerRoute
|
||||
'/$owner/skills/$slug/.well-known/agent-skills/index.json': typeof OwnerSkillsSlugDotwellKnownAgentSkillsIndexDotjsonRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
@@ -494,6 +503,7 @@ export interface FileRoutesById {
|
||||
'/$owner/plugins/$slug/security/$scanner': typeof OwnerPluginsSlugSecurityScannerRoute
|
||||
'/$owner/skills/$slug/security/$scanner': typeof OwnerSkillsSlugSecurityScannerRoute
|
||||
'/plugins/$scope/$name/security/$scanner': typeof PluginsScopeNameSecurityScannerRoute
|
||||
'/$owner/skills/$slug/.well-known/agent-skills/index.json': typeof OwnerSkillsSlugDotwellKnownAgentSkillsIndexDotjsonRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
@@ -550,6 +560,7 @@ export interface FileRouteTypes {
|
||||
| '/$owner/plugins/$slug/security/$scanner'
|
||||
| '/$owner/skills/$slug/security/$scanner'
|
||||
| '/plugins/$scope/$name/security/$scanner'
|
||||
| '/$owner/skills/$slug/.well-known/agent-skills/index.json'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
@@ -604,6 +615,7 @@ export interface FileRouteTypes {
|
||||
| '/$owner/plugins/$slug/security/$scanner'
|
||||
| '/$owner/skills/$slug/security/$scanner'
|
||||
| '/plugins/$scope/$name/security/$scanner'
|
||||
| '/$owner/skills/$slug/.well-known/agent-skills/index.json'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
@@ -658,6 +670,7 @@ export interface FileRouteTypes {
|
||||
| '/$owner/plugins/$slug/security/$scanner'
|
||||
| '/$owner/skills/$slug/security/$scanner'
|
||||
| '/plugins/$scope/$name/security/$scanner'
|
||||
| '/$owner/skills/$slug/.well-known/agent-skills/index.json'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
@@ -1069,6 +1082,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof PluginsScopeNameSecurityScannerRouteImport
|
||||
parentRoute: typeof PluginsScopeNameRoute
|
||||
}
|
||||
'/$owner/skills/$slug/.well-known/agent-skills/index.json': {
|
||||
id: '/$owner/skills/$slug/.well-known/agent-skills/index.json'
|
||||
path: '/.well-known/agent-skills/index.json'
|
||||
fullPath: '/$owner/skills/$slug/.well-known/agent-skills/index.json'
|
||||
preLoaderRoute: typeof OwnerSkillsSlugDotwellKnownAgentSkillsIndexDotjsonRouteImport
|
||||
parentRoute: typeof OwnerSkillsSlugRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1119,12 +1139,15 @@ interface OwnerSkillsSlugRouteChildren {
|
||||
OwnerSkillsSlugSecurityAuditRoute: typeof OwnerSkillsSlugSecurityAuditRoute
|
||||
OwnerSkillsSlugSettingsRoute: typeof OwnerSkillsSlugSettingsRoute
|
||||
OwnerSkillsSlugSecurityScannerRoute: typeof OwnerSkillsSlugSecurityScannerRoute
|
||||
OwnerSkillsSlugDotwellKnownAgentSkillsIndexDotjsonRoute: typeof OwnerSkillsSlugDotwellKnownAgentSkillsIndexDotjsonRoute
|
||||
}
|
||||
|
||||
const OwnerSkillsSlugRouteChildren: OwnerSkillsSlugRouteChildren = {
|
||||
OwnerSkillsSlugSecurityAuditRoute: OwnerSkillsSlugSecurityAuditRoute,
|
||||
OwnerSkillsSlugSettingsRoute: OwnerSkillsSlugSettingsRoute,
|
||||
OwnerSkillsSlugSecurityScannerRoute: OwnerSkillsSlugSecurityScannerRoute,
|
||||
OwnerSkillsSlugDotwellKnownAgentSkillsIndexDotjsonRoute:
|
||||
OwnerSkillsSlugDotwellKnownAgentSkillsIndexDotjsonRoute,
|
||||
}
|
||||
|
||||
const OwnerSkillsSlugRouteWithChildren = OwnerSkillsSlugRoute._addFileChildren(
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { publicApiUrl } from "../../../../../../lib/publicApiUrl";
|
||||
|
||||
export const Route = createFileRoute("/$owner/skills/$slug/.well-known/agent-skills/index.json")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: ({ params }) => fetchAgentSkillsDiscovery(params.owner, params.slug, "GET"),
|
||||
HEAD: ({ params }) => fetchAgentSkillsDiscovery(params.owner, params.slug, "HEAD"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
async function fetchAgentSkillsDiscovery(owner: string, slug: string, method: "GET" | "HEAD") {
|
||||
const upstream = publicApiUrl(
|
||||
`/api/v1/agent-skills/${encodeURIComponent(owner)}/${encodeURIComponent(slug)}/index.json`,
|
||||
);
|
||||
const response = await fetch(upstream, {
|
||||
method,
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
return proxyAgentSkillsDiscoveryResponse(response, method === "GET");
|
||||
}
|
||||
|
||||
export async function proxyAgentSkillsDiscoveryResponse(response: Response, includeBody = true) {
|
||||
const headers = new Headers();
|
||||
const contentType = response.headers.get("Content-Type");
|
||||
const cacheControl = response.headers.get("Cache-Control");
|
||||
if (contentType) headers.set("Content-Type", contentType);
|
||||
if (cacheControl) headers.set("Cache-Control", cacheControl);
|
||||
|
||||
return new Response(includeBody ? await response.arrayBuffer() : null, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user