feat: add controlled skills.sh scanned installs (#3221)

* feat: add controlled skills.sh scanned installs

* fix: resolve exact skills.sh install references

* fix: verify exact skills.sh catalog references
This commit is contained in:
Patrick Erichsen
2026-07-22 02:00:19 -07:00
committed by GitHub
parent ee9fac51cd
commit 89f5e62ef7
39 changed files with 3793 additions and 581 deletions
+2
View File
@@ -132,6 +132,7 @@ import type * as lib_skills_index from "../lib/skills/index.js";
import type * as lib_skills_slugResolution from "../lib/skills/slugResolution.js";
import type * as lib_skillsShCatalogEnvironment from "../lib/skillsShCatalogEnvironment.js";
import type * as lib_skillsShCatalogFixtures from "../lib/skillsShCatalogFixtures.js";
import type * as lib_skillsShCatalogPublication from "../lib/skillsShCatalogPublication.js";
import type * as lib_staticPublishScan from "../lib/staticPublishScan.js";
import type * as lib_testSeed from "../lib/testSeed.js";
import type * as lib_tokens from "../lib/tokens.js";
@@ -307,6 +308,7 @@ declare const fullApi: ApiFromModules<{
"lib/skills/slugResolution": typeof lib_skills_slugResolution;
"lib/skillsShCatalogEnvironment": typeof lib_skillsShCatalogEnvironment;
"lib/skillsShCatalogFixtures": typeof lib_skillsShCatalogFixtures;
"lib/skillsShCatalogPublication": typeof lib_skillsShCatalogPublication;
"lib/staticPublishScan": typeof lib_staticPublishScan;
"lib/testSeed": typeof lib_testSeed;
"lib/tokens": typeof lib_tokens;
@@ -0,0 +1,87 @@
---
name: html
description: Use when the user invokes /html, wants to choose an HTML artifact pattern, or wants a visual or interactive single-file HTML format for planning, review, explanation, reporting, or lightweight editing.
---
# HTML Artifact Chooser
Use this as a menu for picking an HTML artifact shape. It is based on Thariq Shihipar's "The unreasonable effectiveness of HTML" examples.
Source index: https://thariqs.github.io/html-effectiveness/
## How to Use
When the user asks for `/html`, show the options below and ask which one they want to explore or create.
After the user chooses an option, open the linked example and read it before designing the artifact. Treat the example as pattern inspiration, not a template to copy blindly.
Keep outputs as a single self-contained `.html` file unless the user asks for something else. For interactive tools, include a copy/export affordance when useful.
## Default Location
When working inside a repo or workspace, write generated HTML artifacts under
gitignored `.artifacts/` by default so they are easy to open locally without
polluting source docs or PR diffs. For planning or review artifacts, prefer
`.artifacts/<short-slug>/index.html` or `.artifacts/<short-slug>.html` unless
the user asks for a different path.
If the user is using the Codex app, open the generated HTML artifact in the
in-app browser when practical and report the local file path. Keep committed
`docs/` or `specs/` for durable repo documentation, not temporary HTML review
artifacts.
## Options
| Option | Use When | Full Example |
| --- | --- | --- |
| Three code approaches | Comparing multiple implementation strategies side by side with tradeoffs | https://thariqs.github.io/html-effectiveness/01-exploration-code-approaches.html |
| Visual design directions | Reviewing layout, palette, or visual direction options as rendered screens | https://thariqs.github.io/html-effectiveness/02-exploration-visual-designs.html |
| Annotated pull request | Turning a diff or PR review into a scannable annotated artifact | https://thariqs.github.io/html-effectiveness/03-code-review-pr.html |
| Module map | Explaining an unfamiliar package, dependency graph, hot path, or entry points | https://thariqs.github.io/html-effectiveness/04-code-understanding.html |
| Living design system | Showing tokens, colors, type, spacing, and components from a repo | https://thariqs.github.io/html-effectiveness/05-design-system.html |
| Component variants | Reviewing one component across sizes, states, intents, and edge cases | https://thariqs.github.io/html-effectiveness/06-component-variants.html |
| Animation sandbox | Tuning motion with sliders for duration, easing, delay, or intensity | https://thariqs.github.io/html-effectiveness/07-prototype-animation.html |
| Clickable flow | Trying a lightweight multi-screen interaction before implementation | https://thariqs.github.io/html-effectiveness/08-prototype-interaction.html |
| Arrow-key slide deck | Turning a short narrative, update, or meeting brief into browser slides | https://thariqs.github.io/html-effectiveness/09-slide-deck.html |
| SVG figure sheet | Creating editable inline vector figures for docs, posts, or explainers | https://thariqs.github.io/html-effectiveness/10-svg-illustrations.html |
| Weekly status | Making a recurring status update skimmable with sections and small charts | https://thariqs.github.io/html-effectiveness/11-status-report.html |
| Incident timeline | Reconstructing an incident or debugging story with logs and follow-ups | https://thariqs.github.io/html-effectiveness/12-incident-report.html |
| Annotated flowchart | Explaining a process, workflow, pipeline, or failure path interactively | https://thariqs.github.io/html-effectiveness/13-flowchart-diagram.html |
| Feature explainer | Teaching how a repo feature works with paths, snippets, FAQ, and glossary | https://thariqs.github.io/html-effectiveness/14-research-feature-explainer.html |
| Concept explainer | Teaching a general concept with an interactive model and glossary | https://thariqs.github.io/html-effectiveness/15-research-concept-explainer.html |
| Implementation plan | Turning a selected approach into milestones, risks, flows, and handoff notes | https://thariqs.github.io/html-effectiveness/16-implementation-plan.html |
| PR writeup for reviewers | Preparing reviewer context with motivation, before/after, and file tour | https://thariqs.github.io/html-effectiveness/17-pr-writeup.html |
| Ticket triage board | Sorting issues or tasks visually, then exporting the final order | https://thariqs.github.io/html-effectiveness/18-editor-triage-board.html |
| Feature flag editor | Editing flags with dependency warnings and a copyable diff | https://thariqs.github.io/html-effectiveness/19-editor-feature-flags.html |
| Prompt tuner | Editing a prompt template while live-rendering sample inputs | https://thariqs.github.io/html-effectiveness/20-editor-prompt-tuner.html |
## Default Response Shape
If the user invokes `/html` without a choice, reply with:
```text
Pick one HTML artifact pattern:
1. Three code approaches
2. Visual design directions
3. Annotated pull request
4. Module map
5. Living design system
6. Component variants
7. Animation sandbox
8. Clickable flow
9. Arrow-key slide deck
10. SVG figure sheet
11. Weekly status
12. Incident timeline
13. Annotated flowchart
14. Feature explainer
15. Concept explainer
16. Implementation plan
17. PR writeup for reviewers
18. Ticket triage board
19. Feature flag editor
20. Prompt tuner
```
If the user names a choice, say which source example you are reading, then inspect that URL before producing the artifact.
+7
View File
@@ -60,6 +60,7 @@ import {
whoamiV1Http,
contentRightsV1Http,
skillsShCatalogTestV1Http,
skillsShCatalogPublicV1Http,
} from "./httpApiV1";
import { preflightHandler } from "./httpPreflight";
import { installRateLimitedRoutes } from "./lib/httpRouteRateLimit";
@@ -331,6 +332,12 @@ http.route({
handler: skillsShCatalogTestV1Http,
});
http.route({
pathPrefix: "/api/v1/skills-sh/",
method: "GET",
handler: skillsShCatalogPublicV1Http,
});
http.route({
path: "/api/v1/operator/skills-sh/catalog-test",
method: "POST",
+2
View File
@@ -327,6 +327,7 @@ describe("httpApi handlers", () => {
event: "install",
slug: "weather",
ownerHandle: "openclaw",
sourceRef: "skills-sh/openclaw/skills/weather",
version: "1.0.0",
rootId: "abc",
rootLabel: "~/skills",
@@ -339,6 +340,7 @@ describe("httpApi handlers", () => {
userId: "users:1",
slug: "weather",
ownerHandle: "openclaw",
sourceRef: "skills-sh/openclaw/skills/weather",
version: "1.0.0",
});
});
+1
View File
@@ -332,6 +332,7 @@ async function cliTelemetryInstallHandler(ctx: ActionCtx, request: Request) {
userId,
slug: args.slug,
ownerHandle: args.ownerHandle,
sourceRef: args.sourceRef,
version: args.version,
});
}
+279
View File
@@ -2864,6 +2864,285 @@ describe("httpApiV1 handlers", () => {
});
});
it("skill install resolver returns the exact approved skills.sh GitHub descriptor", async () => {
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
expect(args).toEqual({
owner: "patrick-erichsen",
repo: "skills",
slug: "html",
});
return {
install: {
ok: true,
slug: "skills-sh/patrick-erichsen/skills/html",
installKind: "github",
github: {
repo: "patrick-erichsen/skills",
path: "skills/html",
commit: "050daba89f6b6636470add5cb300aac46a412cf8",
contentHash: "a47adb2c1ac33c088f664b5187971b63d2b958a7b9f01516d26005ca941a108f",
sourceUrl:
"https://github.com/patrick-erichsen/skills/tree/050daba89f6b6636470add5cb300aac46a412cf8/skills/html",
},
},
};
});
const runMutation = vi.fn().mockResolvedValue(okRate());
const response = await __handlers.skillsGetRouterV1Handler(
makeCtx({ runQuery, runMutation }),
new Request(
"https://example.com/api/v1/skills/html/install?reference=skills-sh%2Fpatrick-erichsen%2Fskills%2Fhtml",
),
);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
ok: true,
slug: "skills-sh/patrick-erichsen/skills/html",
installKind: "github",
github: {
repo: "patrick-erichsen/skills",
path: "skills/html",
commit: "050daba89f6b6636470add5cb300aac46a412cf8",
},
});
expect(runQuery).toHaveBeenCalledTimes(1);
});
it.each(["skills-sh:patrick-erichsen/skills/html", "skills-sh/patrick-erichsen/skills/weather"])(
"skill install resolver rejects invalid exact catalog reference %s",
async (reference) => {
const runQuery = vi.fn();
const runMutation = vi.fn().mockResolvedValue(okRate());
const response = await __handlers.skillsGetRouterV1Handler(
makeCtx({ runQuery, runMutation }),
new Request(
`https://example.com/api/v1/skills/html/install?reference=${encodeURIComponent(reference)}`,
),
);
expect(response.status).toBe(400);
expect(runQuery).not.toHaveBeenCalled();
},
);
it("skill install resolver never falls back to a native slug for a hidden catalog reference", async () => {
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
expect(args).toEqual({
owner: "patrick-erichsen",
repo: "skills",
slug: "html",
});
return null;
});
const runMutation = vi.fn().mockResolvedValue(okRate());
const response = await __handlers.skillsGetRouterV1Handler(
makeCtx({ runQuery, runMutation }),
new Request(
"https://example.com/api/v1/skills/html/install?reference=skills-sh%2Fpatrick-erichsen%2Fskills%2Fhtml",
),
);
expect(response.status).toBe(404);
await expect(response.text()).resolves.toBe("Skill not found");
expect(runQuery).toHaveBeenCalledTimes(1);
});
it("skill verification returns the exact approved skills.sh scan envelope", async () => {
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
expect(args).toEqual({
owner: "patrick-erichsen",
repo: "skills",
slug: "html",
});
return {
ref: "skills-sh/patrick-erichsen/skills/html",
route: "/skills-sh/patrick-erichsen/skills/html",
displayName: "HTML Artifact Chooser",
repository: "patrick-erichsen/skills",
githubPath: "skills/html",
githubCommit: "1".repeat(40),
githubContentHash: "a47adb2c1ac33c088f664b5187971b63d2b958a7b9f01516d26005ca941a108f",
security: {
verdict: "clean",
source: "clawhub",
attemptId: "skillsShCatalogScanAttempts:canary",
scannedAt: 123,
},
artifact: {
contentHash: "c".repeat(64),
files: [
{
path: "SKILL.md",
size: 42,
sha256: "d".repeat(64),
contentType: "text/markdown",
},
],
},
};
});
const runMutation = vi.fn().mockResolvedValue(okRate());
const response = await __handlers.skillsGetRouterV1Handler(
makeCtx({ runQuery, runMutation }),
new Request(
"https://example.com/api/v1/skills/html/verify?reference=skills-sh%2Fpatrick-erichsen%2Fskills%2Fhtml",
),
);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
schema: "clawhub.skill.verify.v1",
ok: true,
decision: "pass",
reasons: [],
slug: "skills-sh/patrick-erichsen/skills/html",
displayName: "HTML Artifact Chooser",
pageUrl: "https://example.com/skills-sh/patrick-erichsen/skills/html",
publisherHandle: null,
publisherDisplayName: null,
publisherProfileUrl: null,
version: "1".repeat(40),
resolvedFrom: "latest",
tag: null,
createdAt: 123,
card: {
available: false,
path: "skill-card.md",
url: null,
sha256: null,
size: null,
contentType: null,
},
artifact: {
sourceFingerprint: "a47adb2c1ac33c088f664b5187971b63d2b958a7b9f01516d26005ca941a108f",
bundleFingerprints: ["c".repeat(64)],
files: [
{
path: "SKILL.md",
size: 42,
sha256: "d".repeat(64),
contentType: "text/markdown",
},
],
},
provenance: {
source: "skills-sh-catalog",
reference: "skills-sh/patrick-erichsen/skills/html",
repository: "patrick-erichsen/skills",
path: "skills/html",
commit: "1".repeat(40),
contentHash: "a47adb2c1ac33c088f664b5187971b63d2b958a7b9f01516d26005ca941a108f",
scanAttemptId: "skillsShCatalogScanAttempts:canary",
artifactContentHash: "c".repeat(64),
},
security: {
status: "clean",
passed: true,
rawStatus: "clean",
verdict: "clean",
source: "clawhub",
attemptId: "skillsShCatalogScanAttempts:canary",
checkedAt: 123,
},
signature: {
status: "unsigned",
},
});
expect(runQuery).toHaveBeenCalledTimes(1);
});
it("skill verification preserves suspicious publication while failing the security decision", async () => {
const runQuery = vi.fn(async () => ({
ref: "skills-sh/patrick-erichsen/skills/html",
route: "/skills-sh/patrick-erichsen/skills/html",
displayName: "HTML Artifact Chooser",
repository: "patrick-erichsen/skills",
githubPath: "skills/html",
githubCommit: "1".repeat(40),
githubContentHash: "a47adb2c1ac33c088f664b5187971b63d2b958a7b9f01516d26005ca941a108f",
security: {
verdict: "suspicious",
source: "clawhub",
attemptId: "skillsShCatalogScanAttempts:canary",
scannedAt: 123,
},
artifact: {
contentHash: "c".repeat(64),
files: [{ path: "SKILL.md", size: 42, sha256: "d".repeat(64) }],
},
}));
const runMutation = vi.fn().mockResolvedValue(okRate());
const response = await __handlers.skillsGetRouterV1Handler(
makeCtx({ runQuery, runMutation }),
new Request(
"https://example.com/api/v1/skills/html/verify?reference=skills-sh%2Fpatrick-erichsen%2Fskills%2Fhtml",
),
);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
schema: "clawhub.skill.verify.v1",
ok: false,
decision: "fail",
reasons: ["security.status_not_clean"],
slug: "skills-sh/patrick-erichsen/skills/html",
security: {
status: "suspicious",
passed: false,
verdict: "suspicious",
attemptId: "skillsShCatalogScanAttempts:canary",
},
});
expect(runQuery).toHaveBeenCalledTimes(1);
});
it.each(["skills-sh:patrick-erichsen/skills/html", "skills-sh/patrick-erichsen/skills/weather"])(
"skill verification rejects invalid exact catalog reference %s",
async (reference) => {
const runQuery = vi.fn();
const runMutation = vi.fn().mockResolvedValue(okRate());
const response = await __handlers.skillsGetRouterV1Handler(
makeCtx({ runQuery, runMutation }),
new Request(
`https://example.com/api/v1/skills/html/verify?reference=${encodeURIComponent(reference)}`,
),
);
expect(response.status).toBe(400);
expect(runQuery).not.toHaveBeenCalled();
},
);
it("skill verification never falls back to a native slug for a hidden catalog reference", async () => {
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
expect(args).toEqual({
owner: "patrick-erichsen",
repo: "skills",
slug: "html",
});
return null;
});
const runMutation = vi.fn().mockResolvedValue(okRate());
const response = await __handlers.skillsGetRouterV1Handler(
makeCtx({ runQuery, runMutation }),
new Request(
"https://example.com/api/v1/skills/html/verify?reference=skills-sh%2Fpatrick-erichsen%2Fskills%2Fhtml",
),
);
expect(response.status).toBe(404);
await expect(response.text()).resolves.toBe("Skill not found");
expect(runQuery).toHaveBeenCalledTimes(1);
});
it("skill install resolver returns a pinned GitHub descriptor for scan-clean source-backed skills", async () => {
const runQuery = makeInstallResolverRunQuery({
skill: {
+6 -1
View File
@@ -27,7 +27,10 @@ import {
promotionsPostRouterV1Handler,
} from "./httpApiV1/promotionsV1";
import { createPublisherV1Handler } from "./httpApiV1/publishersV1";
import { skillsShCatalogTestV1Handler } from "./httpApiV1/skillsShCatalogV1";
import {
skillsShCatalogPublicV1Handler,
skillsShCatalogTestV1Handler,
} from "./httpApiV1/skillsShCatalogV1";
import {
exportSkillsV1Handler,
listSkillsV1Handler,
@@ -69,6 +72,7 @@ export const verifyDocsSessionV1Http = httpAction(verifyDocsSessionV1Handler);
export const createPublisherV1Http = httpAction(createPublisherV1Handler);
export const contentRightsV1Http = httpAction(contentRightsV1Handler);
export const skillsShCatalogTestV1Http = httpAction(skillsShCatalogTestV1Handler);
export const skillsShCatalogPublicV1Http = httpAction(skillsShCatalogPublicV1Handler);
export const catalogFeedV1Http = httpAction(catalogFeedV1Handler);
export const catalogSkillsFeedV1Http = httpAction(catalogSkillsFeedV1Handler);
export const promotionsFeedV1Http = httpAction(promotionsFeedV1Handler);
@@ -119,6 +123,7 @@ export const __handlers = {
createPublisherV1Handler,
contentRightsV1Handler,
skillsShCatalogTestV1Handler,
skillsShCatalogPublicV1Handler,
catalogFeedV1Handler,
catalogSkillsFeedV1Handler,
searchSkillsV1Handler,
+203 -2
View File
@@ -30,8 +30,11 @@ vi.mock("./shared", async (importOriginal) => {
const { requireAdminOrResponse, requireApiTokenUserOrResponse } = await import("./shared");
const { buildGitHubApiHeaders } = await import("../lib/githubAuth");
const { computeGitHubSkillFolderContentHash } = await import("../lib/githubSkillSync");
const { skillsShCatalogTestV1Handler, verifyControlledCanaryGitHubSource } =
await import("./skillsShCatalogV1");
const {
skillsShCatalogPublicV1Handler,
skillsShCatalogTestV1Handler,
verifyControlledCanaryGitHubSource,
} = await import("./skillsShCatalogV1");
function sha256(value: string) {
return createHash("sha256").update(value).digest("hex");
@@ -565,4 +568,202 @@ describe("skills.sh catalog Test HTTP API", () => {
});
expect(JSON.stringify(runMutation.mock.calls[0]?.[1])).not.toContain("client-controlled");
});
it.each([
{
operation: "start-canary-scan",
input: { reason: "scan the exact canary" },
expected: {
actor: "catalog-operator",
reason: "scan the exact canary",
},
},
{
operation: "set-publication",
input: {
enabled: true,
reason: "enable exact-version publication",
confirm: "set-skills-sh-test-publication",
},
expected: {
enabled: true,
actor: "catalog-operator",
reason: "enable exact-version publication",
confirm: "set-skills-sh-test-publication",
},
},
{
operation: "set-pause",
input: {
paused: true,
reason: "pause catalog-only work",
confirm: "set-skills-sh-test-pause",
},
expected: {
paused: true,
actor: "catalog-operator",
reason: "pause catalog-only work",
confirm: "set-skills-sh-test-pause",
},
},
{
operation: "rollback-publication",
input: {
externalId: "patrick-erichsen/skills/html",
attemptId: "skillsShCatalogScanAttempts:canary",
reason: "hide the exact published attempt",
confirm: "rollback-skills-sh-test-publication",
},
expected: {
externalId: "patrick-erichsen/skills/html",
attemptId: "skillsShCatalogScanAttempts:canary",
actor: "catalog-operator",
reason: "hide the exact published attempt",
confirm: "rollback-skills-sh-test-publication",
},
},
])("forwards the $operation operator command", async ({ operation, input, expected }) => {
const runMutation = vi.fn(async (_ref: unknown, _args: unknown) => ({ ok: true }));
const ctx = {
runQuery: vi.fn(async () => ({
environment: "test",
deploymentName: "academic-chihuahua-392",
buildSha: "test-sha",
control: {},
})),
runMutation,
} as never;
const request = new Request("https://academic-chihuahua-392.convex.site/api/v1/ops", {
method: "POST",
body: JSON.stringify({ operation, ...input }),
});
const response = await skillsShCatalogTestV1Handler(ctx, request);
expect(response.status).toBe(200);
expect(runMutation).toHaveBeenCalledOnce();
expect(runMutation.mock.calls[0]?.[1]).toEqual(expected);
});
it.each([
{ operation: "set-publication", field: "enabled", value: undefined },
{ operation: "set-publication", field: "enabled", value: "true" },
{ operation: "set-publication", field: "enabled", value: null },
{ operation: "set-pause", field: "paused", value: undefined },
{ operation: "set-pause", field: "paused", value: "false" },
{ operation: "set-pause", field: "paused", value: 0 },
])("rejects malformed $operation $field controls", async ({ operation, field, value }) => {
const runMutation = vi.fn(async (_ref: unknown, _args: unknown) => ({ ok: true }));
const ctx = {
runQuery: vi.fn(async () => ({
environment: "test",
deploymentName: "academic-chihuahua-392",
buildSha: "test-sha",
control: {},
})),
runMutation,
} as never;
const request = new Request("https://academic-chihuahua-392.convex.site/api/v1/ops", {
method: "POST",
body: JSON.stringify({
operation,
...(value === undefined ? {} : { [field]: value }),
reason: "exercise strict operator input validation",
confirm:
operation === "set-publication"
? "set-skills-sh-test-publication"
: "set-skills-sh-test-pause",
}),
});
const response = await skillsShCatalogTestV1Handler(ctx, request);
expect(response.status).toBe(400);
expect(await response.text()).toBe(`${field} is required`);
expect(runMutation).not.toHaveBeenCalled();
});
});
describe("skills.sh public HTTP API", () => {
const publicEntry = {
ref: "skills-sh/patrick-erichsen/skills/html",
route: "/skills-sh/patrick-erichsen/skills/html",
displayName: "HTML Artifact Chooser",
security: {
verdict: "clean",
source: "clawhub",
attemptId: "skillsShCatalogScanAttempts:canary",
},
install: {
ok: true,
slug: "skills-sh/patrick-erichsen/skills/html",
installKind: "github",
github: {
repo: "patrick-erichsen/skills",
path: "skills/html",
commit: "050daba89f6b6636470add5cb300aac46a412cf8",
contentHash: "a47adb2c1ac33c088f664b5187971b63d2b958a7b9f01516d26005ca941a108f",
sourceUrl:
"https://github.com/patrick-erichsen/skills/tree/050daba89f6b6636470add5cb300aac46a412cf8/skills/html",
},
},
};
it.each([
{
suffix: "",
expected: publicEntry,
},
{
suffix: "/install",
expected: publicEntry.install,
},
])("serves an approved slash route$suffix", async ({ suffix, expected }) => {
const runQuery = vi.fn(async () => publicEntry);
const ctx = { runQuery } as never;
const response = await skillsShCatalogPublicV1Handler(
ctx,
new Request(
`https://academic-chihuahua-392.convex.site/api/v1/skills-sh/patrick-erichsen/skills/html${suffix}`,
),
);
expect(response.status).toBe(200);
expect(await response.json()).toEqual(expected);
expect(runQuery).toHaveBeenCalledWith(expect.anything(), {
owner: "patrick-erichsen",
repo: "skills",
slug: "html",
});
});
it.each([
"/api/v1/skills-sh:patrick-erichsen/skills/html/install",
"/api/v1/skills-sh/patrick-erichsen/skills/html/extra",
"/api/v1/skills-sh/patrick-erichsen/skills%3Ahtml/install",
"/api/v1/skills-sh/patrick-erichsen/%/html/install",
])("rejects malformed or colon-form references at %s", async (path) => {
const runQuery = vi.fn();
const ctx = { runQuery } as never;
const response = await skillsShCatalogPublicV1Handler(
ctx,
new Request(`https://academic-chihuahua-392.convex.site${path}`),
);
expect(response.status).toBe(404);
expect(runQuery).not.toHaveBeenCalled();
});
it("returns 404 while the exact entry is not public", async () => {
const ctx = { runQuery: vi.fn(async () => null) } as never;
const response = await skillsShCatalogPublicV1Handler(
ctx,
new Request(
"https://academic-chihuahua-392.convex.site/api/v1/skills-sh/patrick-erichsen/skills/html/install",
),
);
expect(response.status).toBe(404);
expect(await response.text()).toBe("Skill not found");
});
});
+105 -1
View File
@@ -1,4 +1,4 @@
import { internal } from "../_generated/api";
import { api, internal } from "../_generated/api";
import type { Id } from "../_generated/dataModel";
import type { ActionCtx } from "../_generated/server";
import { buildGitHubApiHeaders } from "../lib/githubAuth";
@@ -20,6 +20,10 @@ const internalRefs = internal as unknown as {
processStagingLiveBatchInternal: unknown;
resolveKnownGitHubOwnersInternal: unknown;
rollbackFixtureRunInternal: unknown;
rollbackPublicationInternal: unknown;
setCatalogPausedInternal: unknown;
setPublicationEnabledInternal: unknown;
startControlledCanaryScanRunInternal: unknown;
startFixtureRunInternal: unknown;
startStagingLiveRunInternal: unknown;
};
@@ -29,6 +33,19 @@ const GITHUB_OWNER_RESOLUTION_CONCURRENCY = 8;
const CONTROLLED_CANARY_FIXTURE_ID = "patrick-html-canary-v1";
const MAX_CONTROLLED_CANARY_FILES = 100;
export function parseSkillsShCatalogReference(value: string) {
const segments = value
.trim()
.split("/")
.map((segment) => segment.trim().toLowerCase());
if (segments.length !== 4 || segments[0] !== "skills-sh") return null;
const [owner, repo, slug] = segments.slice(1);
if (!owner || !repo || !slug || [owner, repo, slug].some((part) => part.includes(":"))) {
return null;
}
return { owner, repo, slug };
}
async function runMutationRef<T>(
ctx: ActionCtx,
ref: unknown,
@@ -67,6 +84,12 @@ function requireNumber(record: Record<string, unknown>, key: string) {
return value;
}
function requireBoolean(record: Record<string, unknown>, key: string) {
const value = record[key];
if (typeof value !== "boolean") throw new Error(`${key} is required`);
return value;
}
async function sha256Hex(bytes: Uint8Array) {
const digest = await crypto.subtle.digest("SHA-256", new Uint8Array(bytes).buffer);
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
@@ -423,6 +446,57 @@ export async function skillsShCatalogTestV1Handler(ctx: ActionCtx, request: Requ
);
return json({ ...result, sourceVerification: verification }, 200, rate.headers);
}
if (operation === "start-canary-scan") {
return json(
await runMutationRef(
ctx,
internalRefs.skillsShCatalog.startControlledCanaryScanRunInternal,
{
actor: auth.user.handle,
reason: requireString(body, "reason"),
},
),
200,
rate.headers,
);
}
if (operation === "set-publication") {
return json(
await runMutationRef(ctx, internalRefs.skillsShCatalog.setPublicationEnabledInternal, {
enabled: requireBoolean(body, "enabled"),
actor: auth.user.handle,
reason: requireString(body, "reason"),
confirm: requireString(body, "confirm"),
}),
200,
rate.headers,
);
}
if (operation === "set-pause") {
return json(
await runMutationRef(ctx, internalRefs.skillsShCatalog.setCatalogPausedInternal, {
paused: requireBoolean(body, "paused"),
actor: auth.user.handle,
reason: requireString(body, "reason"),
confirm: requireString(body, "confirm"),
}),
200,
rate.headers,
);
}
if (operation === "rollback-publication") {
return json(
await runMutationRef(ctx, internalRefs.skillsShCatalog.rollbackPublicationInternal, {
externalId: requireString(body, "externalId"),
attemptId: requireString(body, "attemptId"),
actor: auth.user.handle,
reason: requireString(body, "reason"),
confirm: requireString(body, "confirm"),
}),
200,
rate.headers,
);
}
if (operation === "process-fixture") {
return json(
await runMutationRef(ctx, internalRefs.skillsShCatalog.processFixtureBatchInternal, {
@@ -521,3 +595,33 @@ export async function skillsShCatalogTestV1Handler(ctx: ActionCtx, request: Requ
return text(unavailable ? "Not found" : message, unavailable ? 404 : 400, rate.headers);
}
}
export async function skillsShCatalogPublicV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, "read");
if (!rate.ok) return rate.response;
if (request.method !== "GET") return text("Not found", 404, rate.headers);
const prefix = "/api/v1/skills-sh/";
const pathname = new URL(request.url).pathname;
if (!pathname.startsWith(prefix)) return text("Not found", 404, rate.headers);
let segments: string[];
try {
segments = pathname
.slice(prefix.length)
.split("/")
.filter(Boolean)
.map((segment) => decodeURIComponent(segment).trim().toLowerCase());
} catch {
return text("Not found", 404, rate.headers);
}
const install = segments.at(-1) === "install";
if ((install && segments.length !== 4) || (!install && segments.length !== 3)) {
return text("Not found", 404, rate.headers);
}
const [owner, repo, slug] = segments;
if (!owner || !repo || !slug || [owner, repo, slug].some((part) => part.includes(":"))) {
return text("Not found", 404, rate.headers);
}
const entry = await ctx.runQuery(api.skillsShCatalog.getPublicEntry, { owner, repo, slug });
if (!entry) return text("Skill not found", 404, rate.headers);
return json(install ? entry.install : entry, 200, rate.headers);
}
+77
View File
@@ -81,6 +81,7 @@ import {
text,
toOptionalNumber,
} from "./shared";
import { parseSkillsShCatalogReference } from "./skillsShCatalogV1";
const MAX_EXPORT_FILE_COUNT = 10_000;
const MAX_EXPORT_PAGE_LIMIT = 250;
@@ -1828,6 +1829,16 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request)
if (second === "install" && segments.length === 2) {
const installUrl = new URL(request.url);
if (installUrl.searchParams.has("reference")) {
const reference = installUrl.searchParams.get("reference") ?? "";
const catalogRef = parseSkillsShCatalogReference(reference);
if (!catalogRef || catalogRef.slug !== slug) {
return text("Invalid skills.sh reference", 400, rate.headers);
}
const entry = await ctx.runQuery(api.skillsShCatalog.getPublicEntry, catalogRef);
if (!entry) return text("Skill not found", 404, rate.headers);
return json(entry.install, 200, rate.headers);
}
const forceInstall = parseBooleanQueryParam(installUrl.searchParams.get("forceInstall"));
const skill = (await runQueryRef<
| (InstallResolverSkill & {
@@ -2269,6 +2280,72 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request)
const versionParam = verifyUrl.searchParams.get("version")?.trim();
const tagParam = verifyUrl.searchParams.get("tag")?.trim();
if (versionParam && tagParam) return text("Use either version or tag", 400, rate.headers);
if (verifyUrl.searchParams.has("reference")) {
const reference = verifyUrl.searchParams.get("reference") ?? "";
const catalogRef = parseSkillsShCatalogReference(reference);
if (!catalogRef || catalogRef.slug !== slug || versionParam || tagParam) {
return text("Invalid skills.sh reference", 400, rate.headers);
}
const entry = await ctx.runQuery(api.skillsShCatalog.getPublicEntry, catalogRef);
if (!entry?.artifact) return text("Skill not found", 404, rate.headers);
const securityPassed = entry.security.verdict === "clean";
const reasons = securityPassed ? [] : ["security.status_not_clean"];
return json(
{
schema: "clawhub.skill.verify.v1",
ok: securityPassed,
decision: securityPassed ? "pass" : "fail",
reasons,
slug: entry.ref,
displayName: entry.displayName,
pageUrl: `${publicApiOrigin(request)}${entry.route}`,
publisherHandle: null,
publisherDisplayName: null,
publisherProfileUrl: null,
version: entry.githubCommit,
resolvedFrom: "latest",
tag: null,
createdAt: entry.security.scannedAt,
card: {
available: false,
path: "skill-card.md",
url: null,
sha256: null,
size: null,
contentType: null,
},
artifact: {
sourceFingerprint: entry.githubContentHash,
bundleFingerprints: [entry.artifact.contentHash],
files: entry.artifact.files,
},
provenance: {
source: "skills-sh-catalog",
reference: entry.ref,
repository: entry.repository,
path: entry.githubPath,
commit: entry.githubCommit,
contentHash: entry.githubContentHash,
scanAttemptId: entry.security.attemptId,
artifactContentHash: entry.artifact.contentHash,
},
security: {
status: entry.security.verdict,
passed: securityPassed,
rawStatus: entry.security.verdict,
verdict: entry.security.verdict,
source: entry.security.source,
attemptId: entry.security.attemptId,
checkedAt: entry.security.scannedAt,
},
signature: {
status: "unsigned",
},
},
200,
rate.headers,
);
}
const skillResult = (await runQueryRef<GetBySlugResult>(
ctx,
@@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import {
buildSkillsShCatalogInstallResolution,
shouldPublishSkillsShCatalogEntry,
} from "./skillsShCatalogPublication";
const entry = {
externalId: "patrick-erichsen/skills/html",
githubOwnerId: 20_157_849,
owner: "patrick-erichsen",
repo: "skills",
slug: "html",
githubPath: "skills/html",
githubCommit: "050daba89f6b6636470add5cb300aac46a412cf8",
githubContentHash: "a47adb2c1ac33c088f664b5187971b63d2b958a7b9f01516d26005ca941a108f",
sourceContentHash: "source-hash",
};
const attempt = {
externalId: entry.externalId,
githubOwnerId: entry.githubOwnerId,
owner: entry.owner,
repo: entry.repo,
slug: entry.slug,
githubPath: entry.githubPath,
githubCommit: entry.githubCommit,
githubContentHash: entry.githubContentHash,
sourceContentHash: entry.sourceContentHash,
dispatchKind: "real" as const,
source: "skills-sh-catalog-test" as const,
};
const control = {
mode: "staging-live" as const,
paused: false,
publicVisibilityEnabled: true,
realScanAllowlist: [entry.externalId],
};
describe("skills.sh catalog publication", () => {
it.each(["clean", "suspicious"] as const)(
"publishes an exact %s result while visibility is enabled",
(verdict) => {
expect(shouldPublishSkillsShCatalogEntry({ control, entry, attempt, verdict })).toBe(true);
},
);
it("rejects a stale callback whose commit no longer matches the entry", () => {
expect(
shouldPublishSkillsShCatalogEntry({
control,
entry: { ...entry, githubCommit: "1".repeat(40) },
attempt,
verdict: "clean",
}),
).toBe(false);
});
it.each(["malicious", "failed"] as const)("never publishes a %s result", (verdict) => {
expect(shouldPublishSkillsShCatalogEntry({ control, entry, attempt, verdict })).toBe(false);
});
it("rejects a deterministic fixture attempt", () => {
expect(
shouldPublishSkillsShCatalogEntry({
control,
entry,
attempt: {
...attempt,
dispatchKind: "deterministic",
source: "skills-sh-catalog-fixture",
},
verdict: "clean",
}),
).toBe(false);
});
it("rejects an exact attempt removed from the active allowlist", () => {
expect(
shouldPublishSkillsShCatalogEntry({
control: { ...control, realScanAllowlist: [] },
entry,
attempt,
verdict: "clean",
}),
).toBe(false);
});
it("returns the approved commit-pinned GitHub descriptor with slash identity", () => {
expect(buildSkillsShCatalogInstallResolution(entry)).toEqual({
ok: true,
slug: "skills-sh/patrick-erichsen/skills/html",
installKind: "github",
github: {
repo: "patrick-erichsen/skills",
path: "skills/html",
commit: "050daba89f6b6636470add5cb300aac46a412cf8",
contentHash: "a47adb2c1ac33c088f664b5187971b63d2b958a7b9f01516d26005ca941a108f",
sourceUrl:
"https://github.com/patrick-erichsen/skills/tree/050daba89f6b6636470add5cb300aac46a412cf8/skills/html",
},
});
});
});
+77
View File
@@ -0,0 +1,77 @@
export type SkillsShCatalogPublicationControl = {
mode: "off" | "fixture" | "staging-live";
paused: boolean;
publicVisibilityEnabled: boolean;
realScanAllowlist: string[];
};
export type SkillsShCatalogIdentity = {
externalId: string;
githubOwnerId: number;
owner: string;
repo: string;
slug: string;
githubPath?: string;
githubCommit?: string;
githubContentHash?: string;
sourceContentHash: string;
};
export type SkillsShCatalogVerdict = "clean" | "suspicious" | "malicious" | "failed";
export type SkillsShCatalogPublicationAttempt = SkillsShCatalogIdentity & {
dispatchKind: "deterministic" | "real";
source: "skills-sh-catalog-fixture" | "skills-sh-catalog-test";
};
export function isExactSkillsShCatalogAttempt(
entry: SkillsShCatalogIdentity,
attempt: SkillsShCatalogIdentity,
) {
return (
attempt.externalId === entry.externalId &&
attempt.githubOwnerId === entry.githubOwnerId &&
attempt.owner === entry.owner &&
attempt.repo === entry.repo &&
attempt.slug === entry.slug &&
attempt.githubPath === entry.githubPath &&
attempt.githubCommit === entry.githubCommit &&
attempt.githubContentHash === entry.githubContentHash &&
attempt.sourceContentHash === entry.sourceContentHash
);
}
export function shouldPublishSkillsShCatalogEntry(args: {
control: SkillsShCatalogPublicationControl | null;
entry: SkillsShCatalogIdentity;
attempt: SkillsShCatalogPublicationAttempt;
verdict: SkillsShCatalogVerdict;
}) {
return (
args.control?.mode === "staging-live" &&
!args.control.paused &&
args.control.publicVisibilityEnabled &&
args.control.realScanAllowlist.includes(args.attempt.externalId) &&
args.attempt.dispatchKind === "real" &&
args.attempt.source === "skills-sh-catalog-test" &&
(args.verdict === "clean" || args.verdict === "suspicious") &&
isExactSkillsShCatalogAttempt(args.entry, args.attempt)
);
}
export function buildSkillsShCatalogInstallResolution(entry: SkillsShCatalogIdentity) {
if (!entry.githubPath || !entry.githubCommit || !entry.githubContentHash) return null;
const repo = `${entry.owner}/${entry.repo}`;
return {
ok: true as const,
slug: `skills-sh/${entry.externalId}`,
installKind: "github" as const,
github: {
repo,
path: entry.githubPath,
commit: entry.githubCommit,
contentHash: entry.githubContentHash,
sourceUrl: `https://github.com/${repo}/tree/${entry.githubCommit}/${entry.githubPath}`,
},
};
}
+9
View File
@@ -2899,6 +2899,7 @@ const skillsShCatalogEntries = defineTable({
}),
),
publicVisible: v.boolean(),
publishedScanAttemptId: v.optional(v.id("skillsShCatalogScanAttempts")),
scanStatus: v.union(
v.literal("not-planned"),
v.literal("planned"),
@@ -2923,6 +2924,13 @@ const skillsShCatalogScanAttempts = defineTable({
entryId: v.id("skillsShCatalogEntries"),
runId: v.id("skillsShCatalogRuns"),
externalId: v.string(),
githubOwnerId: v.optional(v.number()),
owner: v.optional(v.string()),
repo: v.optional(v.string()),
slug: v.optional(v.string()),
githubPath: v.optional(v.string()),
githubCommit: v.optional(v.string()),
githubContentHash: v.optional(v.string()),
sourceContentHash: v.string(),
artifactContentHash: v.optional(v.string()),
skillScanRequestId: v.optional(v.id("skillScanRequests")),
@@ -2945,6 +2953,7 @@ const skillsShCatalogScanAttempts = defineTable({
v.literal("failed"),
),
),
publicationRolledBackAt: v.optional(v.number()),
completedAt: v.optional(v.number()),
createdAt: v.number(),
updatedAt: v.number(),
+46 -5
View File
@@ -22,6 +22,10 @@ import {
serializedSkillScanRequestFilesBytes,
} from "./lib/skillScanRequestFiles";
import { getSkillsShFixtureEnvironmentPolicy } from "./lib/skillsShCatalogEnvironment";
import {
isExactSkillsShCatalogAttempt,
shouldPublishSkillsShCatalogEntry,
} from "./lib/skillsShCatalogPublication";
import { redactWorkerPublicText } from "./lib/workerTextRedaction";
import { requestSecurityScanDispatch } from "./securityScanDispatch";
@@ -1994,7 +1998,12 @@ export const completeCatalogSkillScanJobInternal = internalMutation({
(request.lastError === "Catalog scan analysis failed" &&
job.lastError === "Catalog scan analysis failed"))
) {
return { ok: true as const, applied: true as const, publicVisible: false as const };
const terminalEntry = await ctx.db.get(attempt.entryId);
return {
ok: true as const,
applied: true as const,
publicVisible: terminalEntry?.publicVisible === true,
};
}
if (
attempt.status === "canceled" &&
@@ -2019,9 +2028,13 @@ export const completeCatalogSkillScanJobInternal = internalMutation({
throw new ConvexError("Catalog scan job lease mismatch");
}
const [run, entry] = await Promise.all([
const [run, entry, control] = await Promise.all([
ctx.db.get(attempt.runId),
ctx.db.get(attempt.entryId),
ctx.db
.query("skillsShCatalogControls")
.withIndex("by_key", (q) => q.eq("key", "global"))
.unique(),
]);
const now = Date.now();
const terminalizeWithoutResult = async (reason: "run-canceled" | "stale-attempt") => {
@@ -2091,11 +2104,38 @@ export const completeCatalogSkillScanJobInternal = internalMutation({
if (run?.status === "canceling" || run?.status === "canceled") {
return await terminalizeWithoutResult("run-canceled");
}
if (!entry || entry.sourceContentHash !== attempt.sourceContentHash) {
const attemptIdentity =
attempt.githubOwnerId !== undefined &&
attempt.owner !== undefined &&
attempt.repo !== undefined &&
attempt.slug !== undefined
? {
externalId: attempt.externalId,
githubOwnerId: attempt.githubOwnerId,
owner: attempt.owner,
repo: attempt.repo,
slug: attempt.slug,
githubPath: attempt.githubPath,
githubCommit: attempt.githubCommit,
githubContentHash: attempt.githubContentHash,
sourceContentHash: attempt.sourceContentHash,
dispatchKind: attempt.dispatchKind,
source: attempt.source,
}
: null;
if (!entry || !attemptIdentity || !isExactSkillsShCatalogAttempt(entry, attemptIdentity)) {
return await terminalizeWithoutResult("stale-attempt");
}
const scanFailed = args.verdict === "failed";
const publicVisible =
attempt.publicationRolledBackAt === undefined &&
shouldPublishSkillsShCatalogEntry({
control,
entry,
attempt: attemptIdentity,
verdict: args.verdict,
});
await ctx.db.patch(attempt._id, {
status: scanFailed ? "failed" : "succeeded",
verdict: args.verdict,
@@ -2104,7 +2144,8 @@ export const completeCatalogSkillScanJobInternal = internalMutation({
});
await ctx.db.patch(entry._id, {
scanStatus: args.verdict,
publicVisible: false,
publicVisible,
publishedScanAttemptId: publicVisible ? attempt._id : undefined,
updatedAt: now,
});
await ctx.db.patch(request._id, {
@@ -2142,7 +2183,7 @@ export const completeCatalogSkillScanJobInternal = internalMutation({
updatedAt: now,
});
}
return { ok: true as const, applied: true as const, publicVisible: false as const };
return { ok: true as const, applied: true as const, publicVisible };
},
});
+80 -30
View File
@@ -103,6 +103,28 @@ async function storeTestArtifactFiles(
};
}
async function storeAuthenticatedTestArtifact<
T extends { externalId: string; githubContentHash?: string },
>(t: CatalogTest, row: T, content: string, path = "SKILL.md") {
return await storeAuthenticatedTestArtifactFiles(t, row, [{ path, content }]);
}
async function storeAuthenticatedTestArtifactFiles<
T extends { externalId: string; githubContentHash?: string },
>(t: CatalogTest, row: T, inputs: Array<{ path: string; content: string }>) {
const artifact = await storeTestArtifactFiles(t, row.externalId, inputs);
const manifest = artifact.files
.map((file) => `${file.path}\0${file.size}\0${file.sha256.toLowerCase()}`)
.join("\n");
return {
artifact,
row: {
...row,
githubContentHash: await sha256Hex(manifest),
},
};
}
async function processToTerminal(
t: CatalogTest,
runId: Id<"skillsShCatalogRuns">,
@@ -590,9 +612,14 @@ describe("skills.sh catalog overload control plane", () => {
it("replans unchanged staging content after its prior attempt was canceled", async () => {
useEnvironment(TEST_ENV);
const t = convexTest(schema, modules);
const row = frozenSnapshot.rows.find(
const sourceRow = frozenSnapshot.rows.find(
(candidate) => candidate.externalId === "nvidia/skills/aiq-deploy",
)!;
const { artifact, row } = await storeAuthenticatedTestArtifact(
t,
sourceRow,
"staging retry artifact",
);
const actorUserId = await t.run(async (ctx) => {
return await ctx.db.insert("users", {
handle: "catalog-retry-operator",
@@ -600,7 +627,6 @@ describe("skills.sh catalog overload control plane", () => {
role: "admin",
});
});
const artifact = await storeTestArtifact(t, row.externalId, "staging retry artifact");
await t.mutation(internal.skillsShCatalog.configureFixtureControlInternal, {
...BASE_CONTROL,
mode: "staging-live",
@@ -1656,7 +1682,17 @@ describe("skills.sh catalog overload control plane", () => {
realScanAllowlist: ["nvidia/skills/aiq-deploy"],
});
const rows = frozenSnapshot.rows.map((row) => ({ ...row }));
const sourceRow = frozenSnapshot.rows.find(
(row) => row.externalId === "nvidia/skills/aiq-deploy",
)!;
const { artifact, row: authenticatedRow } = await storeAuthenticatedTestArtifact(
t,
sourceRow,
"hello catalog",
);
const rows = frozenSnapshot.rows.map((row) =>
row.externalId === authenticatedRow.externalId ? authenticatedRow : { ...row },
);
const { runId } = await t.mutation(internal.skillsShCatalog.startStagingLiveRunInternal, {
actor: "catalog-test-operator",
reason: "prove exact live Test batching",
@@ -1686,7 +1722,6 @@ describe("skills.sh catalog overload control plane", () => {
});
expect((await collectEntries(t)).every((entry) => !entry.publicVisible)).toBe(true);
const artifact = await storeTestArtifact(t, "nvidia/skills/aiq-deploy", "hello catalog");
const admitted = await t.action(internal.skillsShCatalog.admitRealScansInternal, {
runId,
externalIds: ["nvidia/skills/aiq-deploy"],
@@ -1804,6 +1839,14 @@ describe("skills.sh catalog overload control plane", () => {
maxCatalogInFlight: 1,
realScanAllowlist: ["nvidia/skills/aiq-deploy"],
});
const sourceRow = frozenSnapshot.rows.find(
(row) => row.externalId === "nvidia/skills/aiq-deploy",
)!;
const { artifact, row } = await storeAuthenticatedTestArtifact(
t,
sourceRow,
"active expiry artifact",
);
const { runId } = await t.mutation(internal.skillsShCatalog.startStagingLiveRunInternal, {
actor: "catalog-expiry-operator",
reason: "prove active request expiry is deferred",
@@ -1815,13 +1858,8 @@ describe("skills.sh catalog overload control plane", () => {
await t.mutation(internal.skillsShCatalog.processStagingLiveBatchInternal, {
runId,
cursor: 0,
rows: [frozenSnapshot.rows.find((row) => row.externalId === "nvidia/skills/aiq-deploy")!],
rows: [row],
});
const artifact = await storeTestArtifact(
t,
"nvidia/skills/aiq-deploy",
"active expiry artifact",
);
await t.action(internal.skillsShCatalog.admitRealScansInternal, {
runId,
externalIds: ["nvidia/skills/aiq-deploy"],
@@ -1927,6 +1965,14 @@ describe("skills.sh catalog overload control plane", () => {
maxCatalogInFlight: 1,
realScanAllowlist: ["nvidia/skills/aiq-deploy"],
});
const sourceRow = frozenSnapshot.rows.find(
(row) => row.externalId === "nvidia/skills/aiq-deploy",
)!;
const { artifact, row } = await storeAuthenticatedTestArtifact(
t,
sourceRow,
"running cancellation artifact",
);
const { runId } = await t.mutation(internal.skillsShCatalog.startStagingLiveRunInternal, {
actor: "catalog-cancel-operator",
reason: "defer running real cancellation",
@@ -1938,13 +1984,8 @@ describe("skills.sh catalog overload control plane", () => {
await t.mutation(internal.skillsShCatalog.processStagingLiveBatchInternal, {
runId,
cursor: 0,
rows: [frozenSnapshot.rows.find((row) => row.externalId === "nvidia/skills/aiq-deploy")!],
rows: [row],
});
const artifact = await storeTestArtifact(
t,
"nvidia/skills/aiq-deploy",
"running cancellation artifact",
);
await t.action(internal.skillsShCatalog.admitRealScansInternal, {
runId,
externalIds: ["nvidia/skills/aiq-deploy"],
@@ -2017,6 +2058,14 @@ describe("skills.sh catalog overload control plane", () => {
maxCatalogInFlight: 1,
realScanAllowlist: ["nvidia/skills/aiq-deploy"],
});
const sourceRow = frozenSnapshot.rows.find(
(row) => row.externalId === "nvidia/skills/aiq-deploy",
)!;
const { artifact, row } = await storeAuthenticatedTestArtifact(
t,
sourceRow,
"expired running cancellation artifact",
);
const { runId } = await t.mutation(internal.skillsShCatalog.startStagingLiveRunInternal, {
actor: "catalog-expired-cancel-operator",
reason: "terminalize expired running real cancellation",
@@ -2028,13 +2077,8 @@ describe("skills.sh catalog overload control plane", () => {
await t.mutation(internal.skillsShCatalog.processStagingLiveBatchInternal, {
runId,
cursor: 0,
rows: [frozenSnapshot.rows.find((row) => row.externalId === "nvidia/skills/aiq-deploy")!],
rows: [row],
});
const artifact = await storeTestArtifact(
t,
"nvidia/skills/aiq-deploy",
"expired running cancellation artifact",
);
await t.action(internal.skillsShCatalog.admitRealScansInternal, {
runId,
externalIds: ["nvidia/skills/aiq-deploy"],
@@ -2290,6 +2334,10 @@ describe("skills.sh catalog overload control plane", () => {
maxCatalogInFlight: 1,
realScanAllowlist: ["nvidia/skills/aiq-deploy"],
});
const sourceRow = frozenSnapshot.rows.find(
(row) => row.externalId === "nvidia/skills/aiq-deploy",
)!;
const { artifact, row } = await storeAuthenticatedTestArtifact(t, sourceRow, "budget artifact");
const { runId } = await t.mutation(internal.skillsShCatalog.startStagingLiveRunInternal, {
actor: "catalog-budget-operator",
reason: "prove admission write reservation",
@@ -2301,9 +2349,8 @@ describe("skills.sh catalog overload control plane", () => {
await t.mutation(internal.skillsShCatalog.processStagingLiveBatchInternal, {
runId,
cursor: 0,
rows: [frozenSnapshot.rows.find((row) => row.externalId === "nvidia/skills/aiq-deploy")!],
rows: [row],
});
const artifact = await storeTestArtifact(t, "nvidia/skills/aiq-deploy", "budget artifact");
await expect(
t.action(internal.skillsShCatalog.admitRealScansInternal, {
@@ -2342,6 +2389,13 @@ describe("skills.sh catalog overload control plane", () => {
maxCatalogInFlight: 1,
realScanAllowlist: ["nvidia/skills/aiq-deploy"],
});
const sourceRow = frozenSnapshot.rows.find(
(row) => row.externalId === "nvidia/skills/aiq-deploy",
)!;
const { artifact, row } = await storeAuthenticatedTestArtifactFiles(t, sourceRow, [
{ path: "SKILL.md", content: "six write artifact" },
{ path: "references/context.md", content: "second embedded artifact file" },
]);
const { runId } = await t.mutation(internal.skillsShCatalog.startStagingLiveRunInternal, {
actor: "catalog-six-write-operator",
reason: "prove exact admission write reservation",
@@ -2353,12 +2407,8 @@ describe("skills.sh catalog overload control plane", () => {
await t.mutation(internal.skillsShCatalog.processStagingLiveBatchInternal, {
runId,
cursor: 0,
rows: [frozenSnapshot.rows.find((row) => row.externalId === "nvidia/skills/aiq-deploy")!],
rows: [row],
});
const artifact = await storeTestArtifactFiles(t, "nvidia/skills/aiq-deploy", [
{ path: "SKILL.md", content: "six write artifact" },
{ path: "references/context.md", content: "second embedded artifact file" },
]);
const result = await t.action(internal.skillsShCatalog.admitRealScansInternal, {
runId,
@@ -2432,7 +2482,7 @@ describe("skills.sh catalog overload control plane", () => {
{
runId: unchanged.runId,
cursor: 0,
rows: [frozenSnapshot.rows.find((row) => row.externalId === "nvidia/skills/aiq-deploy")!],
rows: [row],
},
);
expect(unchangedRun.counts).toMatchObject({
+534 -7
View File
@@ -3,7 +3,7 @@ import { ConvexError, type Infer, v } from "convex/values";
import { internal } from "./_generated/api";
import type { Doc, Id } from "./_generated/dataModel";
import type { ActionCtx, MutationCtx, QueryCtx } from "./_generated/server";
import { internalAction, internalMutation, internalQuery } from "./functions";
import { internalAction, internalMutation, internalQuery, query } from "./functions";
import {
assertSkillsShCatalogControlMutationAllowed,
assertSkillsShFixtureEnvironmentAllowed,
@@ -13,6 +13,11 @@ import {
getSkillsShCatalogFixture,
type SkillsShCatalogFixtureRow,
} from "./lib/skillsShCatalogFixtures";
import {
buildSkillsShCatalogInstallResolution,
isExactSkillsShCatalogAttempt,
shouldPublishSkillsShCatalogEntry,
} from "./lib/skillsShCatalogPublication";
import { validateFilePath } from "./lib/skillZip";
import { enqueueSkillsShCatalogScanRequest } from "./securityScan";
@@ -20,6 +25,9 @@ const CONTROL_KEY = "global";
const ENABLE_FIXTURE_CONFIRM = "enable-skills-sh-fixture-control";
const DISABLE_CATALOG_CONFIRM = "disable-skills-sh-catalog";
const ROLLBACK_CONTROLLED_CANARY_CONFIRM = "rollback-skills-sh-controlled-canary";
const SET_PUBLICATION_CONFIRM = "set-skills-sh-test-publication";
const SET_CATALOG_PAUSE_CONFIRM = "set-skills-sh-test-pause";
const ROLLBACK_PUBLICATION_CONFIRM = "rollback-skills-sh-test-publication";
const CONTROLLED_CANARY_FIXTURE_ID = "patrick-html-canary-v1";
const STATUS_LIMIT = 50;
const MAX_DISCOVERY_ROWS = 20_000;
@@ -309,6 +317,7 @@ export const configureFixtureControlInternal = internalMutation({
writesEnabled: v.boolean(),
scanPlanningEnabled: v.boolean(),
scanAdmissionEnabled: v.boolean(),
publicVisibilityEnabled: v.optional(v.boolean()),
maxEntriesPerRun: v.number(),
maxEntriesPerBatch: v.number(),
maxWritesPerBatch: v.number(),
@@ -393,6 +402,17 @@ export const configureFixtureControlInternal = internalMutation({
) {
throw new ConvexError("skills.sh Test controls are capped at 500 discoveries and 10 scans");
}
if (
args.publicVisibilityEnabled &&
(mode !== "staging-live" ||
!args.scanAdmissionEnabled ||
realScanAllowlist.length < 1 ||
realScanAllowlist.length > 3)
) {
throw new ConvexError(
"skills.sh Test publication requires staging-live admission and an allowlist of 1-3 entries",
);
}
const now = Date.now();
const existing = await getControlDoc(ctx);
@@ -402,7 +422,7 @@ export const configureFixtureControlInternal = internalMutation({
writesEnabled: args.writesEnabled,
scanPlanningEnabled: args.scanPlanningEnabled,
scanAdmissionEnabled: args.scanAdmissionEnabled,
publicVisibilityEnabled: false,
publicVisibilityEnabled: args.publicVisibilityEnabled ?? false,
paused: false,
maxEntriesPerRun: args.maxEntriesPerRun,
maxEntriesPerBatch: args.maxEntriesPerBatch,
@@ -470,6 +490,244 @@ export const disableCatalogInternal = internalMutation({
},
});
export const setPublicationEnabledInternal = internalMutation({
args: {
enabled: v.boolean(),
actor: v.string(),
reason: v.string(),
confirm: v.string(),
},
handler: async (ctx, args) => {
const environment = assertSkillsShFixtureEnvironmentAllowed();
if (environment.environment !== "test") {
throw new ConvexError(
"skills.sh publication controls require the permanent Test environment",
);
}
if (args.confirm !== SET_PUBLICATION_CONFIRM) {
throw new ConvexError(`Pass confirm="${SET_PUBLICATION_CONFIRM}" to change publication.`);
}
const control = await getControlDoc(ctx);
if (!control) throw new ConvexError("skills.sh catalog controls are not configured");
if (
args.enabled &&
(control.mode !== "staging-live" ||
control.paused ||
!control.scanAdmissionEnabled ||
control.realScanAllowlist.length < 1 ||
control.realScanAllowlist.length > 3)
) {
throw new ConvexError(
"skills.sh publication requires active staging-live controls and an allowlist of 1-3 entries",
);
}
const now = Date.now();
await ctx.db.patch(control._id, {
publicVisibilityEnabled: args.enabled,
updatedBy: args.actor.trim(),
reason: args.reason.trim(),
updatedAt: now,
});
return { enabled: args.enabled, updatedAt: now };
},
});
export const setCatalogPausedInternal = internalMutation({
args: {
paused: v.boolean(),
actor: v.string(),
reason: v.string(),
confirm: v.string(),
},
handler: async (ctx, args) => {
const environment = assertSkillsShFixtureEnvironmentAllowed();
if (environment.environment !== "test") {
throw new ConvexError("skills.sh pause controls require the permanent Test environment");
}
if (args.confirm !== SET_CATALOG_PAUSE_CONFIRM) {
throw new ConvexError(`Pass confirm="${SET_CATALOG_PAUSE_CONFIRM}" to change pause state.`);
}
const control = await getControlDoc(ctx);
if (!control) throw new ConvexError("skills.sh catalog controls are not configured");
if (
!args.paused &&
(control.mode !== "staging-live" ||
!control.scanAdmissionEnabled ||
control.realScanAllowlist.length < 1 ||
control.realScanAllowlist.length > 3)
) {
throw new ConvexError(
"skills.sh resume requires staging-live admission and an allowlist of 1-3 entries",
);
}
const now = Date.now();
await ctx.db.patch(control._id, {
paused: args.paused,
updatedBy: args.actor.trim(),
reason: args.reason.trim(),
updatedAt: now,
});
return { paused: args.paused, updatedAt: now };
},
});
export const startControlledCanaryScanRunInternal = internalMutation({
args: {
actor: v.string(),
reason: v.string(),
},
handler: async (ctx, args) => {
const environment = assertSkillsShFixtureEnvironmentAllowed();
const control = assertScanAdmissionEnabled(await getControlDoc(ctx));
if (
environment.environment !== "test" ||
control.mode !== "staging-live" ||
control.realScanAllowlist.length !== 1
) {
throw new ConvexError("controlled canary scan requires one-entry permanent Test controls");
}
const fixture = getSkillsShCatalogFixture(CONTROLLED_CANARY_FIXTURE_ID);
const expected = normalizeIdentity(fixture.rowAt(0));
if (control.realScanAllowlist[0] !== expected.externalId) {
throw new ConvexError(
"controlled canary scan allowlist does not match the committed fixture",
);
}
const entry = await ctx.db
.query("skillsShCatalogEntries")
.withIndex("by_external_id", (q) => q.eq("externalId", expected.externalId))
.unique();
if (
!entry ||
entry.githubOwnerId !== expected.githubOwnerId ||
entry.githubPath !== expected.githubPath ||
entry.githubCommit !== expected.githubCommit ||
entry.githubContentHash !== expected.githubContentHash ||
entry.sourceContentHash !== expected.sourceContentHash
) {
throw new ConvexError("controlled canary row does not match the committed fixture");
}
const existingAttempt = await ctx.db
.query("skillsShCatalogScanAttempts")
.withIndex("by_entry_and_source_content_hash", (q) =>
q.eq("entryId", entry._id).eq("sourceContentHash", entry.sourceContentHash),
)
.filter((q) =>
q.and(
q.eq(q.field("dispatchKind"), "real"),
q.eq(q.field("source"), "skills-sh-catalog-test"),
),
)
.order("desc")
.first();
const existingAttemptIsExact =
existingAttempt?.githubOwnerId !== undefined &&
existingAttempt.owner !== undefined &&
existingAttempt.repo !== undefined &&
existingAttempt.slug !== undefined &&
isExactSkillsShCatalogAttempt(entry, {
externalId: existingAttempt.externalId,
githubOwnerId: existingAttempt.githubOwnerId,
owner: existingAttempt.owner,
repo: existingAttempt.repo,
slug: existingAttempt.slug,
githubPath: existingAttempt.githubPath,
githubCommit: existingAttempt.githubCommit,
githubContentHash: existingAttempt.githubContentHash,
sourceContentHash: existingAttempt.sourceContentHash,
});
if (
existingAttemptIsExact &&
(existingAttempt.status === "queued" || existingAttempt.status === "running")
) {
throw new ConvexError("controlled canary scan attempt is already active");
}
if (
existingAttemptIsExact &&
existingAttempt.status === "succeeded" &&
(existingAttempt.verdict === "clean" || existingAttempt.verdict === "suspicious") &&
existingAttempt.publicationRolledBackAt === undefined
) {
const shouldPublish = shouldPublishSkillsShCatalogEntry({
control,
entry,
attempt: {
externalId: existingAttempt.externalId,
githubOwnerId: existingAttempt.githubOwnerId!,
owner: existingAttempt.owner!,
repo: existingAttempt.repo!,
slug: existingAttempt.slug!,
githubPath: existingAttempt.githubPath,
githubCommit: existingAttempt.githubCommit,
githubContentHash: existingAttempt.githubContentHash,
sourceContentHash: existingAttempt.sourceContentHash,
dispatchKind: existingAttempt.dispatchKind,
source: existingAttempt.source,
},
verdict: existingAttempt.verdict,
});
if (
shouldPublish &&
(!entry.publicVisible ||
entry.publishedScanAttemptId !== existingAttempt._id ||
entry.scanStatus !== existingAttempt.verdict)
) {
await ctx.db.patch(entry._id, {
scanStatus: existingAttempt.verdict,
publicVisible: true,
publishedScanAttemptId: existingAttempt._id,
updatedAt: Date.now(),
});
}
return {
runId: existingAttempt.runId,
externalId: expected.externalId,
reused: true as const,
};
}
const now = Date.now();
const runId = await ctx.db.insert("skillsShCatalogRuns", {
fixtureId: CONTROLLED_CANARY_FIXTURE_ID,
snapshotId: fixture.snapshotId,
sourceKind: fixture.sourceKind,
...(fixture.capturedAt ? { sourceCapturedAt: fixture.capturedAt } : {}),
snapshotCaptureFetches: fixture.snapshotCaptureFetches,
dryRun: false,
status: "completed",
cursor: 1,
scanCursor: 0,
fixtureLength: 1,
counts: { ...emptyCounts(), observed: 1, unchanged: 1, scansPlanned: 1 },
budgets: {
maxEntriesPerRun: control.maxEntriesPerRun,
maxEntriesPerBatch: control.maxEntriesPerBatch,
maxWritesPerBatch: control.maxWritesPerBatch,
maxPlannedScans: control.maxPlannedScans,
maxScanAdmissionsPerBatch: control.maxScanAdmissionsPerBatch,
maxScanAdmissionsPerRun: control.maxScanAdmissionsPerRun,
maxScanAdmissionsPerDay: control.maxScanAdmissionsPerDay,
},
operations: { functionCalls: 1, dbReads: 2, dbWrites: 2 },
actor: args.actor.trim(),
reason: args.reason.trim(),
batchesProcessed: 0,
scanAdmissionBatches: 0,
lastBatchWrites: 2,
lastBatchReads: 2,
startedAt: now,
completedAt: now,
updatedAt: now,
});
await ctx.db.patch(entry._id, {
scanStatus: "planned",
publicVisible: false,
publishedScanAttemptId: undefined,
updatedAt: now,
});
return { runId, externalId: expected.externalId, reused: false as const };
},
});
export const startFixtureRunInternal = internalMutation({
args: {
fixtureId: fixtureIdValidator,
@@ -1119,7 +1377,12 @@ async function admitScans(ctx: MutationCtx, args: AdmitScansArgs) {
if (control.mode !== "staging-live") {
throw new ConvexError("real skills.sh scan admission requires staging-live controls");
}
if (run.sourceKind !== "staging-live" || run.fixtureId !== "skills-sh-test-live-500") {
const controlledCanaryRun =
run.fixtureId === CONTROLLED_CANARY_FIXTURE_ID && run.fixtureLength === 1;
if (
!controlledCanaryRun &&
(run.sourceKind !== "staging-live" || run.fixtureId !== "skills-sh-test-live-500")
) {
throw new ConvexError("real skills.sh scan admission requires a staging-live run");
}
if (environment.environment !== "test") {
@@ -1206,11 +1469,44 @@ async function admitScans(ctx: MutationCtx, args: AdmitScansArgs) {
.withIndex("by_entry_and_source_content_hash", (q) =>
q.eq("entryId", entry._id).eq("sourceContentHash", entry.sourceContentHash),
)
.filter((q) => q.neq(q.field("status"), "canceled"))
.filter((q) => {
const expectedSource =
args.dispatchKind === "real" ? "skills-sh-catalog-test" : "skills-sh-catalog-fixture";
return q.and(
q.neq(q.field("status"), "canceled"),
q.eq(q.field("dispatchKind"), args.dispatchKind),
q.eq(q.field("source"), expectedSource),
);
})
.order("desc")
.first();
readsUsed += 1;
if (existingAttempt) {
const existingAttemptIsExact =
existingAttempt?.githubOwnerId !== undefined &&
existingAttempt.owner !== undefined &&
existingAttempt.repo !== undefined &&
existingAttempt.slug !== undefined &&
isExactSkillsShCatalogAttempt(entry, {
externalId: existingAttempt.externalId,
githubOwnerId: existingAttempt.githubOwnerId,
owner: existingAttempt.owner,
repo: existingAttempt.repo,
slug: existingAttempt.slug,
githubPath: existingAttempt.githubPath,
githubCommit: existingAttempt.githubCommit,
githubContentHash: existingAttempt.githubContentHash,
sourceContentHash: existingAttempt.sourceContentHash,
});
const existingAttemptBlocksAdmission =
existingAttemptIsExact &&
(existingAttempt.status === "queued" ||
existingAttempt.status === "running" ||
(existingAttempt.status === "succeeded" &&
(existingAttempt.verdict === "clean" || existingAttempt.verdict === "suspicious") &&
existingAttempt.publicationRolledBackAt === undefined &&
entry.publicVisible &&
entry.publishedScanAttemptId === existingAttempt._id));
if (existingAttemptBlocksAdmission) {
skipped += 1;
continue;
}
@@ -1234,6 +1530,15 @@ async function admitScans(ctx: MutationCtx, args: AdmitScansArgs) {
`real Test scan admission requires a fetched artifact: ${externalId}`,
);
}
const authenticatedContentHash = await computeGitHubArtifactContentHash(artifact.files);
if (
!entry.githubContentHash ||
authenticatedContentHash !== entry.githubContentHash.toLowerCase()
) {
throw new ConvexError(
`real Test scan artifact does not match authenticated GitHub content: ${externalId}`,
);
}
}
// skillScanRequests embeds its validated file manifest in one document, so file count
// does not change the six real-admission writes before the final run patch.
@@ -1245,6 +1550,13 @@ async function admitScans(ctx: MutationCtx, args: AdmitScansArgs) {
entryId: entry._id,
runId: run._id,
externalId,
githubOwnerId: entry.githubOwnerId,
owner: entry.owner,
repo: entry.repo,
slug: entry.slug,
githubPath: entry.githubPath,
githubCommit: entry.githubCommit,
githubContentHash: entry.githubContentHash,
sourceContentHash: entry.sourceContentHash,
dispatchKind: args.dispatchKind,
artifactContentHash: artifact?.artifactContentHash.toLowerCase(),
@@ -1269,6 +1581,7 @@ async function admitScans(ctx: MutationCtx, args: AdmitScansArgs) {
await ctx.db.patch(entry._id, {
scanStatus: "queued",
publicVisible: false,
publishedScanAttemptId: undefined,
updatedAt: now,
});
writesUsed += 2;
@@ -2004,7 +2317,7 @@ export const getRunReconciliationInternal = internalQuery({
...entry,
resolution: {
externalRoute: `/skills-sh/${entry.externalId}`,
installRef: `skills-sh:${entry.externalId}`,
installRef: `skills-sh/${entry.externalId}`,
installable: false,
},
});
@@ -2201,7 +2514,7 @@ export const getStatusInternal = internalQuery({
...entry,
resolution: {
externalRoute: `/skills-sh/${entry.externalId}`,
installRef: `skills-sh:${entry.externalId}`,
installRef: `skills-sh/${entry.externalId}`,
installable: false,
},
})),
@@ -2215,6 +2528,198 @@ export const getStatusInternal = internalQuery({
},
});
export const getPublicEntry = query({
args: {
owner: v.string(),
repo: v.string(),
slug: v.string(),
},
handler: async (ctx, args) => {
const environment = getSkillsShFixtureEnvironmentPolicy();
if (!environment.allowed) return null;
const externalId = `${args.owner.trim().toLowerCase()}/${args.repo
.trim()
.toLowerCase()}/${args.slug.trim().toLowerCase()}`;
const [control, entry] = await Promise.all([
getControlDoc(ctx),
ctx.db
.query("skillsShCatalogEntries")
.withIndex("by_external_id", (q) => q.eq("externalId", externalId))
.unique(),
]);
if (
!control ||
control.mode !== "staging-live" ||
control.paused ||
!control.publicVisibilityEnabled ||
!entry?.publicVisible ||
(entry.scanStatus !== "clean" && entry.scanStatus !== "suspicious")
) {
return null;
}
const attempt = entry.publishedScanAttemptId
? await ctx.db.get(entry.publishedScanAttemptId)
: null;
if (
!attempt ||
attempt.status !== "succeeded" ||
attempt.publicationRolledBackAt !== undefined ||
attempt.verdict !== entry.scanStatus ||
!shouldPublishSkillsShCatalogEntry({
control,
entry,
attempt: {
externalId: attempt.externalId,
githubOwnerId: attempt.githubOwnerId ?? 0,
owner: attempt.owner ?? "",
repo: attempt.repo ?? "",
slug: attempt.slug ?? "",
githubPath: attempt.githubPath,
githubCommit: attempt.githubCommit,
githubContentHash: attempt.githubContentHash,
sourceContentHash: attempt.sourceContentHash,
dispatchKind: attempt.dispatchKind,
source: attempt.source,
},
verdict: attempt.verdict,
})
) {
return null;
}
const install = buildSkillsShCatalogInstallResolution(entry);
if (!install) return null;
const scanRequest = attempt.skillScanRequestId
? await ctx.db.get(attempt.skillScanRequestId)
: null;
const artifact =
attempt.artifactContentHash &&
scanRequest?.sourceKind === "skills-sh-catalog" &&
scanRequest.status === "succeeded" &&
scanRequest.skillsShCatalogAttemptId === attempt._id &&
scanRequest.securityScanJobId === attempt.securityScanJobId &&
scanRequest.sha256hash === attempt.artifactContentHash
? {
contentHash: attempt.artifactContentHash,
files: scanRequest.files.map(({ path, size, sha256, contentType }) => ({
path,
size,
sha256,
...(contentType ? { contentType } : {}),
})),
}
: null;
return {
ref: `skills-sh/${entry.externalId}`,
route: `/skills-sh/${entry.externalId}`,
displayName: entry.displayName,
summary:
entry.scanStatus === "suspicious"
? "GitHub-backed skill indexed by skills.sh and flagged as suspicious by ClawHub."
: "GitHub-backed skill indexed by skills.sh and verified by ClawHub.",
owner: {
handle: entry.owner,
githubUrl: `https://github.com/${entry.owner}`,
},
repository: `${entry.owner}/${entry.repo}`,
githubPath: entry.githubPath,
githubCommit: entry.githubCommit,
githubContentHash: entry.githubContentHash,
sourceUrl: entry.sourceUrl,
installs: entry.installs,
security: {
verdict: entry.scanStatus,
source: "clawhub" as const,
attemptId: attempt._id,
scannedAt: attempt.completedAt ?? attempt.updatedAt,
},
artifact,
install,
};
},
});
export const rollbackPublicationInternal = internalMutation({
args: {
externalId: v.string(),
attemptId: v.id("skillsShCatalogScanAttempts"),
actor: v.string(),
reason: v.string(),
confirm: v.string(),
},
handler: async (ctx, args) => {
const environment = assertSkillsShFixtureEnvironmentAllowed();
if (environment.environment !== "test") {
throw new ConvexError("skills.sh publication rollback requires permanent Test");
}
if (args.confirm !== ROLLBACK_PUBLICATION_CONFIRM) {
throw new ConvexError(
`Pass confirm="${ROLLBACK_PUBLICATION_CONFIRM}" to roll back publication.`,
);
}
const entry = await ctx.db
.query("skillsShCatalogEntries")
.withIndex("by_external_id", (q) => q.eq("externalId", args.externalId.trim().toLowerCase()))
.unique();
const attempt = await ctx.db.get(args.attemptId);
const attemptIdentity =
attempt?.githubOwnerId !== undefined &&
attempt.owner !== undefined &&
attempt.repo !== undefined &&
attempt.slug !== undefined
? {
externalId: attempt.externalId,
githubOwnerId: attempt.githubOwnerId,
owner: attempt.owner,
repo: attempt.repo,
slug: attempt.slug,
githubPath: attempt.githubPath,
githubCommit: attempt.githubCommit,
githubContentHash: attempt.githubContentHash,
sourceContentHash: attempt.sourceContentHash,
}
: null;
if (
!entry ||
!attempt ||
attempt.entryId !== entry._id ||
!attemptIdentity ||
!isExactSkillsShCatalogAttempt(entry, attemptIdentity) ||
attempt.status !== "succeeded" ||
attempt.dispatchKind !== "real" ||
attempt.source !== "skills-sh-catalog-test" ||
(attempt.verdict !== "clean" && attempt.verdict !== "suspicious")
) {
throw new ConvexError("skills.sh publication rollback identity mismatch");
}
if (attempt.publicationRolledBackAt !== undefined) {
return {
externalId: entry.externalId,
publicVisible: entry.publicVisible,
alreadyRolledBack: true,
actor: args.actor.trim(),
reason: args.reason.trim(),
};
}
if (entry.publishedScanAttemptId !== attempt._id) {
throw new ConvexError("skills.sh publication rollback attempt is not currently published");
}
const now = Date.now();
await ctx.db.patch(attempt._id, { publicationRolledBackAt: now, updatedAt: now });
await ctx.db.patch(entry._id, {
publicVisible: false,
publishedScanAttemptId: undefined,
updatedAt: now,
});
return {
externalId: entry.externalId,
publicVisible: false,
alreadyRolledBack: false,
actor: args.actor.trim(),
reason: args.reason.trim(),
};
},
});
function emptyCounts() {
return {
observed: 0,
@@ -2284,6 +2789,14 @@ async function sha256Hex(bytes: Uint8Array) {
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
async function computeGitHubArtifactContentHash(files: StagingLiveArtifact["files"]) {
const manifest = [...files]
.sort((left, right) => left.path.localeCompare(right.path))
.map((file) => `${file.path}\0${file.size}\0${file.sha256.toLowerCase()}`)
.join("\n");
return await sha256Hex(new TextEncoder().encode(manifest));
}
async function validateRealScanArtifacts(
ctx: ActionCtx,
externalIds: string[],
@@ -2349,6 +2862,13 @@ async function insertCatalogScanAttempt(
entryId: Id<"skillsShCatalogEntries">;
runId: Id<"skillsShCatalogRuns">;
externalId: string;
githubOwnerId: number;
owner: string;
repo: string;
slug: string;
githubPath?: string;
githubCommit?: string;
githubContentHash?: string;
sourceContentHash: string;
dispatchKind: "deterministic" | "real";
artifactContentHash?: string;
@@ -2359,6 +2879,13 @@ async function insertCatalogScanAttempt(
entryId: args.entryId,
runId: args.runId,
externalId: args.externalId,
githubOwnerId: args.githubOwnerId,
owner: args.owner,
repo: args.repo,
slug: args.slug,
...(args.githubPath ? { githubPath: args.githubPath } : {}),
...(args.githubCommit ? { githubCommit: args.githubCommit } : {}),
...(args.githubContentHash ? { githubContentHash: args.githubContentHash } : {}),
sourceContentHash: args.sourceContentHash,
...(args.artifactContentHash ? { artifactContentHash: args.artifactContentHash } : {}),
source: args.dispatchKind === "real" ? "skills-sh-catalog-test" : "skills-sh-catalog-fixture",
+697 -8
View File
@@ -2,8 +2,9 @@
/* @vitest-environment edge-runtime */
import { convexTest } from "convex-test";
import { afterEach, describe, expect, it, vi } from "vitest";
import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
import { api, internal } from "./_generated/api";
import type { Doc, Id } from "./_generated/dataModel";
import canarySkillMarkdown from "./fixtures/patrick-html-canary-SKILL.txt?raw";
import schema from "./schema";
const modules = import.meta.glob("./**/*.ts");
@@ -12,6 +13,13 @@ const LOCAL_ENV = {
CONVEX_CLOUD_URL: "http://127.0.0.1:3210",
};
const TEST_ENV = {
CLAWHUB_DEPLOYMENT_NAME: "academic-chihuahua-392",
CLAWHUB_DISABLE_CRONS: "1",
CLAWHUB_ENV: "test",
CONVEX_CLOUD_URL: "https://academic-chihuahua-392.convex.cloud",
};
const CANARY_EXTERNAL_ID = "patrick-erichsen/skills/html";
const CANARY_COMMIT = "050daba89f6b6636470add5cb300aac46a412cf8";
const CANARY_CONTENT_HASH = "a47adb2c1ac33c088f664b5187971b63d2b958a7b9f01516d26005ca941a108f";
@@ -49,8 +57,8 @@ const SOURCE_VERIFICATION = {
type CatalogTest = ReturnType<typeof convexTest>;
function useLocalEnvironment() {
for (const [name, value] of Object.entries(LOCAL_ENV)) vi.stubEnv(name, value);
function useEnvironment(env: Record<string, string>) {
for (const [name, value] of Object.entries(env)) vi.stubEnv(name, value);
}
async function configureCanary(t: CatalogTest) {
@@ -70,6 +78,118 @@ async function runCanary(t: CatalogTest) {
return { runId: started.runId, run };
}
async function sha256Hex(value: Blob | string) {
const bytes =
typeof value === "string"
? new TextEncoder().encode(value)
: new Uint8Array(await value.arrayBuffer());
const digest = await crypto.subtle.digest("SHA-256", bytes);
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
async function storeCanaryArtifact(t: CatalogTest, content = canarySkillMarkdown) {
const blob = new Blob([content], { type: "text/markdown" });
const storageId = await t.run(async (ctx) => await ctx.storage.store(blob));
const sha256 = await sha256Hex(blob);
return {
externalId: CANARY_EXTERNAL_ID,
artifactContentHash: await sha256Hex(`SKILL.md\0${sha256}\n`),
files: [
{
path: "SKILL.md",
size: blob.size,
storageId,
sha256,
contentType: "text/markdown",
},
],
};
}
async function prepareScannedCanary(t: CatalogTest) {
await configureCanary(t);
await runCanary(t);
await t.mutation(internal.skillsShCatalog.configureFixtureControlInternal, {
...CANARY_CONTROL,
mode: "staging-live",
scanAdmissionEnabled: true,
publicVisibilityEnabled: true,
maxWritesPerBatch: 7,
maxScanAdmissionsPerBatch: 1,
maxScanAdmissionsPerRun: 1,
maxScanAdmissionsPerDay: 1,
maxCatalogQueued: 1,
maxCatalogInFlight: 1,
realScanAllowlist: [CANARY_EXTERNAL_ID],
});
const actorUserId = await t.run(
async (ctx) =>
await ctx.db.insert("users", {
handle: "catalog-test-operator",
displayName: "Catalog Test Operator",
role: "admin",
}),
);
const { runId } = await t.mutation(
internal.skillsShCatalog.startControlledCanaryScanRunInternal,
{
actor: "catalog-test-operator",
reason: "scan one exact controlled canary",
},
);
const artifact = await storeCanaryArtifact(t);
await t.action(internal.skillsShCatalog.admitRealScansInternal, {
runId,
externalIds: [CANARY_EXTERNAL_ID],
actorUserId,
artifacts: [artifact],
});
const [attempt] = await t.run(async (ctx) =>
(await ctx.db.query("skillsShCatalogScanAttempts").collect()).filter(
(candidate) => candidate.runId === runId && candidate.status === "queued",
),
);
if (!attempt?.skillScanRequestId || !attempt.securityScanJobId || !attempt.artifactContentHash) {
throw new Error("controlled canary scan admission did not create linked work");
}
await t.run(async (ctx) => {
await ctx.db.patch(attempt._id, { status: "running", updatedAt: Date.now() });
await ctx.db.patch(attempt.skillScanRequestId!, {
status: "running",
updatedAt: Date.now(),
});
await ctx.db.patch(attempt.securityScanJobId!, {
status: "running",
leaseToken: "canary-lease",
leaseExpiresAt: Date.now() + 60_000,
workerId: "canary-worker",
updatedAt: Date.now(),
});
});
return attempt as Doc<"skillsShCatalogScanAttempts"> & {
skillScanRequestId: Id<"skillScanRequests">;
securityScanJobId: Id<"securityScanJobs">;
artifactContentHash: string;
};
}
async function completeScannedCanary(
t: CatalogTest,
attempt: Awaited<ReturnType<typeof prepareScannedCanary>>,
verdict: "clean" | "suspicious" | "malicious" | "failed",
) {
return await t.mutation(internal.securityScan.completeCatalogSkillScanJobInternal, {
attemptId: attempt._id,
scanId: attempt.skillScanRequestId,
jobId: attempt.securityScanJobId,
leaseToken: "canary-lease",
artifactContentHash: attempt.artifactContentHash,
verdict,
runId: "canary-clawscan-run",
llmAnalysis: { status: verdict, checkedAt: Date.now() },
});
}
async function seedNativeSkill(
t: CatalogTest,
options: {
@@ -135,7 +255,7 @@ describe("skills.sh controlled hidden metadata canary", () => {
});
it("records a new external skill without creating native state", async () => {
useLocalEnvironment();
useEnvironment(LOCAL_ENV);
const t = convexTest(schema, modules);
await configureCanary(t);
@@ -186,7 +306,7 @@ describe("skills.sh controlled hidden metadata canary", () => {
});
it("records an exact native match and preserves its downloads", async () => {
useLocalEnvironment();
useEnvironment(LOCAL_ENV);
const t = convexTest(schema, modules);
const nativeSkillId = await seedNativeSkill(t, { exactSource: true, downloads: 143 });
await configureCanary(t);
@@ -220,7 +340,7 @@ describe("skills.sh controlled hidden metadata canary", () => {
});
it("records a route collision without changing or attaching the native skill", async () => {
useLocalEnvironment();
useEnvironment(LOCAL_ENV);
const t = convexTest(schema, modules);
const nativeSkillId = await seedNativeSkill(t, { exactSource: false, downloads: 77 });
await configureCanary(t);
@@ -253,7 +373,7 @@ describe("skills.sh controlled hidden metadata canary", () => {
});
it("reruns idempotently and rolls back only the hidden canary metadata", async () => {
useLocalEnvironment();
useEnvironment(LOCAL_ENV);
const t = convexTest(schema, modules);
const nativeSkillId = await seedNativeSkill(t, { exactSource: false, downloads: 91 });
await configureCanary(t);
@@ -293,4 +413,573 @@ describe("skills.sh controlled hidden metadata canary", () => {
});
expect(first.runId).not.toBe(repeated.runId);
});
it.each(["clean", "suspicious"] as const)(
"publishes only the exact %s canary attempt and resolves a pinned GitHub install",
async (verdict) => {
useEnvironment(TEST_ENV);
const t = convexTest(schema, modules);
const attempt = await prepareScannedCanary(t);
await expect(completeScannedCanary(t, attempt, verdict)).resolves.toEqual({
ok: true,
applied: true,
publicVisible: true,
});
await expect(
t.query(api.skillsShCatalog.getPublicEntry, {
owner: "patrick-erichsen",
repo: "skills",
slug: "html",
}),
).resolves.toMatchObject({
ref: "skills-sh/patrick-erichsen/skills/html",
route: "/skills-sh/patrick-erichsen/skills/html",
artifact: {
contentHash: attempt.artifactContentHash,
files: [
{
path: "SKILL.md",
size: expect.any(Number),
sha256: expect.stringMatching(/^[a-f0-9]{64}$/),
contentType: "text/markdown",
},
],
},
security: {
verdict,
source: "clawhub",
attemptId: attempt._id,
},
install: {
ok: true,
slug: "skills-sh/patrick-erichsen/skills/html",
installKind: "github",
github: {
repo: "patrick-erichsen/skills",
path: "skills/html",
commit: CANARY_COMMIT,
contentHash: CANARY_CONTENT_HASH,
},
},
});
},
);
it("omits verification artifacts when the scan request no longer matches the approved attempt", async () => {
useEnvironment(TEST_ENV);
const t = convexTest(schema, modules);
const attempt = await prepareScannedCanary(t);
await completeScannedCanary(t, attempt, "clean");
await t.run(async (ctx) => {
await ctx.db.patch(attempt.skillScanRequestId, {
sha256hash: "0".repeat(64),
});
});
await expect(
t.query(api.skillsShCatalog.getPublicEntry, {
owner: "patrick-erichsen",
repo: "skills",
slug: "html",
}),
).resolves.toMatchObject({
ref: "skills-sh/patrick-erichsen/skills/html",
artifact: null,
security: { attemptId: attempt._id, verdict: "clean" },
});
});
it("reuses an exact completed canary scan without hiding the published entry", async () => {
useEnvironment(TEST_ENV);
const t = convexTest(schema, modules);
const attempt = await prepareScannedCanary(t);
await completeScannedCanary(t, attempt, "clean");
await expect(
t.mutation(internal.skillsShCatalog.startControlledCanaryScanRunInternal, {
actor: "catalog-test-operator",
reason: "repeat the exact approved canary",
}),
).resolves.toEqual({
runId: attempt.runId,
externalId: CANARY_EXTERNAL_ID,
reused: true,
});
await expect(
t.query(api.skillsShCatalog.getPublicEntry, {
owner: "patrick-erichsen",
repo: "skills",
slug: "html",
}),
).resolves.toMatchObject({
ref: "skills-sh/patrick-erichsen/skills/html",
security: { attemptId: attempt._id, verdict: "clean" },
});
const attempts = await t.run(async (ctx) =>
ctx.db.query("skillsShCatalogScanAttempts").collect(),
);
expect(attempts).toHaveLength(1);
});
it("does not reuse or block on an exact deterministic fixture verdict", async () => {
useEnvironment(TEST_ENV);
const t = convexTest(schema, modules);
await configureCanary(t);
const { runId: fixtureRunId } = await runCanary(t);
await t.run(async (ctx) => {
const entry = await ctx.db
.query("skillsShCatalogEntries")
.withIndex("by_external_id", (q) => q.eq("externalId", CANARY_EXTERNAL_ID))
.unique();
if (!entry) throw new Error("controlled canary entry was not created");
await ctx.db.insert("skillsShCatalogScanAttempts", {
entryId: entry._id,
runId: fixtureRunId,
externalId: entry.externalId,
githubOwnerId: entry.githubOwnerId,
owner: entry.owner,
repo: entry.repo,
slug: entry.slug,
githubPath: entry.githubPath,
githubCommit: entry.githubCommit,
githubContentHash: entry.githubContentHash,
sourceContentHash: entry.sourceContentHash,
source: "skills-sh-catalog-fixture",
dispatchKind: "deterministic",
priority: "low",
status: "succeeded",
verdict: "clean",
completedAt: 1,
createdAt: 1,
updatedAt: 1,
});
});
await t.mutation(internal.skillsShCatalog.configureFixtureControlInternal, {
...CANARY_CONTROL,
mode: "staging-live",
scanAdmissionEnabled: true,
publicVisibilityEnabled: true,
maxWritesPerBatch: 7,
maxScanAdmissionsPerBatch: 1,
maxScanAdmissionsPerRun: 1,
maxScanAdmissionsPerDay: 1,
maxCatalogQueued: 1,
maxCatalogInFlight: 1,
realScanAllowlist: [CANARY_EXTERNAL_ID],
});
const actorUserId = await t.run(
async (ctx) =>
await ctx.db.insert("users", {
handle: "catalog-test-operator",
displayName: "Catalog Test Operator",
role: "admin",
}),
);
const started = await t.mutation(
internal.skillsShCatalog.startControlledCanaryScanRunInternal,
{
actor: "catalog-test-operator",
reason: "replace deterministic evidence with a real catalog scan",
},
);
expect(started).toMatchObject({
externalId: CANARY_EXTERNAL_ID,
reused: false,
});
expect(started.runId).not.toBe(fixtureRunId);
await expect(
t.action(internal.skillsShCatalog.admitRealScansInternal, {
runId: started.runId,
externalIds: [CANARY_EXTERNAL_ID],
actorUserId,
artifacts: [await storeCanaryArtifact(t)],
}),
).resolves.toMatchObject({ admitted: 1, skipped: 0 });
});
it("does not let a stale real verdict block an exact replacement scan", async () => {
useEnvironment(TEST_ENV);
const t = convexTest(schema, modules);
await configureCanary(t);
const { runId: fixtureRunId } = await runCanary(t);
await t.run(async (ctx) => {
const entry = await ctx.db
.query("skillsShCatalogEntries")
.withIndex("by_external_id", (q) => q.eq("externalId", CANARY_EXTERNAL_ID))
.unique();
if (!entry) throw new Error("controlled canary entry was not created");
await ctx.db.insert("skillsShCatalogScanAttempts", {
entryId: entry._id,
runId: fixtureRunId,
externalId: entry.externalId,
githubOwnerId: entry.githubOwnerId,
owner: entry.owner,
repo: entry.repo,
slug: entry.slug,
githubPath: entry.githubPath,
githubCommit: "1".repeat(40),
githubContentHash: entry.githubContentHash,
sourceContentHash: entry.sourceContentHash,
source: "skills-sh-catalog-test",
dispatchKind: "real",
priority: "low",
status: "succeeded",
verdict: "clean",
completedAt: 1,
createdAt: 1,
updatedAt: 1,
});
});
await t.mutation(internal.skillsShCatalog.configureFixtureControlInternal, {
...CANARY_CONTROL,
mode: "staging-live",
scanAdmissionEnabled: true,
publicVisibilityEnabled: true,
maxWritesPerBatch: 7,
maxScanAdmissionsPerBatch: 1,
maxScanAdmissionsPerRun: 1,
maxScanAdmissionsPerDay: 2,
maxCatalogQueued: 1,
maxCatalogInFlight: 1,
realScanAllowlist: [CANARY_EXTERNAL_ID],
});
const actorUserId = await t.run(
async (ctx) =>
await ctx.db.insert("users", {
handle: "catalog-test-operator",
displayName: "Catalog Test Operator",
role: "admin",
}),
);
const started = await t.mutation(
internal.skillsShCatalog.startControlledCanaryScanRunInternal,
{
actor: "catalog-test-operator",
reason: "replace stale real evidence with an exact scan",
},
);
await expect(
t.action(internal.skillsShCatalog.admitRealScansInternal, {
runId: started.runId,
externalIds: [CANARY_EXTERNAL_ID],
actorUserId,
artifacts: [await storeCanaryArtifact(t)],
}),
).resolves.toMatchObject({ admitted: 1, skipped: 0 });
});
it("rejects a scan artifact that differs from the authenticated GitHub folder", async () => {
useEnvironment(TEST_ENV);
const t = convexTest(schema, modules);
await configureCanary(t);
await runCanary(t);
await t.mutation(internal.skillsShCatalog.configureFixtureControlInternal, {
...CANARY_CONTROL,
mode: "staging-live",
scanAdmissionEnabled: true,
publicVisibilityEnabled: true,
maxWritesPerBatch: 7,
maxScanAdmissionsPerBatch: 1,
maxScanAdmissionsPerRun: 1,
maxScanAdmissionsPerDay: 1,
maxCatalogQueued: 1,
maxCatalogInFlight: 1,
realScanAllowlist: [CANARY_EXTERNAL_ID],
});
const actorUserId = await t.run(
async (ctx) =>
await ctx.db.insert("users", {
handle: "catalog-test-operator",
displayName: "Catalog Test Operator",
role: "admin",
}),
);
const { runId } = await t.mutation(
internal.skillsShCatalog.startControlledCanaryScanRunInternal,
{
actor: "catalog-test-operator",
reason: "reject changed canary content",
},
);
const changedArtifact = await storeCanaryArtifact(t, `${canarySkillMarkdown}\nchanged\n`);
await expect(
t.action(internal.skillsShCatalog.admitRealScansInternal, {
runId,
externalIds: [CANARY_EXTERNAL_ID],
actorUserId,
artifacts: [changedArtifact],
}),
).rejects.toThrow("real Test scan artifact does not match authenticated GitHub content");
const attempts = await t.run(async (ctx) =>
ctx.db.query("skillsShCatalogScanAttempts").collect(),
);
expect(attempts).toHaveLength(0);
});
it.each(["malicious", "failed"] as const)(
"keeps a %s canary hidden and non-installable",
async (verdict) => {
useEnvironment(TEST_ENV);
const t = convexTest(schema, modules);
const attempt = await prepareScannedCanary(t);
await expect(completeScannedCanary(t, attempt, verdict)).resolves.toEqual({
ok: true,
applied: true,
publicVisible: false,
});
await expect(
t.query(api.skillsShCatalog.getPublicEntry, {
owner: "patrick-erichsen",
repo: "skills",
slug: "html",
}),
).resolves.toBeNull();
},
);
it.each(["malicious", "failed"] as const)(
"admits a fresh exact attempt after a %s canary scan",
async (verdict) => {
useEnvironment(TEST_ENV);
const t = convexTest(schema, modules);
const blockedAttempt = await prepareScannedCanary(t);
await completeScannedCanary(t, blockedAttempt, verdict);
await t.mutation(internal.skillsShCatalog.configureFixtureControlInternal, {
...CANARY_CONTROL,
mode: "staging-live",
scanAdmissionEnabled: true,
publicVisibilityEnabled: true,
maxWritesPerBatch: 7,
maxScanAdmissionsPerBatch: 1,
maxScanAdmissionsPerRun: 1,
maxScanAdmissionsPerDay: 2,
maxCatalogQueued: 1,
maxCatalogInFlight: 1,
realScanAllowlist: [CANARY_EXTERNAL_ID],
});
const retry = await t.mutation(
internal.skillsShCatalog.startControlledCanaryScanRunInternal,
{
actor: "catalog-test-operator",
reason: `retry the exact canary after a ${verdict} scan`,
},
);
expect(retry).toMatchObject({
externalId: CANARY_EXTERNAL_ID,
reused: false,
});
expect(retry.runId).not.toBe(blockedAttempt.runId);
const actorUserId = await t.run(
async (ctx) =>
(await ctx.db
.query("users")
.filter((q) => q.eq(q.field("handle"), "catalog-test-operator"))
.unique())!._id,
);
const artifact = await storeCanaryArtifact(t);
await expect(
t.action(internal.skillsShCatalog.admitRealScansInternal, {
runId: retry.runId,
externalIds: [CANARY_EXTERNAL_ID],
actorUserId,
artifacts: [artifact],
}),
).resolves.toMatchObject({ admitted: 1, skipped: 0 });
},
);
it.each([
["githubPath", "skills/changed"],
["githubCommit", "1".repeat(40)],
["githubContentHash", "2".repeat(64)],
] as const)("rejects a stale callback after the entry %s changes", async (field, value) => {
useEnvironment(TEST_ENV);
const t = convexTest(schema, modules);
const attempt = await prepareScannedCanary(t);
await t.run(async (ctx) => {
await ctx.db.patch(attempt.entryId, { [field]: value, updatedAt: Date.now() });
});
await expect(completeScannedCanary(t, attempt, "clean")).resolves.toEqual({
ok: true,
applied: false,
reason: "stale-attempt",
});
await expect(
t.query(api.skillsShCatalog.getPublicEntry, {
owner: "patrick-erichsen",
repo: "skills",
slug: "html",
}),
).resolves.toBeNull();
});
it("blocks promotion while paused, then supports idempotent publication rollback", async () => {
useEnvironment(TEST_ENV);
const t = convexTest(schema, modules);
const pausedAttempt = await prepareScannedCanary(t);
await t.mutation(internal.skillsShCatalog.setCatalogPausedInternal, {
paused: true,
actor: "catalog-test-operator",
reason: "prove catalog-only pause",
confirm: "set-skills-sh-test-pause",
});
await expect(completeScannedCanary(t, pausedAttempt, "clean")).resolves.toEqual({
ok: true,
applied: true,
publicVisible: false,
});
await t.mutation(internal.skillsShCatalog.setCatalogPausedInternal, {
paused: false,
actor: "catalog-test-operator",
reason: "resume after paused callback proof",
confirm: "set-skills-sh-test-pause",
});
await expect(
t.query(api.skillsShCatalog.getPublicEntry, {
owner: "patrick-erichsen",
repo: "skills",
slug: "html",
}),
).resolves.toBeNull();
await expect(
t.mutation(internal.skillsShCatalog.startControlledCanaryScanRunInternal, {
actor: "catalog-test-operator",
reason: "publish the exact completed canary after resume",
}),
).resolves.toEqual({
runId: pausedAttempt.runId,
externalId: CANARY_EXTERNAL_ID,
reused: true,
});
const publishedAttempt = pausedAttempt;
await expect(
t.query(api.skillsShCatalog.getPublicEntry, {
owner: "patrick-erichsen",
repo: "skills",
slug: "html",
}),
).resolves.toMatchObject({
security: { attemptId: publishedAttempt._id, verdict: "clean" },
});
await t.mutation(internal.skillsShCatalog.rollbackPublicationInternal, {
externalId: CANARY_EXTERNAL_ID,
attemptId: publishedAttempt._id,
actor: "catalog-test-operator",
reason: "prove exact publication rollback",
confirm: "rollback-skills-sh-test-publication",
});
await expect(
t.run(async (ctx) => await ctx.db.get(publishedAttempt._id)),
).resolves.toMatchObject({
publicationRolledBackAt: expect.any(Number),
});
await expect(completeScannedCanary(t, publishedAttempt, "clean")).resolves.toEqual({
ok: true,
applied: true,
publicVisible: false,
});
await expect(
t.query(api.skillsShCatalog.getPublicEntry, {
owner: "patrick-erichsen",
repo: "skills",
slug: "html",
}),
).resolves.toBeNull();
await t.mutation(internal.skillsShCatalog.configureFixtureControlInternal, {
...CANARY_CONTROL,
mode: "staging-live",
scanAdmissionEnabled: true,
publicVisibilityEnabled: true,
maxWritesPerBatch: 7,
maxScanAdmissionsPerBatch: 1,
maxScanAdmissionsPerRun: 1,
maxScanAdmissionsPerDay: 2,
maxCatalogQueued: 1,
maxCatalogInFlight: 1,
realScanAllowlist: [CANARY_EXTERNAL_ID],
});
const replacementRun = await t.mutation(
internal.skillsShCatalog.startControlledCanaryScanRunInternal,
{
actor: "catalog-test-operator",
reason: "publish a replacement after rollback",
},
);
const actorUserId = await t.run(
async (ctx) =>
(await ctx.db
.query("users")
.filter((q) => q.eq(q.field("handle"), "catalog-test-operator"))
.first())!._id,
);
await t.action(internal.skillsShCatalog.admitRealScansInternal, {
runId: replacementRun.runId,
externalIds: [CANARY_EXTERNAL_ID],
actorUserId,
artifacts: [await storeCanaryArtifact(t)],
});
const replacementAttempt = await t.run(async (ctx) => {
const attempt = await ctx.db
.query("skillsShCatalogScanAttempts")
.withIndex("by_run", (q) => q.eq("runId", replacementRun.runId))
.unique();
if (!attempt?.skillScanRequestId || !attempt.securityScanJobId) {
throw new Error("replacement canary scan admission did not create linked work");
}
await ctx.db.patch(attempt._id, { status: "running", updatedAt: Date.now() });
await ctx.db.patch(attempt.skillScanRequestId, {
status: "running",
updatedAt: Date.now(),
});
await ctx.db.patch(attempt.securityScanJobId, {
status: "running",
leaseToken: "replacement-lease",
leaseExpiresAt: Date.now() + 60_000,
workerId: "replacement-worker",
updatedAt: Date.now(),
});
return attempt;
});
await t.mutation(internal.securityScan.completeCatalogSkillScanJobInternal, {
attemptId: replacementAttempt._id,
scanId: replacementAttempt.skillScanRequestId!,
jobId: replacementAttempt.securityScanJobId!,
leaseToken: "replacement-lease",
artifactContentHash: replacementAttempt.artifactContentHash!,
verdict: "clean",
runId: "replacement-clawscan-run",
llmAnalysis: { status: "clean", checkedAt: Date.now() },
});
await expect(
t.mutation(internal.skillsShCatalog.rollbackPublicationInternal, {
externalId: CANARY_EXTERNAL_ID,
attemptId: publishedAttempt._id,
actor: "catalog-test-operator",
reason: "retry the old rollback after replacement publication",
confirm: "rollback-skills-sh-test-publication",
}),
).resolves.toMatchObject({
externalId: CANARY_EXTERNAL_ID,
publicVisible: true,
alreadyRolledBack: true,
});
await expect(
t.query(api.skillsShCatalog.getPublicEntry, {
owner: "patrick-erichsen",
repo: "skills",
slug: "html",
}),
).resolves.toMatchObject({
security: { attemptId: replacementAttempt._id, verdict: "clean" },
});
});
});
+23 -1
View File
@@ -36,7 +36,13 @@ const reportCliInstallHandler = (
reportCliInstallInternal as unknown as {
_handler: (
ctx: unknown,
args: { userId: string; slug: string; ownerHandle?: string; version?: string },
args: {
userId: string;
slug: string;
ownerHandle?: string;
sourceRef?: string;
version?: string;
},
) => Promise<void>;
}
)._handler;
@@ -187,6 +193,22 @@ describe("telemetry install events", () => {
);
});
it("does not attribute an unclaimed skills.sh install to a same-slug native skill", async () => {
const query = vi.fn();
const insert = vi.fn();
const ctx = { db: { query, insert, patch: vi.fn() } };
await reportCliInstallHandler(ctx, {
userId: "users:one",
slug: "demo",
sourceRef: "skills-sh/alice/skills/demo",
version: "a".repeat(40),
});
expect(query).not.toHaveBeenCalled();
expect(insert).not.toHaveBeenCalled();
});
it("uses owner identity when recording an owner-qualified install", async () => {
const publisher = {
_id: "publishers:alice",
+4
View File
@@ -24,9 +24,13 @@ export const reportCliInstallInternal = internalMutation({
userId: v.id("users"),
slug: v.string(),
ownerHandle: v.optional(v.string()),
sourceRef: v.optional(v.string()),
version: v.optional(v.string()),
},
handler: async (ctx, args) => {
// Unclaimed catalog installs have no native skill row yet. Keep the source
// identity in the request without guessing from a same-slug native skill.
if (args.sourceRef?.trim().toLowerCase().startsWith("skills-sh/")) return;
await upsertUserSkillInstall(ctx, args);
},
});
@@ -360,6 +360,52 @@ describe("cmdInspect", () => {
});
describe("cmdVerifySkill", () => {
it("prints exact skills.sh catalog verification from the standard verify route", async () => {
const sourceRef = "skills-sh/patrick-erichsen/skills/html";
const payload = {
schema: "clawhub.skill.verify.v1",
ok: true,
decision: "pass",
reasons: [],
slug: sourceRef,
displayName: "HTML Artifact Chooser",
pageUrl: "https://clawhub.ai/skills-sh/patrick-erichsen/skills/html",
publisherHandle: null,
publisherDisplayName: null,
publisherProfileUrl: null,
version: "a".repeat(40),
resolvedFrom: "latest",
tag: null,
createdAt: 123,
card: {
available: false,
},
artifact: {
sourceFingerprint: "b".repeat(64),
bundleFingerprints: ["c".repeat(64)],
files: [{ path: "SKILL.md", size: 42, sha256: "d".repeat(64) }],
},
provenance: { source: "skills-sh-catalog" },
security: { status: "clean", passed: true },
signature: { status: "unsigned" },
};
httpMocks.apiRequest.mockResolvedValueOnce(payload);
await cmdVerifySkill(makeGlobalOpts(), sourceRef);
const request = httpMocks.apiRequest.mock.calls[0]?.[1];
const url = new URL(String(request?.url));
expect(url.pathname).toBe(`${ApiRoutes.skills}/html/verify`);
expect(url.searchParams.get("reference")).toBe(sourceRef);
expect(JSON.parse(String(mockLog.mock.calls[0]?.[0]))).toEqual(payload);
});
it("rejects colon-form skills.sh verification references", async () => {
await expect(
cmdVerifySkill(makeGlobalOpts(), "skills-sh:patrick-erichsen/skills/html"),
).rejects.toThrow("Invalid skills.sh ref: use skills-sh/owner/repo/slug");
});
it("fetches and prints JSON verification by default", async () => {
const payload = {
schema: "clawhub.skill.verify.v1",
+31 -2
View File
@@ -261,7 +261,14 @@ export async function cmdVerifySkill(
slug: string,
options: VerifySkillOptions = {},
) {
const requested = parseSkillRef(slug);
if (slug.trim().toLowerCase().startsWith("skills-sh:")) {
fail("Invalid skills.sh ref: use skills-sh/owner/repo/slug");
}
const skillsShRef = parseSkillsShCatalogRef(slug);
if (skillsShRef && (options.version || options.tag || options.card)) {
fail("skills.sh verification does not support --version, --tag, or --card");
}
const requested = skillsShRef ? { slug: skillsShRef.slug } : parseSkillRef(slug);
const trimmed = requested.slug;
if (!trimmed) fail("Skill required");
if (options.version && options.tag) fail("Use either --version or --tag");
@@ -271,7 +278,11 @@ export async function cmdVerifySkill(
const spinner = createCrabLoader("Fetching skill verification");
try {
const url = registryUrl(`${ApiRoutes.skills}/${encodeURIComponent(trimmed)}/verify`, registry);
if (requested.ownerHandle) url.searchParams.set("ownerHandle", requested.ownerHandle);
if (skillsShRef) {
url.searchParams.set("reference", slug.trim().toLowerCase());
} else if (requested.ownerHandle) {
url.searchParams.set("ownerHandle", requested.ownerHandle);
}
if (options.version) {
url.searchParams.set("version", options.version);
} else if (options.tag) {
@@ -306,6 +317,24 @@ export async function cmdVerifySkill(
}
}
function parseSkillsShCatalogRef(raw: string) {
const value = raw.trim().toLowerCase();
if (!value.startsWith("skills-sh/")) return null;
const segments = value.split("/");
if (
segments.length !== 4 ||
segments[0] !== "skills-sh" ||
segments.slice(1).some((segment) => !segment || segment.includes(":") || segment.includes(".."))
) {
fail("Invalid skills.sh ref: use skills-sh/owner/repo/slug");
}
return {
owner: segments[1]!,
repo: segments[2]!,
slug: segments[3]!,
};
}
function parseSkillRef(raw: string) {
const value = raw.trim();
if (!value) fail("Skill required");
@@ -6,6 +6,7 @@ export async function reportInstalledSkillsTelemetryIfEnabled(params: {
registry: string;
slug: string;
ownerHandle?: string | null;
sourceRef?: string | null;
version?: string | null;
}) {
if (!params.token || isTelemetryDisabled()) return;
@@ -23,6 +24,7 @@ export async function reportInstalledSkillsTelemetryIfEnabled(params: {
event: "install",
slug,
ownerHandle: params.ownerHandle ?? undefined,
sourceRef: params.sourceRef ?? undefined,
version: params.version ?? undefined,
},
},
@@ -61,6 +61,7 @@ vi.mock("../ui.js", () => ({
const extractZipToDirMock = vi.spyOn(skillStore, "extractZipToDir");
const extractGitHubZipPathToDirMock = vi.spyOn(skillStore, "extractGitHubZipPathToDir");
const hashSkillFilesMock = vi.spyOn(skillStore, "hashSkillFiles");
const listManualSkillsMock = vi.spyOn(skillStore, "listManualSkills");
const listTextFilesMock = vi.spyOn(skillStore, "listSkillFiles");
const readLockfileMock = vi.spyOn(skillStore, "readLockfile");
const readSkillOriginMock = vi.spyOn(skillStore, "readSkillOrigin");
@@ -114,6 +115,7 @@ beforeEach(() => {
extractZipToDirMock.mockResolvedValue(undefined);
extractGitHubZipPathToDirMock.mockResolvedValue(undefined);
hashSkillFilesMock.mockReturnValue({ fingerprint: "hash", files: [] });
listManualSkillsMock.mockResolvedValue([]);
listTextFilesMock.mockResolvedValue([]);
readLockfileMock.mockResolvedValue({ version: 1, skills: {} });
readSkillOriginMock.mockResolvedValue(null);
@@ -129,6 +131,7 @@ afterAll(() => {
extractZipToDirMock.mockRestore();
extractGitHubZipPathToDirMock.mockRestore();
hashSkillFilesMock.mockRestore();
listManualSkillsMock.mockRestore();
listTextFilesMock.mockRestore();
readLockfileMock.mockRestore();
readSkillOriginMock.mockRestore();
@@ -441,6 +444,85 @@ describe("skill moderation commands", () => {
});
describe("cmdUpdate", () => {
it("updates a legacy slug-keyed skills.sh install through its stored sourceRef", async () => {
const sourceRef = "skills-sh/patrick-erichsen/skills/html";
const previousCommit = "a".repeat(40);
const nextCommit = "b".repeat(40);
const installedFiles = [{ path: "SKILL.md", sha256: "c".repeat(64), size: 1 }];
const contentHash = skillStore.buildGitHubFolderContentHash(installedFiles);
mockApiRequest.mockResolvedValueOnce({
ok: true,
slug: sourceRef,
installKind: "github",
github: {
repo: "patrick-erichsen/skills",
path: "skills/html",
commit: nextCommit,
contentHash,
sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${nextCommit}/skills/html`,
},
});
mockFetchBinary.mockResolvedValue(new Uint8Array([1, 2, 3]));
vi.mocked(readLockfile).mockResolvedValue({
version: 1,
skills: {
html: {
version: previousCommit,
installedAt: 123,
sourceRef,
},
},
});
vi.mocked(readSkillOrigin).mockResolvedValue({
version: 1,
registry: "https://clawhub.ai",
slug: "html",
sourceRef,
installedVersion: previousCommit,
installedAt: 123,
fingerprint: "hash",
});
vi.mocked(stat).mockResolvedValue({} as unknown as Awaited<ReturnType<typeof stat>>);
vi.mocked(listSkillFiles).mockResolvedValue([
{ relPath: "SKILL.md", bytes: new Uint8Array([1]) },
]);
hashSkillFilesMock.mockReturnValue({ fingerprint: "hash", files: installedFiles });
await cmdUpdate(makeOpts(), sourceRef, {}, false);
expect(mockApiRequest).toHaveBeenCalledWith(
"https://clawhub.ai",
{
method: "GET",
path: `${ApiRoutes.skillsSh}/patrick-erichsen/skills/html/install`,
token: undefined,
},
expect.anything(),
);
expect(mockFetchBinary).toHaveBeenCalledWith("https://clawhub.ai", {
url: `https://codeload.github.com/patrick-erichsen/skills/zip/${nextCommit}`,
});
expect(writeSkillOrigin).toHaveBeenCalledWith("/work/skills/html", {
version: 1,
registry: "https://clawhub.ai",
slug: "html",
sourceRef,
installedVersion: nextCommit,
installedAt: 123,
fingerprint: "hash",
});
expect(writeLockfile).toHaveBeenCalledWith("/work", {
version: 1,
skills: {
html: {
version: nextCommit,
installedAt: 123,
sourceRef,
},
},
});
});
it("fails when directly updating a pinned skill", async () => {
vi.mocked(readLockfile).mockResolvedValue({
version: 1,
@@ -1288,6 +1370,24 @@ describe("pin commands", () => {
});
describe("cmdList", () => {
it("does not report a tracked skills.sh install as a manual skill", async () => {
const sourceRef = "skills-sh/patrick-erichsen/skills/html";
vi.mocked(readLockfile).mockResolvedValue({
version: 1,
skills: {
[sourceRef]: {
version: "a".repeat(40),
installedAt: 123,
sourceRef,
},
},
});
await cmdList(makeOpts());
expect(skillStore.listManualSkills).toHaveBeenCalledWith("/work/skills", new Set(["html"]));
expect(mockLog).toHaveBeenCalledWith(`${sourceRef} ${"a".repeat(40)}`);
});
it("shows pinned state in list output", async () => {
vi.mocked(readLockfile).mockResolvedValue({
version: 1,
@@ -1305,6 +1405,142 @@ describe("cmdList", () => {
});
describe("cmdInstall", () => {
it("installs a skills.sh catalog ref from the approved pinned GitHub resolver", async () => {
const sourceRef = "skills-sh/patrick-erichsen/skills/html";
const commit = "a".repeat(40);
const installedFiles = [{ path: "SKILL.md", sha256: "b".repeat(64), size: 1 }];
const contentHash = skillStore.buildGitHubFolderContentHash(installedFiles);
mockGetOptionalAuthToken.mockResolvedValue("tkn");
mockApiRequest
.mockResolvedValueOnce({
ok: true,
slug: sourceRef,
installKind: "github",
github: {
repo: "patrick-erichsen/skills",
path: "skills/html",
commit,
contentHash,
sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${commit}/skills/html`,
},
})
.mockResolvedValueOnce({ ok: true });
mockFetchBinary.mockResolvedValue(new Uint8Array([1, 2, 3]));
vi.mocked(readLockfile).mockResolvedValue({ version: 1, skills: {} });
vi.mocked(listSkillFiles).mockResolvedValue([
{ relPath: "SKILL.md", bytes: new Uint8Array([1]) },
]);
hashSkillFilesMock.mockReturnValue({ fingerprint: "hash", files: installedFiles });
await cmdInstall(makeOpts(), sourceRef);
expect(mockApiRequest).toHaveBeenNthCalledWith(
1,
"https://clawhub.ai",
{
method: "GET",
path: `${ApiRoutes.skillsSh}/patrick-erichsen/skills/html/install`,
token: "tkn",
},
expect.anything(),
);
expect(mockFetchBinary).toHaveBeenCalledWith("https://clawhub.ai", {
url: `https://codeload.github.com/patrick-erichsen/skills/zip/${commit}`,
});
expect(extractGitHubZipPathToDir).toHaveBeenCalledWith(
new Uint8Array([1, 2, 3]),
"/work/skills/html",
"skills/html",
);
expect(writeSkillOrigin).toHaveBeenCalledWith("/work/skills/html", {
version: 1,
registry: "https://clawhub.ai",
slug: "html",
sourceRef,
installedVersion: commit,
installedAt: expect.any(Number),
fingerprint: "hash",
});
expect(writeLockfile).toHaveBeenCalledWith("/work", {
version: 1,
skills: {
[sourceRef]: {
version: commit,
installedAt: expect.any(Number),
sourceRef,
},
},
});
expect(mockApiRequest).toHaveBeenNthCalledWith(
2,
"https://clawhub.ai",
expect.objectContaining({
path: LegacyApiRoutes.cliTelemetryInstall,
body: {
event: "install",
slug: "html",
sourceRef,
version: commit,
},
}),
expect.anything(),
);
});
it("rejects the unsupported colon-form skills.sh reference", async () => {
await expect(cmdInstall(makeOpts(), "skills-sh:patrick-erichsen/skills/html")).rejects.toThrow(
"Invalid skills.sh ref: use skills-sh/owner/repo/slug",
);
expect(mockApiRequest).not.toHaveBeenCalled();
});
it("blocks a skills.sh install when the slug target belongs to another source", async () => {
const sourceRef = "skills-sh/patrick-erichsen/skills/html";
statMock.mockResolvedValue({} as Awaited<ReturnType<typeof stat>>);
readSkillOriginMock.mockResolvedValue({
version: 1,
registry: "https://clawhub.ai",
slug: "html",
sourceRef: "skills-sh/other/repo/html",
installedVersion: "a".repeat(40),
installedAt: 1,
});
await expect(cmdInstall(makeOpts(), sourceRef, undefined, true)).rejects.toThrow(
`Install target collision: /work/skills/html is owned by skills-sh/other/repo/html`,
);
expect(mockApiRequest).not.toHaveBeenCalled();
});
it("rejects a skills.sh install whose extracted folder hash differs from the resolver", async () => {
const sourceRef = "skills-sh/patrick-erichsen/skills/html";
const commit = "a".repeat(40);
mockApiRequest.mockResolvedValueOnce({
ok: true,
slug: sourceRef,
installKind: "github",
github: {
repo: "patrick-erichsen/skills",
path: "skills/html",
commit,
contentHash: "b".repeat(64),
sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${commit}/skills/html`,
},
});
mockFetchBinary.mockResolvedValue(new Uint8Array([1, 2, 3]));
listTextFilesMock.mockResolvedValue([{ relPath: "SKILL.md", bytes: new Uint8Array([1]) }]);
hashSkillFilesMock.mockReturnValue({
fingerprint: "local-fingerprint",
files: [{ path: "SKILL.md", sha256: "c".repeat(64), size: 1 }],
});
await expect(cmdInstall(makeOpts(), sourceRef)).rejects.toThrow(
"Downloaded skills.sh folder hash does not match the approved ClawHub resolver",
);
expect(writeSkillOrigin).not.toHaveBeenCalled();
expect(writeLockfile).not.toHaveBeenCalled();
});
it("passes optional auth token to API + download requests", async () => {
mockGetOptionalAuthToken.mockResolvedValue("tkn");
mockApiRequest.mockImplementation(async (_registry, args) => {
+282 -3
View File
@@ -19,6 +19,7 @@ import {
type SkillReportStatus,
} from "../../schema/index.js";
import {
buildGitHubFolderContentHash,
extractGitHubZipPathToDir,
extractZipToDir,
hashSkillFiles,
@@ -68,6 +69,11 @@ type SkillReportTriageOptions = {
type SkillRef = {
slug: string;
ownerHandle?: string;
sourceRef?: string;
skillsSh?: {
owner: string;
repo: string;
};
};
function normalizeOwnerHandle(raw: string | null | undefined) {
@@ -104,6 +110,21 @@ function normalizeSkillSlugForRemote(raw: unknown) {
function parseSkillRefOrFail(raw: string): SkillRef {
const ref = raw.trim();
if (!ref) fail("Slug required");
if (ref.toLowerCase().startsWith("skills-sh:")) {
fail("Invalid skills.sh ref: use skills-sh/owner/repo/slug");
}
if (ref.toLowerCase().startsWith("skills-sh/")) {
const segments = ref.split("/");
if (segments.length !== 4 || segments[0]?.toLowerCase() !== "skills-sh") {
fail("Invalid skills.sh ref: use skills-sh/owner/repo/slug");
}
const [, rawOwner, rawRepo, rawSlug] = segments;
const owner = normalizeSkillsShSegment(rawOwner, ref);
const repo = normalizeSkillsShSegment(rawRepo, ref);
const slug = normalizeSkillsShSegment(rawSlug, ref);
const sourceRef = `skills-sh/${owner}/${repo}/${slug}`;
return { slug, sourceRef, skillsSh: { owner, repo } };
}
const slashIndex = ref.indexOf("/");
if (slashIndex < 0) {
return { slug: normalizeSkillSlugOrFail(ref) };
@@ -121,19 +142,43 @@ function parseSkillRefOrFail(raw: string): SkillRef {
return { slug, ownerHandle };
}
function normalizeSkillsShSegment(raw: string | undefined, ref: string) {
const segment = raw?.trim().toLowerCase() ?? "";
if (
!segment ||
segment.includes("\\") ||
segment.includes(":") ||
segment.includes("..") ||
!isSafeSkillSlug(segment)
) {
fail(`Invalid skills.sh ref: ${ref}`);
}
return segment;
}
function isSafeSkillSlug(slug: string) {
return Boolean(slug) && !slug.includes("/") && !slug.includes("\\") && !slug.includes("..");
}
function skillIdentity(ref: SkillRef) {
if (ref.sourceRef) return ref.sourceRef;
return ref.ownerHandle ? `@${ref.ownerHandle}/${ref.slug}` : ref.slug;
}
function skillTarget(dir: string, ref: SkillRef) {
if (ref.sourceRef) return join(dir, ref.slug);
return ref.ownerHandle ? join(dir, `@${ref.ownerHandle}`, ref.slug) : join(dir, ref.slug);
}
function isSafeSkillIdentity(value: string) {
if (value.toLowerCase().startsWith("skills-sh/")) {
const segments = value.split("/");
return (
segments.length === 4 &&
segments[0]?.toLowerCase() === "skills-sh" &&
segments.slice(1).every((segment) => isSafeSkillSlug(segment) && !segment.includes(":"))
);
}
const slashIndex = value.indexOf("/");
if (slashIndex < 0) return isSafeSkillSlug(value);
if (value.indexOf("/", slashIndex + 1) >= 0) return false;
@@ -143,11 +188,16 @@ function isSafeSkillIdentity(value: string) {
}
function findExistingLockKey(
lock: { skills: Record<string, { ownerHandle?: string }> },
lock: { skills: Record<string, { ownerHandle?: string; sourceRef?: string }> },
ref: SkillRef,
) {
const key = skillIdentity(ref);
if (lock.skills[key]) return key;
if (ref.sourceRef) {
const legacyEntry = lock.skills[ref.slug];
if (legacyEntry?.sourceRef === ref.sourceRef) return ref.slug;
return key;
}
if (ref.ownerHandle) {
const legacyEntry = lock.skills[ref.slug];
if (legacyEntry && normalizeOwnerHandle(legacyEntry.ownerHandle) === ref.ownerHandle) {
@@ -296,6 +346,9 @@ export async function cmdInstall(
) {
const requested = parseSkillRefOrFail(slug);
const trimmed = requested.slug;
if (requested.sourceRef && versionFlag) {
fail("--version is not supported for skills.sh catalog references");
}
const token = await getOptionalAuthToken();
@@ -306,6 +359,18 @@ export async function cmdInstall(
const localRef = lockKey === skillIdentity(requested) ? requested : parseSkillRefOrFail(lockKey);
const target = skillTarget(opts.dir, localRef);
const targetExists = await fileExists(target);
const existingOrigin = requested.sourceRef && targetExists ? await readSkillOrigin(target) : null;
if (requested.sourceRef) {
assertSkillsShTargetOwnership({
dir: opts.dir,
lock,
lockKey,
requested,
target,
targetExists,
existingOrigin,
});
}
if (!force && targetExists) {
fail(`Already installed: ${target} (use --force)`);
}
@@ -319,6 +384,51 @@ export async function cmdInstall(
const spinner = createCrabLoader(`Resolving ${trimmed}`);
try {
if (requested.sourceRef && requested.skillsSh) {
const resolvedInstall = await resolveSkillsShCatalogInstall(registry, requested, token);
spinner.text = `Downloading ${trimmed} ${formatGitHubVersion(resolvedInstall.github.commit)}`;
await installSkillWithOptionalStaging(target, targetExists, (installTarget) =>
installGitHubSkill(registry, resolvedInstall, installTarget, {
expectedContentHash: resolvedInstall.github.contentHash,
}),
);
const installedFiles = await listSkillFiles(target);
const installedFingerprint =
installedFiles.length > 0 ? hashSkillFiles(installedFiles).fingerprint : undefined;
const installedAt = Date.now();
await writeSkillOrigin(target, {
version: 1,
registry,
slug: trimmed,
sourceRef: requested.sourceRef,
installedVersion: resolvedInstall.github.commit,
installedAt,
fingerprint: installedFingerprint,
});
lock.skills[lockKey] = {
...withPinnedMetadata(resolvedInstall.github.commit, installedAt, existingEntry),
sourceRef: requested.sourceRef,
};
await writeLockfile(opts.workdir, lock);
await reportInstalledSkillsTelemetryIfEnabled({
token,
registry,
slug: trimmed,
sourceRef: requested.sourceRef,
version: resolvedInstall.github.commit,
});
spinner.succeed(
`${styleText("Installed", "brand")} ${styleText(
requested.sourceRef,
"strong",
)} ${styleText(formatGitHubVersion(resolvedInstall.github.commit), "muted")} -> ${styleText(
target,
"muted",
)}`,
);
return;
}
// Fetch skill metadata including moderation status
const skillMeta = await apiRequest(
registry,
@@ -456,6 +566,9 @@ export async function cmdUpdate(
if (slug && all) fail("Use either <skill> or --all");
if (options.version && !slug) fail("--version requires a single <skill>");
if (options.version && !semver.valid(options.version)) fail("--version must be valid semver");
if (requestedRef?.sourceRef && options.version) {
fail("--version is not supported for skills.sh catalog references");
}
const lock = await readLockfile(opts.workdir);
const requestedLockKey = requestedRef ? findExistingLockKey(lock, requestedRef) : undefined;
if (requestedLockKey && isPinnedSkillEntry(lock.skills[requestedLockKey])) {
@@ -500,13 +613,110 @@ export async function cmdUpdate(
};
for (const entry of slugs) {
const entryRef = parseSkillRefOrFail(entry);
const entryLock = lock.skills[entry];
const entryRef = parseSkillRefOrFail(entryLock?.sourceRef ?? entry);
const spinner = createCrabLoader(`Checking ${entry}`);
try {
const target = skillTarget(opts.dir, entryRef);
const exists = await fileExists(target);
const existingOrigin = exists ? await readSkillOrigin(target) : null;
if (entryRef.sourceRef && entryRef.skillsSh) {
assertSkillsShTargetOwnership({
dir: opts.dir,
lock,
lockKey: entry,
requested: entryRef,
target,
targetExists: exists,
existingOrigin,
});
const filesOnDisk = exists ? await listSkillFiles(target) : [];
const localFingerprint =
filesOnDisk.length > 0 ? hashSkillFiles(filesOnDisk).fingerprint : null;
const latestInstall = await resolveSkillsShCatalogInstall(registry, entryRef, token);
const targetVersion = latestInstall.github.commit;
const originFingerprint =
existingOrigin?.sourceRef === entryRef.sourceRef ? existingOrigin.fingerprint : undefined;
const hasLocalChanges = Boolean(
exists &&
localFingerprint &&
(!originFingerprint || originFingerprint !== localFingerprint),
);
const matched =
existingOrigin?.sourceRef === entryRef.sourceRef &&
originFingerprint &&
localFingerprint &&
originFingerprint === localFingerprint
? existingOrigin.installedVersion
: null;
if (hasLocalChanges && !options.force) {
spinner.stop();
if (!allowPrompt) {
console.log(`${entry}: local changes (no match). Use --force to overwrite.`);
continue;
}
const confirm = await promptConfirm(
`${entry}: local changes (no match). Overwrite with ${formatGitHubVersion(
targetVersion,
)}?`,
);
if (!confirm) {
console.log(`${entry}: skipped`);
continue;
}
spinner.start(`Updating ${entry} -> ${formatGitHubVersion(targetVersion)}`);
}
if (matched === targetVersion && !options.force && !hasLocalChanges) {
if (
lock.skills[entry]?.version !== targetVersion ||
lock.skills[entry]?.sourceRef !== entryRef.sourceRef
) {
lock.skills[entry] = {
...withPinnedMetadata(
targetVersion,
lock.skills[entry]?.installedAt ?? Date.now(),
lock.skills[entry],
),
sourceRef: entryRef.sourceRef,
};
markLockDirty();
await flushLockfile();
}
spinner.succeed(`${entry}: up to date (${formatGitHubVersion(targetVersion)})`);
continue;
}
spinner.text = `Updating ${entry} -> ${formatGitHubVersion(targetVersion)}`;
await installSkillWithOptionalStaging(target, exists, (installTarget) =>
installGitHubSkill(registry, latestInstall, installTarget, {
expectedContentHash: latestInstall.github.contentHash,
}),
);
const installedFiles = await listSkillFiles(target);
const installedFingerprint =
installedFiles.length > 0 ? hashSkillFiles(installedFiles).fingerprint : undefined;
const installedAt = existingOrigin?.installedAt ?? Date.now();
await writeSkillOrigin(target, {
version: 1,
registry: existingOrigin?.registry ?? registry,
slug: entryRef.slug,
sourceRef: entryRef.sourceRef,
installedVersion: targetVersion,
installedAt,
fingerprint: installedFingerprint,
});
lock.skills[entry] = {
...withPinnedMetadata(targetVersion, installedAt, lock.skills[entry]),
sourceRef: entryRef.sourceRef,
};
markLockDirty();
await flushLockfile();
spinner.succeed(`${entry}: updated -> ${formatGitHubVersion(targetVersion)}`);
continue;
}
const requestedOwnerHandle = normalizeOwnerHandle(
requestedRef?.ownerHandle ??
entryRef.ownerHandle ??
@@ -812,7 +1022,13 @@ export async function cmdUpdate(
export async function cmdList(opts: GlobalOpts) {
const lock = await readLockfile(opts.workdir);
const entries = Object.entries(lock.skills);
const manualSkills = await listManualSkills(opts.dir, new Set(Object.keys(lock.skills)));
const trackedTargets = new Set(
Object.keys(lock.skills).map((entry) => {
const ref = parseSkillRefOrFail(entry);
return ref.sourceRef ? ref.slug : entry;
}),
);
const manualSkills = await listManualSkills(opts.dir, trackedTargets);
if (entries.length === 0 && manualSkills.length === 0) {
console.log("No installed skills.");
return;
@@ -1215,15 +1431,78 @@ async function resolveLatestSkillInstall(
);
}
async function resolveSkillsShCatalogInstall(registry: string, ref: SkillRef, token?: string) {
if (!ref.sourceRef || !ref.skillsSh) {
fail("Invalid skills.sh ref: use skills-sh/owner/repo/slug");
}
const path = `${ApiRoutes.skillsSh}/${encodeURIComponent(
ref.skillsSh.owner,
)}/${encodeURIComponent(ref.skillsSh.repo)}/${encodeURIComponent(ref.slug)}/install`;
const resolution = await apiRequest(
registry,
{ method: "GET", path, token },
ApiV1SkillInstallResolveResponseSchema,
);
if (!resolution.ok) fail(resolution.message);
if (resolution.installKind !== "github") {
fail("skills.sh catalog resolver did not return a GitHub install");
}
return resolution;
}
async function installGitHubSkill(
registry: string,
resolution: GitHubInstallResolution,
target: string,
options: { expectedContentHash?: string } = {},
) {
const zip = await fetchBinary(registry, {
url: gitHubZipUrl(resolution.github.repo, resolution.github.commit),
});
await extractGitHubZipPathToDir(zip, target, resolution.github.path);
if (options.expectedContentHash) {
const installed = hashSkillFiles(await listSkillFiles(target));
const actualContentHash = buildGitHubFolderContentHash(installed.files);
if (actualContentHash !== options.expectedContentHash.toLowerCase()) {
await rm(target, { recursive: true, force: true });
fail("Downloaded skills.sh folder hash does not match the approved ClawHub resolver");
}
}
}
function assertSkillsShTargetOwnership(args: {
dir: string;
lock: { skills: Record<string, { sourceRef?: string }> };
lockKey: string;
requested: SkillRef;
target: string;
targetExists: boolean;
existingOrigin: Awaited<ReturnType<typeof readSkillOrigin>>;
}) {
if (!args.requested.sourceRef) return;
for (const [key, entry] of Object.entries(args.lock.skills)) {
if (key === args.lockKey) continue;
const identity = entry.sourceRef ?? key;
if (!isSafeSkillIdentity(identity)) continue;
const ref = parseSkillRefOrFail(identity);
if (skillTarget(args.dir, ref) === args.target) {
fail(`Install target collision: ${args.target} is owned by ${identity}`);
}
}
if (!args.targetExists) return;
const lockedSource = args.lock.skills[args.lockKey]?.sourceRef;
if (
args.existingOrigin?.sourceRef &&
args.existingOrigin.sourceRef !== args.requested.sourceRef
) {
fail(`Install target collision: ${args.target} is owned by ${args.existingOrigin.sourceRef}`);
}
if (!args.existingOrigin?.sourceRef && lockedSource !== args.requested.sourceRef) {
const owner = args.existingOrigin?.ownerHandle
? `@${args.existingOrigin.ownerHandle}/${args.existingOrigin.slug}`
: (args.existingOrigin?.slug ?? "another local skill");
fail(`Install target collision: ${args.target} is owned by ${owner}`);
}
}
async function installSkillWithOptionalStaging(
+1
View File
@@ -17,6 +17,7 @@ export const ApiRoutes = {
download: "/api/v1/download",
publishTokenMint: "/api/v1/publish/token/mint",
skills: "/api/v1/skills",
skillsSh: "/api/v1/skills-sh",
skillScans: "/api/v1/skills/-/scan",
packages: "/api/v1/packages",
codePlugins: "/api/v1/code-plugins",
+38
View File
@@ -24,6 +24,7 @@ export const LockfileSchema = type({
version: "string|null",
installedAt: "number",
ownerHandle: "string?",
sourceRef: "string?",
pinned: "boolean?",
pinReason: "string?",
},
@@ -163,10 +164,47 @@ export const ApiV1SkillInstallResolveResponseSchema = type({
export type ApiV1SkillInstallResolveResponse =
(typeof ApiV1SkillInstallResolveResponseSchema)[inferred];
export const ApiV1SkillsShCatalogEntrySchema = type({
ref: "string",
route: "string",
displayName: "string",
summary: "string",
owner: {
handle: "string",
githubUrl: "string",
},
repository: "string",
githubPath: "string",
githubCommit: "string",
githubContentHash: "string",
sourceUrl: "string",
installs: "number",
security: {
verdict: '"clean"|"suspicious"',
source: '"clawhub"',
attemptId: "string",
scannedAt: "number",
},
install: {
ok: "true",
slug: "string",
installKind: '"github"',
github: {
repo: "string",
path: "string",
commit: "string",
contentHash: "string",
sourceUrl: "string",
},
},
});
export type ApiV1SkillsShCatalogEntry = (typeof ApiV1SkillsShCatalogEntrySchema)[inferred];
export const CliTelemetryInstallRequestSchema = type({
event: '"install"',
slug: "string",
ownerHandle: "string?",
sourceRef: "string?",
version: "string?",
// Deprecated compatibility fields accepted and ignored by the backend.
rootId: "string?",
+12
View File
@@ -16,6 +16,7 @@ export type SkillOrigin = {
registry: string;
slug: string;
ownerHandle?: string;
sourceRef?: string;
installedVersion: string;
installedAt: number;
fingerprint?: string;
@@ -152,6 +153,16 @@ export function hashSkillFiles(files: Array<{ relPath: string; bytes: Uint8Array
return { files: hashed, fingerprint: buildSkillFingerprint(hashed) };
}
export function buildGitHubFolderContentHash(
files: Array<{ path: string; sha256: string; size: number }>,
) {
const payload = [...files]
.sort((left, right) => left.path.localeCompare(right.path))
.map((file) => `${file.path}\0${file.size}\0${file.sha256.toLowerCase()}`)
.join("\n");
return createHash("sha256").update(payload).digest("hex");
}
export function hashSkillZip(zipBytes: Uint8Array) {
const entries = unzipSync(zipBytes);
const hashed = Object.entries(entries)
@@ -205,6 +216,7 @@ export async function readSkillOrigin(skillFolder: string): Promise<SkillOrigin
registry: parsed.registry,
slug: parsed.slug,
ownerHandle: typeof parsed.ownerHandle === "string" ? parsed.ownerHandle : undefined,
sourceRef: typeof parsed.sourceRef === "string" ? parsed.sourceRef : undefined,
installedVersion: parsed.installedVersion,
installedAt: parsed.installedAt,
fingerprint: typeof parsed.fingerprint === "string" ? parsed.fingerprint : undefined,
+1
View File
@@ -16,6 +16,7 @@ export declare const ApiRoutes: {
readonly download: "/api/v1/download";
readonly publishTokenMint: "/api/v1/publish/token/mint";
readonly skills: "/api/v1/skills";
readonly skillsSh: "/api/v1/skills-sh";
readonly skillScans: "/api/v1/skills/-/scan";
readonly plugins: "/api/v1/plugins";
readonly pluginsExport: "/api/v1/plugins/export";
+1
View File
@@ -16,6 +16,7 @@ export const ApiRoutes = {
download: "/api/v1/download",
publishTokenMint: "/api/v1/publish/token/mint",
skills: "/api/v1/skills",
skillsSh: "/api/v1/skills-sh",
skillScans: "/api/v1/skills/-/scan",
plugins: "/api/v1/plugins",
pluginsExport: "/api/v1/plugins/export",
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,eAAe,GAAG;IAC7B,QAAQ,EAAE,eAAe;IACzB,MAAM,EAAE,aAAa;IACrB,KAAK,EAAE,YAAY;IACnB,YAAY,EAAE,oBAAoB;IAClC,SAAS,EAAE,iBAAiB;IAC5B,YAAY,EAAE,qBAAqB;IACnC,UAAU,EAAE,kBAAkB;IAC9B,mBAAmB,EAAE,4BAA4B;IACjD,cAAc,EAAE,uBAAuB;IACvC,gBAAgB,EAAE,yBAAyB;CACnC,CAAC;AAEX,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,gBAAgB,EAAE,4BAA4B;IAC9C,MAAM,EAAE,gBAAgB;IACxB,UAAU,EAAE,uBAAuB;IACnC,OAAO,EAAE,iBAAiB;IAC1B,aAAa,EAAE,wBAAwB;IACvC,QAAQ,EAAE,kBAAkB;IAC5B,WAAW,EAAE,sBAAsB;IACnC,aAAa,EAAE,wBAAwB;IACvC,UAAU,EAAE,oBAAoB;IAChC,WAAW,EAAE,uBAAuB;IACpC,iBAAiB,EAAE,sBAAsB;IACzC,cAAc,EAAE,0BAA0B;IAC1C,KAAK,EAAE,eAAe;IACtB,SAAS,EAAE,mBAAmB;IAC9B,UAAU,EAAE,oBAAoB;IAChC,KAAK,EAAE,eAAe;IACtB,aAAa,EAAE,wBAAwB;IACvC,MAAM,EAAE,gBAAgB;IACxB,YAAY,EAAE,uBAAuB;CAC7B,CAAC"}
{"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,eAAe,GAAG;IAC7B,QAAQ,EAAE,eAAe;IACzB,MAAM,EAAE,aAAa;IACrB,KAAK,EAAE,YAAY;IACnB,YAAY,EAAE,oBAAoB;IAClC,SAAS,EAAE,iBAAiB;IAC5B,YAAY,EAAE,qBAAqB;IACnC,UAAU,EAAE,kBAAkB;IAC9B,mBAAmB,EAAE,4BAA4B;IACjD,cAAc,EAAE,uBAAuB;IACvC,gBAAgB,EAAE,yBAAyB;CACnC,CAAC;AAEX,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,gBAAgB,EAAE,4BAA4B;IAC9C,MAAM,EAAE,gBAAgB;IACxB,QAAQ,EAAE,mBAAmB;IAC7B,UAAU,EAAE,uBAAuB;IACnC,OAAO,EAAE,iBAAiB;IAC1B,aAAa,EAAE,wBAAwB;IACvC,QAAQ,EAAE,kBAAkB;IAC5B,WAAW,EAAE,sBAAsB;IACnC,aAAa,EAAE,wBAAwB;IACvC,UAAU,EAAE,oBAAoB;IAChC,WAAW,EAAE,uBAAuB;IACpC,iBAAiB,EAAE,sBAAsB;IACzC,cAAc,EAAE,0BAA0B;IAC1C,KAAK,EAAE,eAAe;IACtB,SAAS,EAAE,mBAAmB;IAC9B,UAAU,EAAE,oBAAoB;IAChC,KAAK,EAAE,eAAe;IACtB,aAAa,EAAE,wBAAwB;IACvC,MAAM,EAAE,gBAAgB;IACxB,YAAY,EAAE,uBAAuB;CAC7B,CAAC"}
+37
View File
@@ -21,6 +21,7 @@ export declare const LockfileSchema: import("arktype/internal/variants/object.ts
version: string | null;
installedAt: number;
ownerHandle?: string | undefined;
sourceRef?: string | undefined;
pinned?: boolean | undefined;
pinReason?: string | undefined;
};
@@ -160,10 +161,46 @@ export declare const ApiV1SkillInstallResolveResponseSchema: import("arktype/int
status: number;
}, {}>;
export type ApiV1SkillInstallResolveResponse = (typeof ApiV1SkillInstallResolveResponseSchema)[inferred];
export declare const ApiV1SkillsShCatalogEntrySchema: import("arktype/internal/variants/object.ts").ObjectType<{
ref: string;
route: string;
displayName: string;
summary: string;
owner: {
handle: string;
githubUrl: string;
};
repository: string;
githubPath: string;
githubCommit: string;
githubContentHash: string;
sourceUrl: string;
installs: number;
security: {
verdict: "clean" | "suspicious";
source: "clawhub";
attemptId: string;
scannedAt: number;
};
install: {
ok: true;
slug: string;
installKind: "github";
github: {
repo: string;
path: string;
commit: string;
contentHash: string;
sourceUrl: string;
};
};
}, {}>;
export type ApiV1SkillsShCatalogEntry = (typeof ApiV1SkillsShCatalogEntrySchema)[inferred];
export declare const CliTelemetryInstallRequestSchema: import("arktype/internal/variants/object.ts").ObjectType<{
event: "install";
slug: string;
ownerHandle?: string | undefined;
sourceRef?: string | undefined;
version?: string | undefined;
rootId?: string | undefined;
rootLabel?: string | undefined;
+36
View File
@@ -20,6 +20,7 @@ export const LockfileSchema = type({
version: "string|null",
installedAt: "number",
ownerHandle: "string?",
sourceRef: "string?",
pinned: "boolean?",
pinReason: "string?",
},
@@ -138,10 +139,45 @@ export const ApiV1SkillInstallResolveResponseSchema = type({
message: "string",
status: "number",
});
export const ApiV1SkillsShCatalogEntrySchema = type({
ref: "string",
route: "string",
displayName: "string",
summary: "string",
owner: {
handle: "string",
githubUrl: "string",
},
repository: "string",
githubPath: "string",
githubCommit: "string",
githubContentHash: "string",
sourceUrl: "string",
installs: "number",
security: {
verdict: '"clean"|"suspicious"',
source: '"clawhub"',
attemptId: "string",
scannedAt: "number",
},
install: {
ok: "true",
slug: "string",
installKind: '"github"',
github: {
repo: "string",
path: "string",
commit: "string",
contentHash: "string",
sourceUrl: "string",
},
},
});
export const CliTelemetryInstallRequestSchema = type({
event: '"install"',
slug: "string",
ownerHandle: "string?",
sourceRef: "string?",
version: "string?",
// Deprecated compatibility fields accepted and ignored by the backend.
rootId: "string?",
File diff suppressed because one or more lines are too long
+1
View File
@@ -17,6 +17,7 @@ export const ApiRoutes = {
download: "/api/v1/download",
publishTokenMint: "/api/v1/publish/token/mint",
skills: "/api/v1/skills",
skillsSh: "/api/v1/skills-sh",
skillScans: "/api/v1/skills/-/scan",
plugins: "/api/v1/plugins",
pluginsExport: "/api/v1/plugins/export",
+13 -2
View File
@@ -231,7 +231,13 @@ describe("clawhub-schema", () => {
it("accepts current and legacy install telemetry payloads", () => {
const current = parseArk(
CliTelemetryInstallRequestSchema,
{ event: "install", slug: "demo", ownerHandle: "alice", version: "1.0.0" },
{
event: "install",
slug: "demo",
ownerHandle: "alice",
sourceRef: "skills-sh/alice/skills/demo",
version: "1.0.0",
},
"Install telemetry",
);
const legacy = parseArk(
@@ -248,7 +254,12 @@ describe("clawhub-schema", () => {
"Install telemetry",
);
expect(current).toMatchObject({ event: "install", slug: "demo", ownerHandle: "alice" });
expect(current).toMatchObject({
event: "install",
slug: "demo",
ownerHandle: "alice",
sourceRef: "skills-sh/alice/skills/demo",
});
expect(legacy).toMatchObject({ roots: [{ rootId: "root" }] });
});
+38
View File
@@ -25,6 +25,7 @@ export const LockfileSchema = type({
version: "string|null",
installedAt: "number",
ownerHandle: "string?",
sourceRef: "string?",
pinned: "boolean?",
pinReason: "string?",
},
@@ -164,10 +165,47 @@ export const ApiV1SkillInstallResolveResponseSchema = type({
export type ApiV1SkillInstallResolveResponse =
(typeof ApiV1SkillInstallResolveResponseSchema)[inferred];
export const ApiV1SkillsShCatalogEntrySchema = type({
ref: "string",
route: "string",
displayName: "string",
summary: "string",
owner: {
handle: "string",
githubUrl: "string",
},
repository: "string",
githubPath: "string",
githubCommit: "string",
githubContentHash: "string",
sourceUrl: "string",
installs: "number",
security: {
verdict: '"clean"|"suspicious"',
source: '"clawhub"',
attemptId: "string",
scannedAt: "number",
},
install: {
ok: "true",
slug: "string",
installKind: '"github"',
github: {
repo: "string",
path: "string",
commit: "string",
contentHash: "string",
sourceUrl: "string",
},
},
});
export type ApiV1SkillsShCatalogEntry = (typeof ApiV1SkillsShCatalogEntrySchema)[inferred];
export const CliTelemetryInstallRequestSchema = type({
event: '"install"',
slug: "string",
ownerHandle: "string?",
sourceRef: "string?",
version: "string?",
// Deprecated compatibility fields accepted and ignored by the backend.
rootId: "string?",
+17
View File
@@ -250,6 +250,23 @@ returns:
OpenClaw downloads the GitHub archive for that commit and extracts only the skill
path. The local lock/origin version is the commit SHA.
Controlled unclaimed skills.sh catalog entries use the repository-qualified
reference `skills-sh/<owner>/<repo>/<slug>`. The colon form
`skills-sh:<owner>/<repo>/<slug>` is invalid and must be rejected by clients and
HTTP handlers.
In Local and the permanent Test environment, an unclaimed catalog entry remains
hidden and non-installable until a real low-priority ClawHub scan completes for
the exact catalog identity, immutable GitHub owner ID, repository, path, commit,
folder content hash, source content hash, and scan attempt. Clean and suspicious
verdicts may publish only that exact attempt. Malicious, failed, canceled, or
stale callbacks cannot publish it.
The public Test route is `/skills-sh/<owner>/<repo>/<slug>`. Its install resolver
returns the same commit-pinned GitHub descriptor used by native GitHub-backed
skills. Catalog pause, kill, publication disable, and exact-attempt rollback
must fail closed without disabling or mutating native scan work.
Pending verification keeps the skill visible in ClawHub search and detail UI,
but normal install/update returns a structured block:
+538 -517
View File
File diff suppressed because it is too large Load Diff
+118
View File
@@ -0,0 +1,118 @@
import { createFileRoute, notFound } from "@tanstack/react-router";
import { ExternalLink, ShieldAlert, ShieldCheck } from "lucide-react";
import { api } from "../../../../../convex/_generated/api";
import { InstallCopyButton } from "../../../../components/InstallCopyButton";
import { Container } from "../../../../components/layout/Container";
import { convexHttp } from "../../../../convex/client";
export const Route = createFileRoute("/skills-sh/$owner/$repo/$slug")({
loader: async ({ params }) => {
const entry = await convexHttp.query(api.skillsShCatalog.getPublicEntry, params);
if (!entry) throw notFound();
return entry;
},
head: ({ loaderData }) => ({
meta: [
{ title: `${loaderData?.displayName ?? "Skill"} - ClawHub` },
{ name: "description", content: loaderData?.summary ?? "ClawHub verified skill" },
],
}),
component: SkillsShCatalogEntryPage,
});
function SkillsShCatalogEntryPage() {
const entry = Route.useLoaderData();
const installCommand = `openclaw skills install ${entry.ref}`;
const suspicious = entry.security.verdict === "suspicious";
const VerdictIcon = suspicious ? ShieldAlert : ShieldCheck;
return (
<main className="py-10 sm:py-14">
<Container size="narrow">
<div className="flex flex-col gap-5 border-b border-[color:var(--oc-border-subtle)] pb-7 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<p className="break-all font-mono text-xs text-[color:var(--oc-text-muted)]">
{entry.ref}
</p>
<h1 className="mt-2 font-display text-3xl font-black leading-tight text-[color:var(--oc-text-primary)] sm:text-4xl">
{entry.displayName}
</h1>
<p className="mt-3 max-w-2xl text-sm leading-6 text-[color:var(--oc-text-secondary)] sm:text-base">
{entry.summary}
</p>
</div>
<div
className={`inline-flex min-h-8 shrink-0 items-center gap-2 self-start rounded-[var(--oc-radius-control)] border px-3 text-sm font-semibold ${
suspicious
? "border-status-warning-fg/30 bg-status-warning-bg text-status-warning-fg"
: "border-status-success-fg/30 bg-status-success-bg text-status-success-fg"
}`}
>
<VerdictIcon aria-hidden="true" size={16} />
ClawHub {entry.security.verdict}
</div>
</div>
<dl className="grid grid-cols-1 gap-x-8 gap-y-6 py-7 sm:grid-cols-2">
<div className="min-w-0">
<dt className="text-xs font-semibold text-[color:var(--oc-text-muted)]">Owner</dt>
<dd className="mt-1 min-w-0 text-sm text-[color:var(--oc-text-primary)]">
<a
className="inline-flex max-w-full items-center gap-1.5 hover:text-[color:var(--oc-accent-primary)]"
href={entry.owner.githubUrl}
target="_blank"
rel="noreferrer"
>
{entry.owner.handle}
<ExternalLink aria-hidden="true" size={14} />
</a>
</dd>
</div>
<div className="min-w-0">
<dt className="text-xs font-semibold text-[color:var(--oc-text-muted)]">Repository</dt>
<dd className="mt-1 break-all font-mono text-sm text-[color:var(--oc-text-primary)]">
{entry.repository}
</dd>
</div>
<div className="min-w-0">
<dt className="text-xs font-semibold text-[color:var(--oc-text-muted)]">Path</dt>
<dd className="mt-1 break-all font-mono text-sm text-[color:var(--oc-text-primary)]">
{entry.githubPath}
</dd>
</div>
<div className="min-w-0">
<dt className="text-xs font-semibold text-[color:var(--oc-text-muted)]">Commit</dt>
<dd className="mt-1 min-w-0">
<code className="block break-all text-sm text-[color:var(--oc-text-primary)]">
{entry.githubCommit}
</code>
</dd>
</div>
</dl>
<section
className="border-t border-[color:var(--oc-border-subtle)] pt-7"
aria-labelledby="skills-sh-install-title"
>
<h2
id="skills-sh-install-title"
className="font-display text-lg font-bold text-[color:var(--oc-text-primary)]"
>
Install
</h2>
<div className="mt-3 flex min-w-0 items-center gap-2 rounded-[var(--oc-radius-inset)] border border-[color:var(--oc-border-subtle)] bg-[color:var(--oc-bg-surface)] p-2 pl-3">
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap font-mono text-sm text-[color:var(--oc-text-primary)]">
{installCommand}
</code>
<InstallCopyButton
text={installCommand}
ariaLabel="Copy OpenClaw install command"
showLabel={false}
variant="ghost"
size="icon-sm"
/>
</div>
</section>
</Container>
</main>
);
}