feat: claim skills.sh listings through GitHub Skill Sync (#3230)

* feat: add verified mirrored skill adoption state

* feat: add mirrored skill adoption preview

* feat: route skills.sh claims through GitHub sync

* ci: allow guarded CLAW-560 Test deploy

* fix: canonicalize claimed GitHub source repos

* fix: use public GitHub auth for skill sync

* fix: authenticate public GitHub source reads
This commit is contained in:
Patrick Erichsen
2026-07-25 10:32:09 -05:00
committed by GitHub
parent a9a80bbf6d
commit 79cdd938c4
15 changed files with 540 additions and 37 deletions
+11 -1
View File
@@ -37,7 +37,9 @@ jobs:
(github.ref == 'refs/heads/pe/claw-577-canonical-mixed-search' &&
inputs.branch_test_confirm == 'deploy-claw-577-to-permanent-test') ||
(github.ref == 'refs/heads/pe/claw-583-mirrored-search-journey' &&
inputs.branch_test_confirm == 'deploy-claw-583-to-permanent-test')) &&
inputs.branch_test_confirm == 'deploy-claw-583-to-permanent-test') ||
(github.ref == 'refs/heads/pe/claw-560-verified-adoption' &&
inputs.branch_test_confirm == 'deploy-claw-560-to-permanent-test')) &&
github.actor == 'Patrick-Erichsen' &&
inputs.expected_sha != '')) ||
(github.event_name == 'pull_request' &&
@@ -116,6 +118,14 @@ jobs:
then
branch_test_allowed=true
fi
if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch ]] &&
[[ "$GITHUB_REF" == refs/heads/pe/claw-560-verified-adoption ]] &&
[[ "$GITHUB_ACTOR" == Patrick-Erichsen ]] &&
[[ "${{ inputs.branch_test_confirm }}" == deploy-claw-560-to-permanent-test ]] &&
[[ "${{ inputs.expected_sha }}" == "$deploy_sha" ]]
then
branch_test_allowed=true
fi
if [[ "$GITHUB_EVENT_NAME" == pull_request ]] &&
[[ "$GITHUB_HEAD_REF" == pe/claw-563-skills-sh-mirror-10k ]] &&
[[ "$GITHUB_ACTOR" == Patrick-Erichsen ]] &&
+140 -9
View File
@@ -1,3 +1,4 @@
import { generateKeyPairSync } from "node:crypto";
import { getFunctionName } from "convex/server";
import { ConvexError } from "convex/values";
import { zipSync } from "fflate";
@@ -282,17 +283,26 @@ describe("buildGitHubSourceImport", () => {
});
describe("buildGitHubSkillSourceFetch", () => {
it("attaches configured GitHub auth to API and archive requests only", async () => {
it("uses public API auth without sending OAuth app credentials to codeload", async () => {
const previousEnv = {
token: process.env.GITHUB_TOKEN,
appId: process.env.GITHUB_APP_ID,
installationId: process.env.GITHUB_APP_INSTALLATION_ID,
privateKey: process.env.GITHUB_APP_PRIVATE_KEY,
oauthClientId: process.env.AUTH_GITHUB_ID,
oauthClientSecret: process.env.AUTH_GITHUB_SECRET,
};
process.env.GITHUB_TOKEN = "github-token";
delete process.env.GITHUB_APP_ID;
delete process.env.GITHUB_APP_INSTALLATION_ID;
delete process.env.GITHUB_APP_PRIVATE_KEY;
delete process.env.GITHUB_TOKEN;
const { privateKey } = generateKeyPairSync("rsa", {
modulusLength: 2048,
privateKeyEncoding: { type: "pkcs1", format: "pem" },
publicKeyEncoding: { type: "spki", format: "pem" },
});
process.env.GITHUB_APP_ID = "3536245";
process.env.GITHUB_APP_INSTALLATION_ID = "987654";
process.env.GITHUB_APP_PRIVATE_KEY = privateKey;
process.env.AUTH_GITHUB_ID = "oauth-client-id";
process.env.AUTH_GITHUB_SECRET = "oauth-client-secret";
const fetcher = vi.fn(async () => new Response("ok"));
const wrapped = __test.buildGitHubSkillSourceFetch(fetcher as unknown as typeof fetch);
@@ -311,15 +321,22 @@ describe("buildGitHubSkillSourceFetch", () => {
else process.env.GITHUB_APP_INSTALLATION_ID = previousEnv.installationId;
if (previousEnv.privateKey === undefined) delete process.env.GITHUB_APP_PRIVATE_KEY;
else process.env.GITHUB_APP_PRIVATE_KEY = previousEnv.privateKey;
if (previousEnv.oauthClientId === undefined) delete process.env.AUTH_GITHUB_ID;
else process.env.AUTH_GITHUB_ID = previousEnv.oauthClientId;
if (previousEnv.oauthClientSecret === undefined) delete process.env.AUTH_GITHUB_SECRET;
else process.env.AUTH_GITHUB_SECRET = previousEnv.oauthClientSecret;
}
const calls = fetcher.mock.calls as unknown as Array<[RequestInfo | URL, RequestInit?]>;
expect(calls).toHaveLength(3);
const firstHeaders = calls[0]?.[1]?.headers as Headers;
const secondHeaders = calls[1]?.[1]?.headers as Headers;
const thirdInit = calls[2]?.[1];
expect(firstHeaders.get("Authorization")).toBe("Bearer github-token");
expect(firstHeaders.get("Authorization")).toBe(
`Basic ${btoa("oauth-client-id:oauth-client-secret")}`,
);
expect(firstHeaders.get("Accept")).toBe("application/vnd.github+json");
expect(secondHeaders.get("Authorization")).toBe("Bearer github-token");
expect(secondHeaders.get("Authorization")).toBeNull();
expect(secondHeaders.get("User-Agent")).toBe("clawhub/github-skill-source");
expect(thirdInit).toBeUndefined();
});
@@ -348,7 +365,13 @@ describe("configurePublicGitHubSkillSourceHandler", () => {
expect(runMutation).not.toHaveBeenCalled();
});
it("configures any public GitHub repo for an official publisher the user can manage", async () => {
it("uses Test OAuth app auth and stores a canonical lowercase repo identity", async () => {
vi.stubEnv("GITHUB_TOKEN", "");
vi.stubEnv("GITHUB_APP_ID", "");
vi.stubEnv("GITHUB_APP_INSTALLATION_ID", "");
vi.stubEnv("GITHUB_APP_PRIVATE_KEY", "");
vi.stubEnv("AUTH_GITHUB_ID", "oauth-client-id");
vi.stubEnv("AUTH_GITHUB_SECRET", "oauth-client-secret");
const zip = zipSync({
"skills-main/skills/aiq-deploy/SKILL.md": new TextEncoder().encode("# AIQ Deploy\n"),
});
@@ -413,10 +436,16 @@ describe("configurePublicGitHubSkillSourceHandler", () => {
);
expect(result).toEqual({ ok: true, stats: { discovered: 1 } });
const calls = fetchMock.mock.calls as unknown as Array<[RequestInfo | URL, RequestInit?]>;
const expectedAuthorization = `Basic ${btoa("oauth-client-id:oauth-client-secret")}`;
expect(new Headers(calls[0]?.[1]?.headers).get("Authorization")).toBe(expectedAuthorization);
expect(new Headers(calls[1]?.[1]?.headers).get("Authorization")).toBe(expectedAuthorization);
expect(new Headers(calls[2]?.[1]?.headers).get("Authorization")).toBeNull();
expect(new Headers(calls[3]?.[1]?.headers).get("Authorization")).toBe(expectedAuthorization);
expect(runMutation).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
repo: "SomeoneElse/public-skills",
repo: "someoneelse/public-skills",
ownerUserId: "users:publisher-owner",
ownerPublisherId: "publishers:local",
snapshot: expect.objectContaining({
@@ -434,6 +463,108 @@ describe("configurePublicGitHubSkillSourceHandler", () => {
);
});
it("rejects a changed skills.sh selection before applying repository writes", async () => {
const zip = zipSync({
"skills-main/skills/html/SKILL.md": new TextEncoder().encode("# HTML\n"),
});
const runQuery = vi.fn(async () => ({
ownerUserId: "users:publisher-owner",
existingSource: null,
}));
const runMutation = vi.fn();
const fetchMock = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({
id: 101,
full_name: "patrick-erichsen/skills",
owner: { id: 201 },
private: false,
visibility: "public",
default_branch: "main",
disabled: false,
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ sha: "1".repeat(40) }),
})
.mockResolvedValueOnce({
ok: true,
headers: new Headers({ "content-length": String(zip.byteLength) }),
body: null,
arrayBuffer: async () => zip.buffer.slice(zip.byteOffset, zip.byteOffset + zip.byteLength),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
id: 101,
full_name: "patrick-erichsen/skills",
owner: { id: 201 },
private: false,
visibility: "public",
default_branch: "main",
disabled: false,
}),
});
await expect(
configurePublicGitHubSkillSourceHandler(
{ runQuery, runMutation, auth: { getUserIdentity: vi.fn() } } as never,
{
ownerPublisherId: "publishers:local" as never,
repo: "patrick-erichsen/skills",
expectedSkillsShSource: {
repo: "patrick-erichsen/skills",
externalId: "patrick-erichsen/skills/html",
path: "skills/html",
commit: "2".repeat(40),
contentHash: "3".repeat(64),
},
},
fetchMock as never,
{ userId: "users:actor" as never },
),
).rejects.toThrow(/changed since this skills\.sh listing was observed/i);
expect(fetchMock).toHaveBeenCalledTimes(4);
expect(runMutation).not.toHaveBeenCalled();
});
it("matches a skills.sh selection by exact slug, path, commit, and content hash", async () => {
const snapshot = await buildGitHubSkillSourceSnapshot({
repo: "patrick-erichsen/skills",
defaultBranch: "main",
commit: "1".repeat(40),
entries: {
"skills/html/SKILL.md": new TextEncoder().encode("---\nname: html\n---\n# HTML\n"),
},
});
const contentHash = snapshot.skills[0]?.contentHash;
if (!contentHash) throw new Error("missing fixture hash");
const exact = {
repo: "patrick-erichsen/skills",
externalId: "patrick-erichsen/skills/html",
path: "skills/html",
commit: snapshot.commit,
contentHash,
};
expect(() => __test.assertExactSkillsShSourceSelection(snapshot, exact)).not.toThrow();
for (const changed of [
{ ...exact, repo: "openclaw/openclaw" },
{ ...exact, externalId: "patrick-erichsen/skills/other" },
{ ...exact, path: "skills/other" },
{ ...exact, commit: "2".repeat(40) },
{ ...exact, contentHash: "3".repeat(64) },
]) {
expect(() => __test.assertExactSkillsShSourceSelection(snapshot, changed)).toThrow(
/changed since this skills\.sh listing was observed/i,
);
}
});
it("prefers nested catalog skill paths over duplicate plugin package copies", async () => {
const zip = zipSync({
"repo-main/plugins/aws-core/skills/amazon-bedrock/SKILL.md": new TextEncoder().encode(
+85 -9
View File
@@ -139,6 +139,14 @@ type GitHubSkillSourceSetupContext = {
existingSource: SourceForSync | null;
};
type ExpectedSkillsShSource = {
repo: string;
externalId: string;
path: string;
commit: string;
contentHash: string;
};
type GitHubSkillVerificationTarget = {
skill: Pick<Doc<"skills">, "_id" | "slug" | "displayName" | "summary"> & {
githubPath: string;
@@ -1820,7 +1828,11 @@ export async function verifyGitHubSkillHandler(
export async function configurePublicGitHubSkillSourceHandler(
ctx: ActionCtx,
args: { ownerPublisherId: Id<"publishers">; repo: string },
args: {
ownerPublisherId: Id<"publishers">;
repo: string;
expectedSkillsShSource?: ExpectedSkillsShSource;
},
fetcher: typeof fetch = fetch,
authOverride?: { userId: Id<"users"> },
): Promise<SyncOneResult> {
@@ -1849,12 +1861,16 @@ export async function configurePublicGitHubSkillSourceHandler(
fetcher,
);
const revalidatedMetadata = await revalidateGitHubRepoMetadata(metadata, fetcher);
if (args.expectedSkillsShSource) {
assertExactSkillsShSourceSelection(snapshot, args.expectedSkillsShSource);
}
if (snapshot.skills.length === 0) {
throw new ConvexError("No skills were found in that public GitHub repo.");
}
const canonicalRepo = normalizeRepo(revalidatedMetadata.repo).toLowerCase();
return await applyFetchedGitHubSkillSourceSnapshot(ctx, {
sourceId: setup.existingSource?._id,
repo: revalidatedMetadata.repo,
repo: canonicalRepo,
ownerUserId: setup.ownerUserId,
ownerPublisherId: args.ownerPublisherId,
githubRepositoryId: revalidatedMetadata.repositoryId,
@@ -1867,6 +1883,15 @@ export const configurePublicGitHubSkillSource: ReturnType<typeof action> = actio
args: {
ownerPublisherId: v.id("publishers"),
repo: v.string(),
expectedSkillsShSource: v.optional(
v.object({
repo: v.string(),
externalId: v.string(),
path: v.string(),
commit: v.string(),
contentHash: v.string(),
}),
),
},
handler: async (ctx, args): Promise<SyncOneResult> =>
configurePublicGitHubSkillSourceHandler(ctx, args),
@@ -2350,7 +2375,7 @@ async function fetchPublicGitHubRepoMetadata(
const response = await fetcher(
`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}`,
{
headers: await buildGitHubSkillSourceHeaders(fetcher),
headers: await buildGitHubSkillSourceHeaders(fetcher, true),
},
);
if (!response.ok) {
@@ -2412,32 +2437,44 @@ async function revalidateGitHubRepoMetadata(expected: GitHubRepoMetadata, fetche
return current;
}
async function buildGitHubSkillSourceHeaders(fetcher: typeof fetch) {
async function buildGitHubSkillSourceHeaders(
fetcher: typeof fetch,
useOAuthAppClientCredentials = false,
) {
return await buildGitHubApiHeaders({
userAgent: "clawhub/github-skill-source",
fetchImpl: fetcher,
// Installation tokens are repository-scoped and cannot reliably read an
// arbitrary public repository selected by a publisher.
useGitHubApp: false,
useOAuthAppClientCredentials,
});
}
function buildGitHubSkillSourceFetch(fetcher: typeof fetch): typeof fetch {
return (async (input: RequestInfo | URL, init?: RequestInit) => {
if (!shouldAttachGitHubSkillSourceHeaders(input)) return fetcher(input, init);
const hostname = getGitHubSkillSourceHostname(input);
if (!hostname) return fetcher(input, init);
const headers = new Headers(init?.headers);
for (const [key, value] of Object.entries(await buildGitHubSkillSourceHeaders(fetcher))) {
for (const [key, value] of Object.entries(
await buildGitHubSkillSourceHeaders(fetcher, hostname === "api.github.com"),
)) {
if (!headers.has(key)) headers.set(key, value);
}
return fetcher(input, { ...init, headers });
}) as typeof fetch;
}
function shouldAttachGitHubSkillSourceHeaders(input: RequestInfo | URL) {
function getGitHubSkillSourceHostname(input: RequestInfo | URL) {
const urlString =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
try {
const url = new URL(urlString);
return url.hostname === "api.github.com" || url.hostname === "codeload.github.com";
return url.hostname === "api.github.com" || url.hostname === "codeload.github.com"
? url.hostname
: null;
} catch {
return false;
return null;
}
}
@@ -2547,6 +2584,44 @@ function normalizeRepoPath(path: string) {
return segments.join("/");
}
function assertExactSkillsShSourceSelection(
snapshot: GitHubSkillSourceSnapshot,
expected: ExpectedSkillsShSource,
) {
const repo = normalizeRepoOrNull(expected.repo);
const externalSegments = expected.externalId.trim().toLowerCase().split("/").filter(Boolean);
const path = normalizeRepoPath(expected.path);
const commit = expected.commit.trim().toLowerCase();
const contentHash = expected.contentHash.trim().toLowerCase();
const expectedSlug = externalSegments.length === 3 ? externalSegments[2] : null;
const matches = snapshot.skills.filter(
(skill) => skill.path === path && skill.slug.toLowerCase() === expectedSlug,
);
if (
!expectedSlug ||
!repo ||
snapshot.repo.trim().toLowerCase() !== repo ||
!path ||
!/^[a-f0-9]{40}$/.test(commit) ||
!/^[a-f0-9]{64}$/.test(contentHash) ||
snapshot.commit.trim().toLowerCase() !== commit ||
matches.length !== 1 ||
matches[0]?.contentHash.trim().toLowerCase() !== contentHash
) {
throw new ConvexError(
"The GitHub source changed since this skills.sh listing was observed. Refresh the listing before claiming it.",
);
}
}
function normalizeRepoOrNull(value: string) {
try {
return normalizeRepo(value).toLowerCase();
} catch {
return null;
}
}
function stripUndefined<T extends Record<string, unknown>>(value: T): Partial<T> {
return Object.fromEntries(
Object.entries(value).filter((entry) => entry[1] !== undefined),
@@ -2554,6 +2629,7 @@ function stripUndefined<T extends Record<string, unknown>>(value: T): Partial<T>
}
export const __test = {
assertExactSkillsShSourceSelection,
buildGitHubSkillSourceFetch,
buildGitHubSourceImport,
normalizeRepo,
+32
View File
@@ -99,4 +99,36 @@ describe("githubAuth", () => {
});
expect(fetchMock).not.toHaveBeenCalled();
});
it("can authenticate public API reads with the configured OAuth app", async () => {
vi.stubEnv("GITHUB_TOKEN", "");
vi.stubEnv("AUTH_GITHUB_ID", "oauth-client-id");
vi.stubEnv("AUTH_GITHUB_SECRET", "oauth-client-secret");
await expect(
buildGitHubApiHeaders({
userAgent: "clawhub/test",
useGitHubApp: false,
useOAuthAppClientCredentials: true,
}),
).resolves.toEqual({
Accept: "application/vnd.github+json",
Authorization: `Basic ${btoa("oauth-client-id:oauth-client-secret")}`,
"User-Agent": "clawhub/test",
});
});
it("prefers a general token over OAuth app client credentials", async () => {
vi.stubEnv("GITHUB_TOKEN", "ghp_pat_token");
vi.stubEnv("AUTH_GITHUB_ID", "oauth-client-id");
vi.stubEnv("AUTH_GITHUB_SECRET", "oauth-client-secret");
await expect(
buildGitHubApiHeaders({
userAgent: "clawhub/test",
useGitHubApp: false,
useOAuthAppClientCredentials: true,
}),
).resolves.toMatchObject({ Authorization: "Bearer ghp_pat_token" });
});
});
+27
View File
@@ -11,6 +11,11 @@ type GitHubAppConfig = {
privateKey: string;
};
type GitHubOAuthAppConfig = {
clientId: string;
clientSecret: string;
};
type InstallationToken = {
token: string;
expiresAt: number;
@@ -33,6 +38,7 @@ export async function buildGitHubApiHeaders(options: {
fetchImpl?: FetchImpl;
allowAnonymous?: boolean;
useGitHubApp?: boolean;
useOAuthAppClientCredentials?: boolean;
}): Promise<Record<string, string>> {
const headers = buildGitHubHeaders({
userAgent: options.userAgent,
@@ -56,6 +62,14 @@ export async function buildGitHubApiHeaders(options: {
return headers;
}
if (options.useOAuthAppClientCredentials) {
const oauthApp = readGitHubOAuthAppConfig(process.env);
if (oauthApp) {
headers.Authorization = `Basic ${base64String(`${oauthApp.clientId}:${oauthApp.clientSecret}`)}`;
return headers;
}
}
if (options.allowAnonymous === false) {
throw new Error("GitHub API authentication is not configured");
}
@@ -157,6 +171,13 @@ function readGitHubAppConfig(env: NodeJS.ProcessEnv): GitHubAppConfig | null {
return { appId, installationId, privateKey };
}
function readGitHubOAuthAppConfig(env: NodeJS.ProcessEnv): GitHubOAuthAppConfig | null {
const clientId = env.AUTH_GITHUB_ID?.trim();
const clientSecret = env.AUTH_GITHUB_SECRET?.trim();
if (!clientId || !clientSecret) return null;
return { clientId, clientSecret };
}
async function createGitHubAppJwt(appId: string, rawPrivateKey: string, nowMs: number) {
const now = Math.floor(nowMs / 1000);
const header = { alg: "RS256", typ: "JWT" };
@@ -249,6 +270,12 @@ function base64UrlString(value: string) {
return base64UrlBytes(new TextEncoder().encode(value));
}
function base64String(value: string) {
let binary = "";
for (const byte of new TextEncoder().encode(value)) binary += String.fromCharCode(byte);
return btoa(binary);
}
function base64UrlBytes(value: Uint8Array) {
let binary = "";
for (const byte of value) binary += String.fromCharCode(byte);
+26
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
buildSkillsShMirrorCatalogDetail,
buildSkillsShCanonicalGitHubRepo,
buildSkillsShMirrorIdentity,
buildUnclaimedSkillsShInstallResolution,
buildUnclaimedSkillsShVerifyResponse,
@@ -85,6 +86,31 @@ describe("skills.sh mirror public contract", () => {
});
});
it("uses the canonical GitHub repository after an upstream redirect", () => {
const redirected = {
...digest,
canonicalRepoUrl: "https://github.com/openclaw/openclaw",
};
expect(buildSkillsShCanonicalGitHubRepo(redirected)).toBe("openclaw/openclaw");
expect(buildUnclaimedSkillsShInstallResolution(redirected)).toMatchObject({
github: { repo: "openclaw/openclaw" },
});
expect(buildSkillsShMirrorCatalogDetail({ digest: redirected, detail })).toMatchObject({
canonicalGitHubRepo: "openclaw/openclaw",
githubContentHash: digest.sourceContentHash,
});
});
it("rejects non-GitHub canonical repository URLs", () => {
expect(
buildSkillsShCanonicalGitHubRepo({
...digest,
canonicalRepoUrl: "https://example.com/openclaw/openclaw",
}),
).toBeNull();
});
it("renders only content whose stored hash matches the digest", () => {
expect(buildSkillsShMirrorCatalogDetail({ digest, detail })).toMatchObject({
summary: "Choose and build HTML artifacts.",
+33 -2
View File
@@ -91,6 +91,32 @@ export function buildSkillsShMirrorIdentity(
};
}
export function buildSkillsShCanonicalGitHubRepo(
digest: Pick<SkillsShMirrorDigest, "canonicalRepoUrl" | "owner" | "repo">,
) {
if (!digest.canonicalRepoUrl) {
const owner = normalizeSegment(digest.owner);
const repo = normalizeSegment(digest.repo);
return owner && repo ? `${owner}/${repo}` : null;
}
try {
const url = new URL(digest.canonicalRepoUrl);
if (url.protocol !== "https:" || !["github.com", "www.github.com"].includes(url.hostname)) {
return null;
}
const segments = url.pathname
.replace(/\.git$/i, "")
.split("/")
.filter(Boolean);
if (segments.length !== 2) return null;
const owner = normalizeSegment(segments[0]);
const repo = normalizeSegment(segments[1]);
return owner && repo ? `${owner}/${repo}` : null;
} catch {
return null;
}
}
export function isPublicSkillsShMirrorDigest(digest: SkillsShMirrorDigest) {
return (
digest.active &&
@@ -135,7 +161,8 @@ export function buildSkillsShMirrorCatalogDetail(args: {
detail: SkillsShMirrorDetail | null;
}) {
const identity = buildSkillsShMirrorIdentity(args.digest);
if (!identity || !isPublicSkillsShMirrorDigest(args.digest)) return null;
const canonicalGitHubRepo = buildSkillsShCanonicalGitHubRepo(args.digest);
if (!identity || !canonicalGitHubRepo || !isPublicSkillsShMirrorDigest(args.digest)) return null;
const digestHash = args.digest.sourceContentHash?.trim().toLowerCase();
const detailHash = args.detail?.sourceContentHash?.trim().toLowerCase();
const content =
@@ -161,8 +188,11 @@ export function buildSkillsShMirrorCatalogDetail(args: {
lastObservedAt: args.digest.lastObservedAt,
sourceUrl: args.digest.sourceUrl,
canonicalRepoUrl: args.digest.canonicalRepoUrl,
canonicalGitHubRepo,
githubPath: args.digest.githubPath,
githubCommit: args.digest.githubCommit,
// The permanent mirror stores the exact GitHub folder hash under this source field.
githubContentHash: args.digest.sourceContentHash,
sourceContentHash: args.digest.sourceContentHash,
upstreamChecks: [
buildUpstreamCheck(UPSTREAM_SCANNERS[0], args.digest.upstreamScanners.genAgentTrustHub),
@@ -197,11 +227,13 @@ export function buildGitHubTreeUrl(repo: string, commit: string, path: string) {
export function buildUnclaimedSkillsShInstallResolution(digest: SkillsShMirrorDigest) {
const identity = buildSkillsShMirrorIdentity(digest);
const repo = buildSkillsShCanonicalGitHubRepo(digest);
const path = normalizeGitHubPath(digest.githubPath);
const commit = digest.githubCommit?.trim().toLowerCase();
const contentHash = digest.sourceContentHash?.trim().toLowerCase();
if (
!identity ||
!repo ||
!isPublicSkillsShMirrorDigest(digest) ||
!path ||
!commit ||
@@ -211,7 +243,6 @@ export function buildUnclaimedSkillsShInstallResolution(digest: SkillsShMirrorDi
) {
return null;
}
const repo = `${identity.owner}/${identity.repo}`;
return {
ok: true as const,
slug: identity.reference,
+5
View File
@@ -16,6 +16,7 @@ describe("skillsShMirrorPublic.getSkillsShMirrorByRoute", () => {
.fn()
.mockResolvedValueOnce({
externalId: "patrick-erichsen/skills/html",
canonicalRepoUrl: "https://github.com/openclaw/openclaw",
githubPath: "skills/html",
publicVisible: false,
installable: false,
@@ -31,6 +32,10 @@ describe("skillsShMirrorPublic.getSkillsShMirrorByRoute", () => {
canonicalRoute: "/openclaw/skills/html",
canonicalRef: "@openclaw/html",
});
expect(runQuery.mock.calls[2]?.[1]).toEqual({
repo: "openclaw/openclaw",
path: "skills/html",
});
});
it("returns null while the skills.sh runtime is disabled", async () => {
+9 -6
View File
@@ -4,6 +4,7 @@ import type { QueryCtx } from "./_generated/server";
import { query } from "./functions";
import { getRuntimeRolloutCapabilities } from "./lib/rolloutCapabilities";
import {
buildSkillsShCanonicalGitHubRepo,
buildSkillsShMirrorCatalogDetail,
type SkillsShMirrorDetail,
type SkillsShMirrorDigest,
@@ -62,12 +63,14 @@ export async function getSkillsShMirrorByRoute(
]);
// A promoted native repo/path remains the canonical compatibility target even
// after the external listing itself is no longer published.
const alias = digest?.githubPath
? ((await ctx.runQuery(
internalRefs.githubSkillSources.getSkillsShAliasTargetInternal as never,
{ repo: `${owner}/${repo}`, path: digest.githubPath } as never,
)) as { canonicalRoute: string; canonicalRef: string } | null)
: null;
const canonicalRepo = digest ? buildSkillsShCanonicalGitHubRepo(digest) : null;
const alias =
digest?.githubPath && canonicalRepo
? ((await ctx.runQuery(
internalRefs.githubSkillSources.getSkillsShAliasTargetInternal as never,
{ repo: canonicalRepo, path: digest.githubPath } as never,
)) as { canonicalRoute: string; canonicalRef: string } | null)
: null;
if (alias) return { kind: "redirect" as const, ...alias };
if (!digest) return null;
const entry = buildSkillsShMirrorCatalogDetail({ digest, detail });
+22
View File
@@ -85,6 +85,28 @@ not create or mutate `skills` rows during its planning gates.
leave native skills, download history, scan jobs, publishers, and aliases
unchanged.
## Claiming mirrored skills.sh listings
Claim is a preselected entry into GitHub Skill Sync, not a separate adoption or
scan state machine. The handoff carries the mirror's external identity plus its
canonical GitHub repository, exact skill path, commit, and content hash.
GitHub Skill Sync re-fetches the repository, revalidates immutable repository
and owner IDs, and rejects the request before writes unless the selected skill
still matches every frozen source field.
After that check, the normal GitHub Skill Sync lifecycle owns creation,
controlled replacement, scanning, and promotion. New destinations remain
hidden while pending. Existing allowed Hosted or GitHub content remains active
while its candidate is pending or rejected. Promotion reuses the existing
`skills` row, so routes, metrics, bookmarks, official state, prior versions,
and audit history stay attached to the canonical identity.
The skills.sh route and `skills-sh:` install reference resolve their canonical
GitHub repository from the stored redirect target. They remain external until
the matching GitHub-backed skill has an allowed ClawHub verdict, then resolve
dynamically by canonical repository plus exact path. No hosted archive or
duplicated alias/adoption row is created for GitHub-backed content.
`skills` stores the public catalog row and install state:
- `installKind: "github"`
+17 -3
View File
@@ -44,9 +44,21 @@ describe("SkillsShCatalogDetailPage", () => {
expect(
screen.getByText("clawhub install skills-sh:patrick-erichsen/skills/html", { exact: true }),
).toBeTruthy();
expect(screen.getByRole("link", { name: "Claim" }).getAttribute("href")).toBe(
"/settings?view=githubSources&repo=patrick-erichsen%2Fskills&sourcePath=skills%2Fhtml",
const claimUrl = new URL(
screen.getByRole("link", { name: "Claim" }).getAttribute("href") ?? "",
"https://clawhub.test",
);
expect(claimUrl.pathname).toBe("/settings");
expect(Object.fromEntries(claimUrl.searchParams)).toEqual({
view: "githubSources",
ownerHandle: "openclaw",
repo: "openclaw/openclaw",
sourceRepo: "openclaw/openclaw",
sourceExternalId: "patrick-erichsen/skills/html",
sourcePath: "skills/html",
sourceCommit: "050daba89f6b6636470add5cb300aac46a412cf8",
sourceContentHash: "a".repeat(64),
});
});
it("renders only stored bounded content and no file explorer", () => {
@@ -83,9 +95,11 @@ function makeEntry(): SkillsShCatalogDetail {
topics: [],
sourceUrl: "https://skills.sh/patrick-erichsen/skills/html",
canonicalRepoUrl: "https://github.com/patrick-erichsen/skills",
canonicalGitHubRepo: "openclaw/openclaw",
githubPath: "skills/html",
githubCommit: "050daba89f6b6636470add5cb300aac46a412cf8",
sourceContentHash: "a".repeat(64),
githubContentHash: "a".repeat(64),
sourceContentHash: "b".repeat(64),
upstreamInstalls: 100,
lastObservedAt: Date.now() - 60000,
upstreamChecks: [
+7 -2
View File
@@ -98,14 +98,19 @@ export function SkillsShCatalogDetailPage({ entry }: { entry: SkillsShCatalogDet
<h2 className="font-display text-lg font-bold text-[color:var(--oc-text-primary)]">
Install
</h2>
{entry.owner && entry.repo && entry.githubPath ? (
{entry.githubPath && entry.githubCommit && entry.githubContentHash ? (
<Button asChild variant="outline" size="sm">
<Link
to="/settings"
search={{
view: "githubSources",
repo: `${entry.owner}/${entry.repo}`,
ownerHandle: entry.canonicalGitHubRepo.split("/")[0],
repo: entry.canonicalGitHubRepo,
sourceRepo: entry.canonicalGitHubRepo,
sourceExternalId: entry.externalId,
sourcePath: entry.githubPath,
sourceCommit: entry.githubCommit,
sourceContentHash: entry.githubContentHash,
}}
>
<GitBranch size={15} aria-hidden="true" /> Claim
+4 -2
View File
@@ -28,8 +28,10 @@ export type SkillsShCatalogDetail = SkillsShSearchResult & {
topics: string[];
sourceUrl: string;
canonicalRepoUrl?: string;
canonicalGitHubRepo: string;
githubPath?: string;
githubCommit?: string;
githubContentHash?: string;
sourceContentHash?: string;
upstreamChecks: SkillsShUpstreamCheck[];
content: {
@@ -104,11 +106,11 @@ export function buildSkillsShInstallCommands(reference: string) {
}
export function isSkillsShCatalogInstallable(
detail: Pick<SkillsShCatalogDetail, "githubCommit" | "githubPath" | "sourceContentHash">,
detail: Pick<SkillsShCatalogDetail, "githubCommit" | "githubContentHash" | "githubPath">,
) {
return Boolean(
detail.githubPath?.trim() &&
/^[a-f0-9]{40}$/.test(detail.githubCommit?.trim().toLowerCase() ?? "") &&
/^[a-f0-9]{64}$/.test(detail.sourceContentHash?.trim().toLowerCase() ?? ""),
/^[a-f0-9]{64}$/.test(detail.githubContentHash?.trim().toLowerCase() ?? ""),
);
}
+70 -3
View File
@@ -688,12 +688,76 @@ describe("Settings", () => {
expect(toast.success).toHaveBeenCalledWith(expect.stringMatching(/GitHub source synced/i));
});
it("prefills the repository and source path from an external Claim handoff", async () => {
it("passes the exact external selection from Claim into GitHub Skill Sync", async () => {
const configureSource = vi.fn().mockResolvedValue({ ok: true, stats: { discovered: 1 } });
useActionMock.mockReturnValue(configureSource);
mockSignedInSettings({
search: {
view: "githubSources",
ownerHandle: "openclaw",
repo: "patrick-erichsen/skills",
sourceRepo: "patrick-erichsen/skills",
sourceExternalId: "patrick-erichsen/skills/html",
sourcePath: "skills/html",
sourceCommit: "1".repeat(40),
sourceContentHash: "2".repeat(64),
},
memberships: [orgMembership],
});
const view = render(<Settings />);
await waitFor(() => {
expect((screen.getByLabelText("GitHub repo URL") as HTMLInputElement).value).toBe(
"patrick-erichsen/skills",
);
});
expect(screen.getByText("skills/html")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /Add repo/i }));
await waitFor(() => {
expect(configureSource).toHaveBeenCalledWith({
ownerPublisherId: "publisher_openclaw",
repo: "patrick-erichsen/skills",
expectedSkillsShSource: {
repo: "patrick-erichsen/skills",
externalId: "patrick-erichsen/skills/html",
path: "skills/html",
commit: "1".repeat(40),
contentHash: "2".repeat(64),
},
});
});
expect(navigateMock).toHaveBeenCalledWith({
to: "/settings",
search: { view: "githubSources" },
replace: true,
});
searchMock.mockReturnValue({ view: "githubSources" });
view.rerender(<Settings />);
fireEvent.change(screen.getByLabelText("GitHub repo URL"), {
target: { value: "NVIDIA/skills" },
});
fireEvent.click(screen.getByRole("button", { name: /Add repo/i }));
await waitFor(() => {
expect(configureSource).toHaveBeenNthCalledWith(2, {
ownerPublisherId: "publisher_openclaw",
repo: "NVIDIA/skills",
});
});
});
it("rejects a partial external Claim selection before GitHub Skill Sync", async () => {
const configureSource = vi.fn();
useActionMock.mockReturnValue(configureSource);
mockSignedInSettings({
search: {
view: "githubSources",
repo: "patrick-erichsen/skills",
sourcePath: "skills/html",
sourceExternalId: "patrick-erichsen/skills/html",
sourcePath: "",
},
memberships: [orgMembership],
});
@@ -705,7 +769,10 @@ describe("Settings", () => {
"patrick-erichsen/skills",
);
});
expect(screen.getByText("skills/html")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /Add repo/i }));
expect(configureSource).not.toHaveBeenCalled();
expect(toast.error).toHaveBeenCalledWith(expect.stringMatching(/Claim link is incomplete/i));
});
it("shows synced repos as separate cards and lets owners delete a source", async () => {
+52
View File
@@ -90,12 +90,22 @@ export const Route = createFileRoute("/settings")({
view?: SettingsView;
ownerHandle?: string;
repo?: string;
sourceRepo?: string;
sourceExternalId?: string;
sourcePath?: string;
sourceCommit?: string;
sourceContentHash?: string;
} => ({
view: isSettingsView(search.view) ? search.view : undefined,
ownerHandle: typeof search.ownerHandle === "string" ? search.ownerHandle : undefined,
repo: typeof search.repo === "string" ? search.repo : undefined,
sourceRepo: typeof search.sourceRepo === "string" ? search.sourceRepo : undefined,
sourceExternalId:
typeof search.sourceExternalId === "string" ? search.sourceExternalId : undefined,
sourcePath: typeof search.sourcePath === "string" ? search.sourcePath : undefined,
sourceCommit: typeof search.sourceCommit === "string" ? search.sourceCommit : undefined,
sourceContentHash:
typeof search.sourceContentHash === "string" ? search.sourceContentHash : undefined,
}),
component: Settings,
});
@@ -343,7 +353,11 @@ export function Settings() {
navigateToView,
ownerHandle: requestedOwnerHandle,
repo: requestedRepo,
sourceRepo: requestedSourceRepo,
sourceExternalId: requestedSourceExternalId,
sourcePath: requestedSourcePath,
sourceCommit: requestedSourceCommit,
sourceContentHash: requestedSourceContentHash,
} = useActiveSettingsView();
const orgs = (publisherMemberships ?? []).filter((entry) => entry.publisher.kind === "org");
const manageablePublishers = (publisherMemberships ?? []).filter(
@@ -752,12 +766,44 @@ export function Settings() {
if (!selectedSourcePublisher) return;
const repo = parseGitHubRepoInput(githubRepo);
if (!repo) return;
const requestedSourceFields = [
requestedSourceRepo,
requestedSourceExternalId,
requestedSourcePath,
requestedSourceCommit,
requestedSourceContentHash,
];
const hasRequestedSource = requestedSourceFields.some((value) => value !== undefined);
const hasCompleteRequestedSource = requestedSourceFields.every(
(value) => typeof value === "string" && value.trim().length > 0,
);
if (hasRequestedSource && !hasCompleteRequestedSource) {
toast.error("This Claim link is incomplete. Return to the skill listing and try again.");
return;
}
const expectedSkillsShSource = hasCompleteRequestedSource
? {
repo: requestedSourceRepo as string,
externalId: requestedSourceExternalId as string,
path: requestedSourcePath as string,
commit: requestedSourceCommit as string,
contentHash: requestedSourceContentHash as string,
}
: undefined;
setIsSyncingSource(true);
try {
const result = await configureGitHubSource({
ownerPublisherId: selectedSourcePublisher.publisher._id,
repo,
...(expectedSkillsShSource ? { expectedSkillsShSource } : {}),
});
if (expectedSkillsShSource) {
await navigate({
to: "/settings",
search: { view: "githubSources" },
replace: true,
});
}
setGithubRepo("");
toast.success(formatGitHubSourceSyncToast(result?.stats));
} catch (error) {
@@ -3013,7 +3059,13 @@ function useActiveSettingsView() {
navigateToView,
ownerHandle: typeof search.ownerHandle === "string" ? search.ownerHandle : undefined,
repo: typeof search.repo === "string" ? search.repo : undefined,
sourceRepo: typeof search.sourceRepo === "string" ? search.sourceRepo : undefined,
sourceExternalId:
typeof search.sourceExternalId === "string" ? search.sourceExternalId : undefined,
sourcePath: typeof search.sourcePath === "string" ? search.sourcePath : undefined,
sourceCommit: typeof search.sourceCommit === "string" ? search.sourceCommit : undefined,
sourceContentHash:
typeof search.sourceContentHash === "string" ? search.sourceContentHash : undefined,
};
}