mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
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:
Vendored
+2
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -332,6 +332,7 @@ async function cliTelemetryInstallHandler(ctx: ActionCtx, request: Request) {
|
||||
userId,
|
||||
slug: args.slug,
|
||||
ownerHandle: args.ownerHandle,
|
||||
sourceRef: args.sourceRef,
|
||||
version: args.version,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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 };
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Vendored
+1
@@ -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";
|
||||
|
||||
Vendored
+1
@@ -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",
|
||||
|
||||
Vendored
+1
-1
@@ -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"}
|
||||
Vendored
+37
@@ -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;
|
||||
|
||||
Vendored
+36
@@ -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?",
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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",
|
||||
|
||||
@@ -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" }] });
|
||||
});
|
||||
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user