mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
feat: run full ClawScan for GitHub skills
Adds full ClawScan execution for GitHub-backed skills and updates the static audit ignore list for newly reported DOMPurify advisories.
This commit is contained in:
Vendored
+4
@@ -63,6 +63,7 @@ import type * as lib_githubAuth from "../lib/githubAuth.js";
|
||||
import type * as lib_githubIdentity from "../lib/githubIdentity.js";
|
||||
import type * as lib_githubImport from "../lib/githubImport.js";
|
||||
import type * as lib_githubProfileSync from "../lib/githubProfileSync.js";
|
||||
import type * as lib_githubSkillScans from "../lib/githubSkillScans.js";
|
||||
import type * as lib_githubSkillSync from "../lib/githubSkillSync.js";
|
||||
import type * as lib_globalStats from "../lib/globalStats.js";
|
||||
import type * as lib_httpHeaders from "../lib/httpHeaders.js";
|
||||
@@ -103,6 +104,7 @@ import type * as lib_skillIcon from "../lib/skillIcon.js";
|
||||
import type * as lib_skillPublish from "../lib/skillPublish.js";
|
||||
import type * as lib_skillQuality from "../lib/skillQuality.js";
|
||||
import type * as lib_skillSafety from "../lib/skillSafety.js";
|
||||
import type * as lib_skillScanRequestFiles from "../lib/skillScanRequestFiles.js";
|
||||
import type * as lib_skillSearchDigest from "../lib/skillSearchDigest.js";
|
||||
import type * as lib_skillSlugValidator from "../lib/skillSlugValidator.js";
|
||||
import type * as lib_skillStats from "../lib/skillStats.js";
|
||||
@@ -208,6 +210,7 @@ declare const fullApi: ApiFromModules<{
|
||||
"lib/githubIdentity": typeof lib_githubIdentity;
|
||||
"lib/githubImport": typeof lib_githubImport;
|
||||
"lib/githubProfileSync": typeof lib_githubProfileSync;
|
||||
"lib/githubSkillScans": typeof lib_githubSkillScans;
|
||||
"lib/githubSkillSync": typeof lib_githubSkillSync;
|
||||
"lib/globalStats": typeof lib_globalStats;
|
||||
"lib/httpHeaders": typeof lib_httpHeaders;
|
||||
@@ -248,6 +251,7 @@ declare const fullApi: ApiFromModules<{
|
||||
"lib/skillPublish": typeof lib_skillPublish;
|
||||
"lib/skillQuality": typeof lib_skillQuality;
|
||||
"lib/skillSafety": typeof lib_skillSafety;
|
||||
"lib/skillScanRequestFiles": typeof lib_skillScanRequestFiles;
|
||||
"lib/skillSearchDigest": typeof lib_skillSearchDigest;
|
||||
"lib/skillSlugValidator": typeof lib_skillSlugValidator;
|
||||
"lib/skillStats": typeof lib_skillStats;
|
||||
|
||||
@@ -69,4 +69,15 @@ describe("crons", () => {
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("prunes expired skill scan requests in bounded continuation batches", async () => {
|
||||
await import("./crons");
|
||||
|
||||
expect(mocks.interval).toHaveBeenCalledWith(
|
||||
"skill-scan-request-prune",
|
||||
{ hours: 6 },
|
||||
expect.anything(),
|
||||
{ batchSize: 10 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -104,7 +104,7 @@ crons.interval(
|
||||
"skill-scan-request-prune",
|
||||
{ hours: 6 },
|
||||
internal.securityScan.pruneExpiredSkillScanRequestsInternal,
|
||||
{ batchSize: 250 },
|
||||
{ batchSize: 10 },
|
||||
);
|
||||
|
||||
crons.interval(
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ActionCtx, MutationCtx } from "./_generated/server";
|
||||
import { internalMutation as rawInternalMutation } from "./_generated/server";
|
||||
import { internalAction, internalMutation } from "./functions";
|
||||
import { EMBEDDING_DIMENSIONS, generateEmbedding } from "./lib/embeddings";
|
||||
import { deleteGitHubSkillScansForSkill } from "./lib/githubSkillScans";
|
||||
import { normalizePackageName } from "./lib/packageRegistry";
|
||||
import { ensurePersonalPublisherForUser } from "./lib/publishers";
|
||||
import {
|
||||
@@ -1340,6 +1341,7 @@ async function deleteSkillAndVersions(ctx: MutationCtx, skillId: Id<"skills">) {
|
||||
.withIndex("by_skill", (q) => q.eq("skillId", skillId))
|
||||
.collect();
|
||||
for (const version of versions) await ctx.db.delete(version._id);
|
||||
await deleteGitHubSkillScansForSkill(ctx, skillId);
|
||||
await deleteSkillEmbeddingsForSkill(ctx, skillId);
|
||||
await deleteSkillBadgesForSkill(ctx, skillId);
|
||||
await ctx.db.delete(skillId);
|
||||
|
||||
@@ -15,7 +15,8 @@ vi.mock("./lib/publishers", async () => {
|
||||
|
||||
const { requireUser } = await import("./lib/access");
|
||||
const { requirePublisherRole } = await import("./lib/publishers");
|
||||
const { deleteForPublisherHandler } = await import("./githubSkillSources");
|
||||
const { cleanupDeletedSourceScansHandler, deleteForPublisherHandler } =
|
||||
await import("./githubSkillSources");
|
||||
const { buildSkillInstallResolution } = await import("./lib/installResolver");
|
||||
|
||||
type Row = Record<string, unknown> & { _id: string };
|
||||
@@ -74,6 +75,7 @@ function createDb(initial: Record<string, Row[]> = {}) {
|
||||
const matched = () => list(table).filter((row) => matches(row, constraints));
|
||||
return {
|
||||
collect: async () => matched(),
|
||||
take: async (limit: number) => matched().slice(0, limit),
|
||||
unique: async () => matched()[0] ?? null,
|
||||
};
|
||||
},
|
||||
@@ -107,6 +109,20 @@ describe("githubSkillSources.deleteForPublisherHandler", () => {
|
||||
githubSourceId: "githubSkillSources:matt",
|
||||
},
|
||||
],
|
||||
githubSkillScans: [
|
||||
{
|
||||
_id: "githubSkillScans:matt",
|
||||
skillId: "skills:github",
|
||||
githubSourceId: "githubSkillSources:matt",
|
||||
contentHash: "hash-source-backed",
|
||||
},
|
||||
{
|
||||
_id: "githubSkillScans:other",
|
||||
skillId: "skills:other-source",
|
||||
githubSourceId: "githubSkillSources:other",
|
||||
contentHash: "hash-other-source",
|
||||
},
|
||||
],
|
||||
skills: [
|
||||
{
|
||||
_id: "skills:github",
|
||||
@@ -163,9 +179,10 @@ describe("githubSkillSources.deleteForPublisherHandler", () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
const scheduler = { runAfter: vi.fn(async () => undefined) };
|
||||
|
||||
await expect(
|
||||
deleteForPublisherHandler({ db } as never, {
|
||||
deleteForPublisherHandler({ db, scheduler } as never, {
|
||||
ownerPublisherId: "publishers:openclaw" as never,
|
||||
sourceId: "githubSkillSources:matt" as never,
|
||||
now: 123,
|
||||
@@ -182,6 +199,10 @@ describe("githubSkillSources.deleteForPublisherHandler", () => {
|
||||
);
|
||||
expect(tables.githubSkillSources).toHaveLength(0);
|
||||
expect(tables.githubSkillContents).toHaveLength(0);
|
||||
expect(tables.githubSkillScans).toHaveLength(2);
|
||||
expect(scheduler.runAfter).toHaveBeenCalledWith(0, expect.anything(), {
|
||||
sourceId: "githubSkillSources:matt",
|
||||
});
|
||||
const deletedSkill = tables.skills.find((skill) => skill._id === "skills:github");
|
||||
expect(deletedSkill).toMatchObject({
|
||||
softDeletedAt: 123,
|
||||
@@ -217,6 +238,61 @@ describe("githubSkillSources.deleteForPublisherHandler", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("cleans deleted-source scan history in bounded batches", async () => {
|
||||
const { db, tables } = createDb({
|
||||
githubSkillScans: [
|
||||
{
|
||||
_id: "githubSkillScans:matt",
|
||||
githubSourceId: "githubSkillSources:matt",
|
||||
skillScanRequestId: "skillScanRequests:matt",
|
||||
},
|
||||
{
|
||||
_id: "githubSkillScans:other",
|
||||
githubSourceId: "githubSkillSources:other",
|
||||
},
|
||||
],
|
||||
securityScanJobs: [
|
||||
{
|
||||
_id: "securityScanJobs:matt",
|
||||
targetKind: "skillScanRequest",
|
||||
status: "queued",
|
||||
},
|
||||
],
|
||||
skillScanRequests: [
|
||||
{
|
||||
_id: "skillScanRequests:matt",
|
||||
sourceKind: "github",
|
||||
status: "queued",
|
||||
securityScanJobId: "securityScanJobs:matt",
|
||||
githubSkillScanId: "githubSkillScans:matt",
|
||||
expiresAt: Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
],
|
||||
});
|
||||
const scheduler = { runAfter: vi.fn(async () => undefined) };
|
||||
|
||||
await expect(
|
||||
cleanupDeletedSourceScansHandler({ db, scheduler } as never, {
|
||||
sourceId: "githubSkillSources:matt" as never,
|
||||
}),
|
||||
).resolves.toEqual({ ok: true, deleted: 1, done: true });
|
||||
|
||||
expect(tables.githubSkillScans).toEqual([
|
||||
expect.objectContaining({ _id: "githubSkillScans:other" }),
|
||||
]);
|
||||
expect(tables.securityScanJobs).toEqual([]);
|
||||
expect(tables.skillScanRequests).toEqual([
|
||||
expect.objectContaining({
|
||||
_id: "skillScanRequests:matt",
|
||||
status: "failed",
|
||||
}),
|
||||
]);
|
||||
expect(tables.skillScanRequests?.[0]).not.toHaveProperty("githubSkillScanId");
|
||||
expect(tables.skillScanRequests?.[0]).not.toHaveProperty("securityScanJobId");
|
||||
expect(tables.skillScanRequests?.[0]?.expiresAt).toBeLessThan(Number.MAX_SAFE_INTEGER);
|
||||
expect(scheduler.runAfter).toHaveBeenCalledWith(0, expect.anything(), { batchSize: 10 });
|
||||
});
|
||||
|
||||
it("rejects deleting a source from another publisher", async () => {
|
||||
const { db } = createDb({
|
||||
githubSkillSources: [
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { ConvexError, v } from "convex/values";
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
||||
import { internalQuery, mutation, query } from "./functions";
|
||||
import { internalMutation, internalQuery, mutation, query } from "./functions";
|
||||
import { requireUser } from "./lib/access";
|
||||
import { deleteGitHubSkillScansForSource } from "./lib/githubSkillScans";
|
||||
import { adjustGlobalPublicSkillsCount, getPublicSkillVisibilityDelta } from "./lib/globalStats";
|
||||
import { isOfficialPublisher } from "./lib/officialPublishers";
|
||||
import { isPublisherActive, isPublisherRoleAllowed, requirePublisherRole } from "./lib/publishers";
|
||||
import { syncSkillSearchDigestForSkill } from "./lib/skillSearchDigest";
|
||||
|
||||
const GITHUB_SKILL_SCAN_CLEANUP_BATCH_SIZE = 25;
|
||||
|
||||
type PublicGitHubSkillSource = Pick<
|
||||
Doc<"githubSkillSources">,
|
||||
| "_id"
|
||||
@@ -161,6 +165,9 @@ export async function deleteForPublisherHandler(
|
||||
for (const content of contents) {
|
||||
await ctx.db.delete(content._id);
|
||||
}
|
||||
await ctx.scheduler.runAfter(0, internal.githubSkillSources.cleanupDeletedSourceScansInternal, {
|
||||
sourceId: args.sourceId,
|
||||
});
|
||||
|
||||
const skills = await ctx.db
|
||||
.query("skills")
|
||||
@@ -197,6 +204,36 @@ export async function deleteForPublisherHandler(
|
||||
return { ok: true as const, deletedSkills };
|
||||
}
|
||||
|
||||
export async function cleanupDeletedSourceScansHandler(
|
||||
ctx: MutationCtx,
|
||||
args: { sourceId: Id<"githubSkillSources"> },
|
||||
) {
|
||||
const deleted = await deleteGitHubSkillScansForSource(
|
||||
ctx,
|
||||
args.sourceId,
|
||||
GITHUB_SKILL_SCAN_CLEANUP_BATCH_SIZE,
|
||||
);
|
||||
const done = deleted < GITHUB_SKILL_SCAN_CLEANUP_BATCH_SIZE;
|
||||
if (deleted > 0) {
|
||||
await ctx.scheduler.runAfter(0, internal.securityScan.pruneExpiredSkillScanRequestsInternal, {
|
||||
batchSize: 10,
|
||||
});
|
||||
}
|
||||
if (!done) {
|
||||
await ctx.scheduler.runAfter(
|
||||
0,
|
||||
internal.githubSkillSources.cleanupDeletedSourceScansInternal,
|
||||
args,
|
||||
);
|
||||
}
|
||||
return { ok: true as const, deleted, done };
|
||||
}
|
||||
|
||||
export const cleanupDeletedSourceScansInternal = internalMutation({
|
||||
args: { sourceId: v.id("githubSkillSources") },
|
||||
handler: cleanupDeletedSourceScansHandler,
|
||||
});
|
||||
|
||||
export const deleteForPublisher: ReturnType<typeof mutation> = mutation({
|
||||
args: {
|
||||
ownerPublisherId: v.id("publishers"),
|
||||
|
||||
@@ -8,6 +8,11 @@ import {
|
||||
verifyGitHubSkillHandler,
|
||||
} from "./githubSkillSync";
|
||||
import { buildSkillInstallResolution } from "./lib/installResolver";
|
||||
import {
|
||||
appendGitHubSkillScanRequestFilesInternal,
|
||||
finalizeGitHubSkillScanRequestInternal,
|
||||
prepareGitHubSkillScanRequestInternal,
|
||||
} from "./securityScan";
|
||||
|
||||
type Row = Record<string, unknown> & { _id: string };
|
||||
|
||||
@@ -35,10 +40,12 @@ function createDb(initial: Record<string, Row[]> = {}) {
|
||||
};
|
||||
|
||||
const db = {
|
||||
get: async (id: string) => {
|
||||
get: async (idOrTable: string, maybeId?: string) => {
|
||||
const id = maybeId ?? idOrTable;
|
||||
const table = id.split(":")[0] ?? "";
|
||||
return list(table).find((row) => row._id === id) ?? null;
|
||||
},
|
||||
normalizeId: (table: string, id: string) => (id.startsWith(`${table}:`) ? id : null),
|
||||
insert: async (table: string, doc: Record<string, unknown>) => {
|
||||
counters[table] = (counters[table] ?? 0) + 1;
|
||||
const inserted = {
|
||||
@@ -58,6 +65,18 @@ function createDb(initial: Record<string, Row[]> = {}) {
|
||||
else row[key] = value;
|
||||
}
|
||||
},
|
||||
replace: async (id: string, doc: Record<string, unknown>) => {
|
||||
const table = id.split(":")[0] ?? "";
|
||||
const rows = list(table);
|
||||
const index = rows.findIndex((candidate) => candidate._id === id);
|
||||
if (index >= 0) rows[index] = { _id: id, ...doc };
|
||||
},
|
||||
delete: async (id: string) => {
|
||||
const table = id.split(":")[0] ?? "";
|
||||
const rows = list(table);
|
||||
const index = rows.findIndex((candidate) => candidate._id === id);
|
||||
if (index >= 0) rows.splice(index, 1);
|
||||
},
|
||||
query: (table: string) => ({
|
||||
withIndex: (_indexName: string, build?: (q: ReturnType<typeof chainEq>) => unknown) => {
|
||||
const constraints: Record<string, unknown> = {};
|
||||
@@ -65,6 +84,7 @@ function createDb(initial: Record<string, Row[]> = {}) {
|
||||
const matched = () => list(table).filter((row) => matches(row, constraints));
|
||||
return {
|
||||
collect: async () => matched(),
|
||||
take: async (limit: number) => matched().slice(0, limit),
|
||||
unique: async () => matched()[0] ?? null,
|
||||
};
|
||||
},
|
||||
@@ -114,6 +134,7 @@ describe("GitHub-backed skills live canary", () => {
|
||||
],
|
||||
});
|
||||
const scheduler = { runAfter: async () => undefined };
|
||||
let storedFile = 0;
|
||||
let now = Date.now();
|
||||
const actionCtx = {
|
||||
runQuery: async (_query: unknown, args: Record<string, unknown>) => {
|
||||
@@ -165,6 +186,27 @@ describe("GitHub-backed skills live canary", () => {
|
||||
} as never,
|
||||
);
|
||||
}
|
||||
if ("requestId" in args && "files" in args) {
|
||||
return await (
|
||||
appendGitHubSkillScanRequestFilesInternal as unknown as {
|
||||
_handler: (ctx: never, args: never) => unknown;
|
||||
}
|
||||
)._handler({ db } as never, args as never);
|
||||
}
|
||||
if ("requestId" in args) {
|
||||
return await (
|
||||
finalizeGitHubSkillScanRequestInternal as unknown as {
|
||||
_handler: (ctx: never, args: never) => unknown;
|
||||
}
|
||||
)._handler({ db } as never, args as never);
|
||||
}
|
||||
if ("staticScan" in args && "commit" in args && "contentHash" in args) {
|
||||
return await (
|
||||
prepareGitHubSkillScanRequestInternal as unknown as {
|
||||
_handler: (ctx: never, args: never) => unknown;
|
||||
}
|
||||
)._handler({ db } as never, args as never);
|
||||
}
|
||||
if ("scanStatus" in args && "contentHash" in args) {
|
||||
return await applyGitHubSkillVerificationResultHandler(
|
||||
{ db } as never,
|
||||
@@ -185,6 +227,13 @@ describe("GitHub-backed skills live canary", () => {
|
||||
}
|
||||
throw new Error(`unexpected live canary mutation args: ${JSON.stringify(args)}`);
|
||||
},
|
||||
storage: {
|
||||
store: async () => {
|
||||
storedFile += 1;
|
||||
return `storage:live-${storedFile}`;
|
||||
},
|
||||
delete: async () => undefined,
|
||||
},
|
||||
auth: { getUserIdentity: async () => null },
|
||||
};
|
||||
|
||||
@@ -227,7 +276,18 @@ describe("GitHub-backed skills live canary", () => {
|
||||
fetch,
|
||||
);
|
||||
|
||||
expect(verified).toMatchObject({ ok: true, scanStatus: "clean" });
|
||||
expect(verified).toMatchObject({ ok: true, queued: true });
|
||||
expect(storedFile).toBeGreaterThan(0);
|
||||
expect(resolveInstallFromTables(tables, skillSlug)).toMatchObject({
|
||||
ok: false,
|
||||
reason: "github_verification_pending",
|
||||
});
|
||||
await applyGitHubSkillVerificationResultHandler({ db } as never, {
|
||||
skillId: skill._id as never,
|
||||
contentHash: skill.githubCurrentContentHash as string,
|
||||
scanStatus: "clean",
|
||||
now,
|
||||
});
|
||||
skill = getSkill(tables, skillSlug);
|
||||
expect(skill).toMatchObject({
|
||||
githubCurrentCommit: configured.commit,
|
||||
|
||||
+527
-23
@@ -690,6 +690,7 @@ description: Install from a GitHub-backed source.
|
||||
],
|
||||
});
|
||||
const scheduler = { runAfter: vi.fn(async () => undefined) };
|
||||
let storedFile = 0;
|
||||
let now = 100;
|
||||
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const actionCtx = {
|
||||
@@ -740,7 +741,8 @@ description: Install from a GitHub-backed source.
|
||||
}
|
||||
throw new Error(`unexpected lifecycle query args: ${JSON.stringify(args)}`);
|
||||
}),
|
||||
runMutation: vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
|
||||
runMutation: vi.fn(async (mutation: unknown, args: Record<string, unknown>) => {
|
||||
const mutationName = getFunctionName(mutation as Parameters<typeof getFunctionName>[0]);
|
||||
if ("snapshot" in args) {
|
||||
return await applyGitHubSkillSourceSyncHandler(
|
||||
{ db, scheduler } as never,
|
||||
@@ -750,6 +752,26 @@ description: Install from a GitHub-backed source.
|
||||
} as never,
|
||||
);
|
||||
}
|
||||
if (mutationName === "securityScan:prepareGitHubSkillScanRequestInternal") {
|
||||
return {
|
||||
ok: true,
|
||||
prepared: true,
|
||||
scanId: "githubSkillScans:1",
|
||||
requestId: "skillScanRequests:1",
|
||||
};
|
||||
}
|
||||
if (mutationName === "securityScan:appendGitHubSkillScanRequestFilesInternal") {
|
||||
return { ok: true, appended: true };
|
||||
}
|
||||
if (mutationName === "securityScan:finalizeGitHubSkillScanRequestInternal") {
|
||||
return {
|
||||
ok: true,
|
||||
queued: true,
|
||||
scanId: "githubSkillScans:1",
|
||||
requestId: "skillScanRequests:1",
|
||||
jobId: "securityScanJobs:1",
|
||||
};
|
||||
}
|
||||
if ("scanStatus" in args && "contentHash" in args) {
|
||||
return await applyGitHubSkillVerificationResultHandler(
|
||||
{ db } as never,
|
||||
@@ -779,6 +801,13 @@ description: Install from a GitHub-backed source.
|
||||
}
|
||||
throw new Error(`unexpected lifecycle mutation args: ${JSON.stringify(args)}`);
|
||||
}),
|
||||
storage: {
|
||||
store: vi.fn(async () => {
|
||||
storedFile += 1;
|
||||
return `storage:${storedFile}`;
|
||||
}),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
auth: { getUserIdentity: vi.fn() },
|
||||
};
|
||||
|
||||
@@ -849,14 +878,27 @@ description: Install from a GitHub-backed source.
|
||||
});
|
||||
|
||||
now = 110;
|
||||
await verifyGitHubSkillHandler(
|
||||
actionCtx as never,
|
||||
{
|
||||
skillId: skill._id as never,
|
||||
contentHash: skill.githubCurrentContentHash as string,
|
||||
},
|
||||
fakeGitHub.fetcher as never,
|
||||
);
|
||||
await expect(
|
||||
verifyGitHubSkillHandler(
|
||||
actionCtx as never,
|
||||
{
|
||||
skillId: skill._id as never,
|
||||
contentHash: skill.githubCurrentContentHash as string,
|
||||
},
|
||||
fakeGitHub.fetcher as never,
|
||||
),
|
||||
).resolves.toMatchObject({ ok: true, queued: true });
|
||||
expect(resolveInstallFromTables(tables, "demo-source")).toMatchObject({
|
||||
ok: false,
|
||||
reason: "github_verification_pending",
|
||||
status: 423,
|
||||
});
|
||||
await applyGitHubSkillVerificationResultHandler({ db } as never, {
|
||||
skillId: skill._id as never,
|
||||
contentHash: skill.githubCurrentContentHash as string,
|
||||
scanStatus: "clean",
|
||||
now,
|
||||
});
|
||||
expect(resolveInstallFromTables(tables, "demo-source")).toMatchObject({
|
||||
ok: true,
|
||||
installKind: "github",
|
||||
@@ -911,14 +953,27 @@ description: Install from a GitHub-backed source.
|
||||
});
|
||||
|
||||
now = 210;
|
||||
await verifyGitHubSkillHandler(
|
||||
actionCtx as never,
|
||||
{
|
||||
skillId: skill._id as never,
|
||||
contentHash: skill.githubCurrentContentHash as string,
|
||||
},
|
||||
fakeGitHub.fetcher as never,
|
||||
);
|
||||
await expect(
|
||||
verifyGitHubSkillHandler(
|
||||
actionCtx as never,
|
||||
{
|
||||
skillId: skill._id as never,
|
||||
contentHash: skill.githubCurrentContentHash as string,
|
||||
},
|
||||
fakeGitHub.fetcher as never,
|
||||
),
|
||||
).resolves.toMatchObject({ ok: true, queued: true });
|
||||
expect(resolveInstallFromTables(tables, "demo-source")).toMatchObject({
|
||||
ok: false,
|
||||
reason: "github_verification_pending",
|
||||
status: 423,
|
||||
});
|
||||
await applyGitHubSkillVerificationResultHandler({ db } as never, {
|
||||
skillId: skill._id as never,
|
||||
contentHash: skill.githubCurrentContentHash as string,
|
||||
scanStatus: "clean",
|
||||
now,
|
||||
});
|
||||
expect(resolveInstallFromTables(tables, "demo-source")).toMatchObject({
|
||||
ok: true,
|
||||
installKind: "github",
|
||||
@@ -1012,6 +1067,72 @@ describe("resolveOwnerUserIdForPublisherHandler", () => {
|
||||
});
|
||||
|
||||
describe("applyGitHubSkillSourceSyncHandler", () => {
|
||||
it("queues a full scan and blocks legacy clean GitHub skills without a durable result", async () => {
|
||||
const snapshot = await buildGitHubSkillSourceSnapshot({
|
||||
repo: "NVIDIA/skills",
|
||||
defaultBranch: "main",
|
||||
commit: "2".repeat(40),
|
||||
entries: {
|
||||
"skills/aiq-deploy/SKILL.md": new TextEncoder().encode("# AIQ Deploy\n"),
|
||||
},
|
||||
});
|
||||
const contentHash = snapshot.skills[0]?.contentHash;
|
||||
const { db, tables } = createDb({
|
||||
githubSkillSources: [
|
||||
{
|
||||
_id: "githubSkillSources:nvidia",
|
||||
repo: "NVIDIA/skills",
|
||||
ownerPublisherId: "publishers:nvidia",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
],
|
||||
skills: [
|
||||
{
|
||||
_id: "skills:aiq-deploy",
|
||||
slug: "aiq-deploy",
|
||||
displayName: "AIQ Deploy",
|
||||
ownerUserId: "users:nvidia",
|
||||
ownerPublisherId: "publishers:nvidia",
|
||||
installKind: "github",
|
||||
githubSourceId: "githubSkillSources:nvidia",
|
||||
githubPath: "skills/aiq-deploy",
|
||||
githubCurrentCommit: "1".repeat(40),
|
||||
githubCurrentContentHash: contentHash,
|
||||
githubCurrentStatus: "present",
|
||||
githubScanStatus: "clean",
|
||||
moderationStatus: "active",
|
||||
moderationVerdict: "clean",
|
||||
tags: {},
|
||||
stats: { downloads: 0, stars: 0, installsCurrent: 0, installsAllTime: 0, versions: 0 },
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
const scheduler = { runAfter: vi.fn(async () => undefined) };
|
||||
|
||||
await applyGitHubSkillSourceSyncHandler({ db, scheduler } as never, {
|
||||
sourceId: "githubSkillSources:nvidia" as never,
|
||||
repo: "NVIDIA/skills",
|
||||
ownerUserId: "users:nvidia" as never,
|
||||
ownerPublisherId: "publishers:nvidia" as never,
|
||||
snapshot,
|
||||
now: 123,
|
||||
});
|
||||
|
||||
expect(tables.skills[0]).toMatchObject({
|
||||
githubScanStatus: "pending",
|
||||
moderationStatus: "active",
|
||||
moderationReason: "pending.scan",
|
||||
});
|
||||
expect(tables.skills[0]).not.toHaveProperty("moderationVerdict");
|
||||
expect(scheduler.runAfter).toHaveBeenCalledWith(0, expect.anything(), {
|
||||
skillId: "skills:aiq-deploy",
|
||||
contentHash,
|
||||
});
|
||||
});
|
||||
|
||||
it("applies a trusted fetched snapshot without overwriting unrelated slug owners", async () => {
|
||||
const snapshot = await buildGitHubSkillSourceSnapshot({
|
||||
repo: "NVIDIA/skills",
|
||||
@@ -1410,7 +1531,7 @@ describe("applyGitHubSkillSourceSyncHandler", () => {
|
||||
"skills/aiq-deploy/SKILL.md": new TextEncoder().encode("# AIQ Deploy\n"),
|
||||
},
|
||||
});
|
||||
const { db } = createDb({
|
||||
const { db, tables } = createDb({
|
||||
githubSkillSources: [
|
||||
{
|
||||
_id: "githubSkillSources:nvidia",
|
||||
@@ -1441,12 +1562,167 @@ describe("applyGitHubSkillSourceSyncHandler", () => {
|
||||
skillId: "skills:new-1",
|
||||
contentHash: snapshot.skills[0]?.contentHash,
|
||||
});
|
||||
expect(Object.values(tables.githubSkillScans?.[0] ?? {})).not.toContain(undefined);
|
||||
const scheduledFunction = scheduler.runAfter.mock.calls[0]?.[1];
|
||||
expect(getFunctionName(scheduledFunction as Parameters<typeof getFunctionName>[0])).toBe(
|
||||
"githubSkillSyncNode:verifyGitHubSkillInternal",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not requeue heavy verification while the current content scan job is active", async () => {
|
||||
const snapshot = await buildGitHubSkillSourceSnapshot({
|
||||
repo: "NVIDIA/skills",
|
||||
defaultBranch: "main",
|
||||
commit: "2".repeat(40),
|
||||
entries: {
|
||||
"skills/aiq-deploy/SKILL.md": new TextEncoder().encode("# AIQ Deploy\n"),
|
||||
},
|
||||
});
|
||||
const contentHash = snapshot.skills[0]?.contentHash;
|
||||
const { db } = createDb({
|
||||
githubSkillSources: [
|
||||
{
|
||||
_id: "githubSkillSources:nvidia",
|
||||
repo: "NVIDIA/skills",
|
||||
ownerPublisherId: "publishers:nvidia",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
],
|
||||
skills: [
|
||||
{
|
||||
_id: "skills:aiq-deploy",
|
||||
slug: "aiq-deploy",
|
||||
displayName: "AIQ Deploy",
|
||||
ownerUserId: "users:nvidia",
|
||||
ownerPublisherId: "publishers:nvidia",
|
||||
installKind: "github",
|
||||
githubSourceId: "githubSkillSources:nvidia",
|
||||
githubPath: "skills/aiq-deploy",
|
||||
githubCurrentCommit: "1".repeat(40),
|
||||
githubCurrentContentHash: contentHash,
|
||||
githubCurrentStatus: "present",
|
||||
githubScanStatus: "pending",
|
||||
tags: {},
|
||||
stats: { downloads: 0, stars: 0, installsCurrent: 0, installsAllTime: 0, versions: 0 },
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
],
|
||||
githubSkillScans: [
|
||||
{
|
||||
_id: "githubSkillScans:aiq-deploy",
|
||||
skillId: "skills:aiq-deploy",
|
||||
githubSourceId: "githubSkillSources:nvidia",
|
||||
contentHash,
|
||||
status: "pending",
|
||||
skillScanRequestId: "skillScanRequests:aiq-deploy",
|
||||
},
|
||||
],
|
||||
skillScanRequests: [
|
||||
{
|
||||
_id: "skillScanRequests:aiq-deploy",
|
||||
securityScanJobId: "securityScanJobs:aiq-deploy",
|
||||
},
|
||||
],
|
||||
securityScanJobs: [
|
||||
{
|
||||
_id: "securityScanJobs:aiq-deploy",
|
||||
status: "queued",
|
||||
},
|
||||
],
|
||||
});
|
||||
const scheduler = { runAfter: vi.fn(async () => undefined) };
|
||||
|
||||
await applyGitHubSkillSourceSyncHandler({ db, scheduler } as never, {
|
||||
sourceId: "githubSkillSources:nvidia" as never,
|
||||
repo: "NVIDIA/skills",
|
||||
ownerUserId: "users:nvidia" as never,
|
||||
ownerPublisherId: "publishers:nvidia" as never,
|
||||
snapshot,
|
||||
now: 123,
|
||||
});
|
||||
|
||||
expect(scheduler.runAfter).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("does not requeue heavy verification while a recent verification action is pending", async () => {
|
||||
const snapshot = await buildGitHubSkillSourceSnapshot({
|
||||
repo: "NVIDIA/skills",
|
||||
defaultBranch: "main",
|
||||
commit: "2".repeat(40),
|
||||
entries: {
|
||||
"skills/aiq-deploy/SKILL.md": new TextEncoder().encode("# AIQ Deploy\n"),
|
||||
},
|
||||
});
|
||||
const contentHash = snapshot.skills[0]?.contentHash;
|
||||
const { db } = createDb({
|
||||
githubSkillSources: [
|
||||
{
|
||||
_id: "githubSkillSources:nvidia",
|
||||
repo: "NVIDIA/skills",
|
||||
ownerPublisherId: "publishers:nvidia",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
],
|
||||
skills: [
|
||||
{
|
||||
_id: "skills:aiq-deploy",
|
||||
slug: "aiq-deploy",
|
||||
displayName: "AIQ Deploy",
|
||||
ownerUserId: "users:nvidia",
|
||||
ownerPublisherId: "publishers:nvidia",
|
||||
installKind: "github",
|
||||
githubSourceId: "githubSkillSources:nvidia",
|
||||
githubPath: "skills/aiq-deploy",
|
||||
githubCurrentCommit: "1".repeat(40),
|
||||
githubCurrentContentHash: contentHash,
|
||||
githubCurrentStatus: "present",
|
||||
githubScanStatus: "pending",
|
||||
tags: {},
|
||||
stats: { downloads: 0, stars: 0, installsCurrent: 0, installsAllTime: 0, versions: 0 },
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
],
|
||||
githubSkillScans: [
|
||||
{
|
||||
_id: "githubSkillScans:aiq-deploy",
|
||||
skillId: "skills:aiq-deploy",
|
||||
githubSourceId: "githubSkillSources:nvidia",
|
||||
contentHash,
|
||||
commit: "1".repeat(40),
|
||||
path: "skills/aiq-deploy",
|
||||
status: "pending",
|
||||
skillScanRequestId: "skillScanRequests:aiq-deploy",
|
||||
createdAt: 1,
|
||||
updatedAt: 123,
|
||||
},
|
||||
],
|
||||
skillScanRequests: [
|
||||
{
|
||||
_id: "skillScanRequests:aiq-deploy",
|
||||
sourceKind: "github",
|
||||
createdAt: 123,
|
||||
updatedAt: 123,
|
||||
},
|
||||
],
|
||||
});
|
||||
const scheduler = { runAfter: vi.fn(async () => undefined) };
|
||||
|
||||
await applyGitHubSkillSourceSyncHandler({ db, scheduler } as never, {
|
||||
sourceId: "githubSkillSources:nvidia" as never,
|
||||
repo: "NVIDIA/skills",
|
||||
ownerUserId: "users:nvidia" as never,
|
||||
ownerPublisherId: "publishers:nvidia" as never,
|
||||
snapshot,
|
||||
now: 123,
|
||||
});
|
||||
|
||||
expect(scheduler.runAfter).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("refreshes cached GitHub content metadata when bytes are unchanged at a new commit", async () => {
|
||||
const snapshot = await buildGitHubSkillSourceSnapshot({
|
||||
repo: "NVIDIA/skills",
|
||||
@@ -1677,6 +1953,9 @@ describe("verifyGitHubSkillHandler", () => {
|
||||
const commit = "3".repeat(40);
|
||||
const zip = zipSync({
|
||||
"skills-main/skills/aiq-deploy/SKILL.md": new TextEncoder().encode("# AIQ Deploy\n"),
|
||||
"skills-main/skills/aiq-deploy/scripts/deploy.sh": new TextEncoder().encode(
|
||||
"#!/bin/sh\necho deploy\n",
|
||||
),
|
||||
});
|
||||
const snapshot = await buildGitHubSkillSourceSnapshot({
|
||||
repo: "NVIDIA/skills",
|
||||
@@ -1687,7 +1966,40 @@ describe("verifyGitHubSkillHandler", () => {
|
||||
const contentHash = snapshot.skills[0]?.contentHash;
|
||||
if (!contentHash) throw new Error("missing fixture hash");
|
||||
|
||||
const runMutation = vi.fn(async () => ({ ok: true, promoted: false }));
|
||||
const events: string[] = [];
|
||||
let storedFile = 0;
|
||||
const store = vi.fn(async () => {
|
||||
events.push("store");
|
||||
storedFile += 1;
|
||||
return `storage:${storedFile}`;
|
||||
});
|
||||
const runMutation = vi.fn(async (mutation: unknown, _args: Record<string, unknown>) => {
|
||||
const name = getFunctionName(mutation as Parameters<typeof getFunctionName>[0]);
|
||||
if (name === "securityScan:prepareGitHubSkillScanRequestInternal") {
|
||||
events.push("prepare");
|
||||
return {
|
||||
ok: true,
|
||||
prepared: true,
|
||||
scanId: "githubSkillScans:1",
|
||||
requestId: "skillScanRequests:1",
|
||||
};
|
||||
}
|
||||
if (name === "securityScan:appendGitHubSkillScanRequestFilesInternal") {
|
||||
events.push("append");
|
||||
return { ok: true, appended: true };
|
||||
}
|
||||
if (name === "securityScan:finalizeGitHubSkillScanRequestInternal") {
|
||||
events.push("finalize");
|
||||
return {
|
||||
ok: true,
|
||||
queued: true,
|
||||
scanId: "githubSkillScans:1",
|
||||
requestId: "skillScanRequests:1",
|
||||
jobId: "securityScanJobs:1",
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected mutation: ${name}`);
|
||||
});
|
||||
const ctx = {
|
||||
runQuery: vi.fn(async () => ({
|
||||
skill: {
|
||||
@@ -1707,6 +2019,7 @@ describe("verifyGitHubSkillHandler", () => {
|
||||
},
|
||||
})),
|
||||
runMutation,
|
||||
storage: { store, delete: vi.fn() },
|
||||
};
|
||||
const fetcher = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url =
|
||||
@@ -1728,15 +2041,206 @@ describe("verifyGitHubSkillHandler", () => {
|
||||
fetcher as unknown as typeof fetch,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ ok: true, scanStatus: "clean" });
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect(result).toMatchObject({ ok: true, queued: true });
|
||||
expect(store).toHaveBeenCalledTimes(2);
|
||||
expect(runMutation).toHaveBeenCalledTimes(3);
|
||||
expect(events).toEqual(["prepare", "store", "store", "append", "finalize"]);
|
||||
const [prepareMutation, prepareArgs] = runMutation.mock.calls[0] ?? [];
|
||||
expect(getFunctionName(prepareMutation as Parameters<typeof getFunctionName>[0])).toBe(
|
||||
"securityScan:prepareGitHubSkillScanRequestInternal",
|
||||
);
|
||||
expect(prepareArgs).toEqual(
|
||||
expect.objectContaining({
|
||||
skillId: "skills:aiq-deploy",
|
||||
contentHash,
|
||||
scanStatus: "clean",
|
||||
commit,
|
||||
staticScan: expect.objectContaining({ status: "clean" }),
|
||||
}),
|
||||
);
|
||||
expect(prepareArgs).not.toHaveProperty("files");
|
||||
expect(Object.values(prepareArgs ?? {})).not.toContain(undefined);
|
||||
const [appendMutation, appendArgs] = runMutation.mock.calls[1] ?? [];
|
||||
expect(getFunctionName(appendMutation as Parameters<typeof getFunctionName>[0])).toBe(
|
||||
"securityScan:appendGitHubSkillScanRequestFilesInternal",
|
||||
);
|
||||
expect(appendArgs).toEqual(
|
||||
expect.objectContaining({
|
||||
requestId: "skillScanRequests:1",
|
||||
chunkIndex: 0,
|
||||
files: expect.arrayContaining([
|
||||
expect.objectContaining({ path: "SKILL.md" }),
|
||||
expect.objectContaining({ path: "scripts/deploy.sh" }),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
const [finalizeMutation, finalizeArgs] = runMutation.mock.calls[2] ?? [];
|
||||
expect(getFunctionName(finalizeMutation as Parameters<typeof getFunctionName>[0])).toBe(
|
||||
"securityScan:finalizeGitHubSkillScanRequestInternal",
|
||||
);
|
||||
expect(finalizeArgs).toEqual({ requestId: "skillScanRequests:1" });
|
||||
});
|
||||
|
||||
it("does not store GitHub skill files when the durable content-hash scan can be reused", async () => {
|
||||
const commit = "4".repeat(40);
|
||||
const zip = zipSync({
|
||||
"skills-main/skills/aiq-deploy/SKILL.md": new TextEncoder().encode("# AIQ Deploy\n"),
|
||||
});
|
||||
const snapshot = await buildGitHubSkillSourceSnapshot({
|
||||
repo: "NVIDIA/skills",
|
||||
defaultBranch: "main",
|
||||
commit,
|
||||
entries: stripGitHubZipRoot(__test.unzipToEntries(zip)),
|
||||
});
|
||||
const contentHash = snapshot.skills[0]?.contentHash;
|
||||
if (!contentHash) throw new Error("missing fixture hash");
|
||||
|
||||
const store = vi.fn();
|
||||
const runMutation = vi.fn(async (mutation: unknown) => {
|
||||
const name = getFunctionName(mutation as Parameters<typeof getFunctionName>[0]);
|
||||
if (name === "securityScan:prepareGitHubSkillScanRequestInternal") {
|
||||
return {
|
||||
ok: true,
|
||||
reused: true,
|
||||
scanId: "githubSkillScans:1",
|
||||
scanStatus: "clean",
|
||||
};
|
||||
}
|
||||
if (name === "githubSkillSync:applyGitHubSkillVerificationResultInternal") {
|
||||
return { ok: true, promoted: true };
|
||||
}
|
||||
throw new Error(`unexpected mutation: ${name}`);
|
||||
});
|
||||
const ctx = {
|
||||
runQuery: vi.fn(async () => ({
|
||||
skill: {
|
||||
_id: "skills:aiq-deploy",
|
||||
slug: "aiq-deploy",
|
||||
displayName: "AIQ Deploy",
|
||||
githubPath: "skills/aiq-deploy",
|
||||
githubCurrentCommit: commit,
|
||||
githubCurrentContentHash: contentHash,
|
||||
githubCurrentStatus: "present",
|
||||
},
|
||||
source: {
|
||||
_id: "githubSkillSources:nvidia",
|
||||
repo: "NVIDIA/skills",
|
||||
defaultBranch: "main",
|
||||
},
|
||||
})),
|
||||
runMutation,
|
||||
storage: { store, delete: vi.fn() },
|
||||
};
|
||||
const fetcher = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.startsWith("https://api.github.com/")) {
|
||||
return new Response(JSON.stringify({ sha: commit }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (url.startsWith("https://codeload.github.com/")) {
|
||||
return new Response(zip, { headers: { "content-length": String(zip.byteLength) } });
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
});
|
||||
|
||||
await expect(
|
||||
verifyGitHubSkillHandler(
|
||||
ctx as never,
|
||||
{ skillId: "skills:aiq-deploy" as never, contentHash },
|
||||
fetcher as unknown as typeof fetch,
|
||||
),
|
||||
).resolves.toMatchObject({ ok: true, reused: true, scanStatus: "clean" });
|
||||
|
||||
expect(store).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes the newly stored boundary file when appending the previous chunk fails", async () => {
|
||||
const commit = "5".repeat(40);
|
||||
const zipEntries: Record<string, Uint8Array> = {
|
||||
"skills-main/skills/aiq-deploy/SKILL.md": new TextEncoder().encode("# AIQ Deploy\n"),
|
||||
};
|
||||
for (let index = 0; index < 100; index += 1) {
|
||||
zipEntries[
|
||||
`skills-main/skills/aiq-deploy/scripts/file-${String(index).padStart(3, "0")}.txt`
|
||||
] = new TextEncoder().encode(`file ${index}\n`);
|
||||
}
|
||||
const zip = zipSync(zipEntries);
|
||||
const snapshot = await buildGitHubSkillSourceSnapshot({
|
||||
repo: "NVIDIA/skills",
|
||||
defaultBranch: "main",
|
||||
commit,
|
||||
entries: stripGitHubZipRoot(__test.unzipToEntries(zip)),
|
||||
});
|
||||
const contentHash = snapshot.skills[0]?.contentHash;
|
||||
if (!contentHash) throw new Error("missing fixture hash");
|
||||
|
||||
let storedFile = 0;
|
||||
const store = vi.fn(async () => {
|
||||
storedFile += 1;
|
||||
return `storage:${storedFile}`;
|
||||
});
|
||||
const deleteFile = vi.fn(async () => undefined);
|
||||
const runMutation = vi.fn(async (mutation: unknown) => {
|
||||
const name = getFunctionName(mutation as Parameters<typeof getFunctionName>[0]);
|
||||
if (name === "securityScan:prepareGitHubSkillScanRequestInternal") {
|
||||
return {
|
||||
ok: true,
|
||||
prepared: true,
|
||||
scanId: "githubSkillScans:1",
|
||||
requestId: "skillScanRequests:1",
|
||||
};
|
||||
}
|
||||
if (name === "securityScan:appendGitHubSkillScanRequestFilesInternal") {
|
||||
throw new Error("append failed");
|
||||
}
|
||||
throw new Error(`unexpected mutation: ${name}`);
|
||||
});
|
||||
const ctx = {
|
||||
runQuery: vi.fn(async () => ({
|
||||
skill: {
|
||||
_id: "skills:aiq-deploy",
|
||||
slug: "aiq-deploy",
|
||||
displayName: "AIQ Deploy",
|
||||
githubPath: "skills/aiq-deploy",
|
||||
githubCurrentCommit: commit,
|
||||
githubCurrentContentHash: contentHash,
|
||||
githubCurrentStatus: "present",
|
||||
},
|
||||
source: {
|
||||
_id: "githubSkillSources:nvidia",
|
||||
repo: "NVIDIA/skills",
|
||||
defaultBranch: "main",
|
||||
},
|
||||
})),
|
||||
runMutation,
|
||||
storage: { store, delete: deleteFile },
|
||||
};
|
||||
const fetcher = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.startsWith("https://api.github.com/")) {
|
||||
return new Response(JSON.stringify({ sha: commit }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (url.startsWith("https://codeload.github.com/")) {
|
||||
return new Response(zip, { headers: { "content-length": String(zip.byteLength) } });
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
});
|
||||
|
||||
await expect(
|
||||
verifyGitHubSkillHandler(
|
||||
ctx as never,
|
||||
{ skillId: "skills:aiq-deploy" as never, contentHash },
|
||||
fetcher as unknown as typeof fetch,
|
||||
),
|
||||
).rejects.toThrow("append failed");
|
||||
|
||||
expect(store).toHaveBeenCalledTimes(101);
|
||||
expect(deleteFile).toHaveBeenCalledTimes(101);
|
||||
expect(deleteFile).toHaveBeenCalledWith("storage:101");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+194
-13
@@ -28,10 +28,12 @@ import { Events, logErrorEvent, logEvent } from "./lib/observabilityEvents";
|
||||
import { isOfficialPublisher } from "./lib/officialPublishers";
|
||||
import { requirePublisherRole } from "./lib/publishers";
|
||||
import { isMacJunkPath, isTextFile, parseFrontmatter } from "./lib/skills";
|
||||
import { chunkSkillScanRequestFiles } from "./lib/skillScanRequestFiles";
|
||||
import { syncSkillSearchDigestForSkill } from "./lib/skillSearchDigest";
|
||||
import { assertValidSkillSlug } from "./lib/skillSlugValidator";
|
||||
|
||||
const DEFAULT_BRANCH = "main";
|
||||
const GITHUB_SKILL_SCAN_ACTION_LEASE_MS = 15 * 60 * 1000;
|
||||
const PUBLIC_REPO_ONLY_ERROR = "Enter a public GitHub repo.";
|
||||
const MAX_UNZIPPED_BYTES = 80 * 1024 * 1024;
|
||||
const MAX_FILE_COUNT = 7_500;
|
||||
@@ -129,6 +131,20 @@ type GitHubSkillVerificationTarget = {
|
||||
source: Pick<Doc<"githubSkillSources">, "_id" | "repo" | "defaultBranch">;
|
||||
};
|
||||
|
||||
type GitHubSkillVerificationResult = {
|
||||
ok: true;
|
||||
prepared?: true;
|
||||
queued?: true;
|
||||
reused?: true;
|
||||
alreadyQueued?: true;
|
||||
skipped?: string;
|
||||
scanStatus?: GitHubSkillScanStatus;
|
||||
scanId?: Id<"githubSkillScans">;
|
||||
requestId?: Id<"skillScanRequests">;
|
||||
jobId?: Id<"securityScanJobs">;
|
||||
currentContentHash?: string;
|
||||
};
|
||||
|
||||
type GitHubSkillContentTarget = {
|
||||
skillId: Id<"skills">;
|
||||
githubPath: string;
|
||||
@@ -485,6 +501,7 @@ export async function applyGitHubSkillSourceSyncHandler(
|
||||
skillId: skillPatch.skillId as Id<"skills">,
|
||||
contentHash: discovered.contentHash,
|
||||
scanStatus: skillPatch.patch.githubScanStatus,
|
||||
now,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -568,6 +585,7 @@ export async function applyGitHubSkillSourceSyncHandler(
|
||||
skillId: existingBySlug._id,
|
||||
contentHash: discovered.contentHash,
|
||||
scanStatus: doc.githubScanStatus,
|
||||
now,
|
||||
});
|
||||
}
|
||||
await adjustGlobalPublicCountForSkillChange(
|
||||
@@ -610,6 +628,7 @@ export async function applyGitHubSkillSourceSyncHandler(
|
||||
skillId,
|
||||
contentHash: discovered.contentHash,
|
||||
scanStatus: doc.githubScanStatus,
|
||||
now,
|
||||
});
|
||||
}
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, null, insertedSkill, now);
|
||||
@@ -783,9 +802,70 @@ async function scheduleGitHubSkillVerification(
|
||||
skillId: Id<"skills">;
|
||||
contentHash: string;
|
||||
scanStatus: unknown;
|
||||
now: number;
|
||||
},
|
||||
) {
|
||||
if (args.scanStatus !== "pending") return;
|
||||
const scan = await ctx.db
|
||||
.query("githubSkillScans")
|
||||
.withIndex("by_skill_and_content_hash", (q) =>
|
||||
q.eq("skillId", args.skillId).eq("contentHash", args.contentHash),
|
||||
)
|
||||
.unique();
|
||||
if (args.scanStatus !== "pending") {
|
||||
if (scan?.status !== "pending") {
|
||||
if (scan) return;
|
||||
await applyGitHubSkillVerificationResultHandler(ctx, {
|
||||
skillId: args.skillId,
|
||||
contentHash: args.contentHash,
|
||||
scanStatus: "pending",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (scan?.status === "pending" && scan.skillScanRequestId) {
|
||||
const request = await ctx.db.get(scan.skillScanRequestId);
|
||||
const job = request?.securityScanJobId ? await ctx.db.get(request.securityScanJobId) : null;
|
||||
if (job?.status === "queued" || job?.status === "running") return;
|
||||
if (request && request.updatedAt > args.now - GITHUB_SKILL_SCAN_ACTION_LEASE_MS) return;
|
||||
}
|
||||
if (
|
||||
scan?.status === "pending" &&
|
||||
!scan.skillScanRequestId &&
|
||||
scan.updatedAt > args.now - GITHUB_SKILL_SCAN_ACTION_LEASE_MS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const skill = await ctx.db.get(args.skillId);
|
||||
if (
|
||||
!skill ||
|
||||
skill.installKind !== "github" ||
|
||||
!skill.githubSourceId ||
|
||||
!skill.githubPath ||
|
||||
skill.githubCurrentStatus !== "present" ||
|
||||
!skill.githubCurrentCommit ||
|
||||
skill.githubCurrentContentHash !== args.contentHash
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const pendingScanInsert = {
|
||||
githubSourceId: skill.githubSourceId,
|
||||
commit: skill.githubCurrentCommit,
|
||||
path: skill.githubPath,
|
||||
status: "pending" as const,
|
||||
updatedAt: args.now,
|
||||
};
|
||||
if (scan) {
|
||||
await ctx.db.patch(scan._id, {
|
||||
...pendingScanInsert,
|
||||
skillScanRequestId: undefined,
|
||||
});
|
||||
} else {
|
||||
await ctx.db.insert("githubSkillScans", {
|
||||
skillId: skill._id,
|
||||
contentHash: args.contentHash,
|
||||
...pendingScanInsert,
|
||||
createdAt: args.now,
|
||||
});
|
||||
}
|
||||
await ctx.scheduler?.runAfter(0, internal.githubSkillSyncNode.verifyGitHubSkillInternal, {
|
||||
skillId: args.skillId,
|
||||
contentHash: args.contentHash,
|
||||
@@ -860,12 +940,12 @@ export const applyGitHubSkillVerificationResultInternal = internalMutation({
|
||||
|
||||
export async function verifyGitHubSkillHandler(
|
||||
ctx: ActionCtx,
|
||||
args: { skillId: Id<"skills">; contentHash: string },
|
||||
args: { skillId: Id<"skills">; contentHash: string; force?: boolean },
|
||||
fetcher: typeof fetch = fetch,
|
||||
) {
|
||||
): Promise<GitHubSkillVerificationResult> {
|
||||
const target = (await ctx.runQuery(
|
||||
internal.githubSkillSync.getGitHubSkillVerificationTargetInternal,
|
||||
args,
|
||||
{ skillId: args.skillId, contentHash: args.contentHash },
|
||||
)) as GitHubSkillVerificationTarget | null;
|
||||
if (!target) return { ok: true as const, skipped: "stale-or-missing" as const };
|
||||
|
||||
@@ -895,16 +975,42 @@ export async function verifyGitHubSkillHandler(
|
||||
fileContents: listGitHubSkillTextContents(entries, discovered.path),
|
||||
});
|
||||
|
||||
await ctx.runMutation(internal.githubSkillSync.applyGitHubSkillVerificationResultInternal, {
|
||||
skillId: target.skill._id,
|
||||
contentHash: args.contentHash,
|
||||
scanStatus: staticScan.status,
|
||||
});
|
||||
const prepared = (await ctx.runMutation(
|
||||
internal.securityScan.prepareGitHubSkillScanRequestInternal,
|
||||
{
|
||||
skillId: target.skill._id,
|
||||
contentHash: args.contentHash,
|
||||
commit: target.skill.githubCurrentCommit,
|
||||
...(args.force ? { force: true } : {}),
|
||||
parsed: { frontmatter: parseFrontmatter(discovered.skillMarkdown) },
|
||||
staticScan,
|
||||
},
|
||||
)) as GitHubSkillVerificationResult | undefined;
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
scanStatus: staticScan.status,
|
||||
};
|
||||
if (!prepared?.prepared || !prepared.requestId) {
|
||||
if (prepared?.reused && prepared.scanStatus) {
|
||||
await ctx.runMutation(internal.githubSkillSync.applyGitHubSkillVerificationResultInternal, {
|
||||
skillId: target.skill._id,
|
||||
contentHash: args.contentHash,
|
||||
scanStatus: prepared.scanStatus,
|
||||
});
|
||||
}
|
||||
return prepared ?? { ok: true as const, skipped: "scan-request-not-created" as const };
|
||||
}
|
||||
|
||||
let chunkIndex = 0;
|
||||
await storeGitHubSkillScanFileChunks(ctx, entries, discovered.path, async (chunk) => {
|
||||
await ctx.runMutation(internal.securityScan.appendGitHubSkillScanRequestFilesInternal, {
|
||||
requestId: prepared.requestId as Id<"skillScanRequests">,
|
||||
chunkIndex,
|
||||
files: chunk,
|
||||
});
|
||||
chunkIndex += 1;
|
||||
});
|
||||
return (await ctx.runMutation(internal.securityScan.finalizeGitHubSkillScanRequestInternal, {
|
||||
requestId: prepared.requestId,
|
||||
...(args.force ? { force: true } : {}),
|
||||
})) as typeof prepared;
|
||||
}
|
||||
|
||||
export async function configurePublicGitHubSkillSourceHandler(
|
||||
@@ -1254,6 +1360,81 @@ function listGitHubSkillFolderEntries(entries: Record<string, Uint8Array>, folde
|
||||
.sort(([a], [b]) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
async function storeGitHubSkillScanFileChunks(
|
||||
ctx: Pick<ActionCtx, "storage">,
|
||||
entries: Record<string, Uint8Array>,
|
||||
folderPath: string,
|
||||
appendChunk: (
|
||||
files: Array<{
|
||||
path: string;
|
||||
size: number;
|
||||
storageId: Id<"_storage">;
|
||||
sha256: string;
|
||||
}>,
|
||||
) => Promise<void>,
|
||||
) {
|
||||
let pendingChunk: Array<{
|
||||
path: string;
|
||||
size: number;
|
||||
storageId: Id<"_storage">;
|
||||
sha256: string;
|
||||
}> = [];
|
||||
try {
|
||||
for (const [path, bytes] of listGitHubSkillFolderEntries(entries, folderPath)) {
|
||||
const safeBytes = new Uint8Array(bytes);
|
||||
const sha256 = await sha256Hex(safeBytes);
|
||||
const storageId = await ctx.storage.store(new Blob([safeBytes]));
|
||||
const file = {
|
||||
path,
|
||||
size: safeBytes.byteLength,
|
||||
storageId,
|
||||
sha256,
|
||||
};
|
||||
const nextPendingChunk = [...pendingChunk, file];
|
||||
let candidateChunks;
|
||||
try {
|
||||
candidateChunks = chunkSkillScanRequestFiles(nextPendingChunk);
|
||||
} catch (error) {
|
||||
pendingChunk = nextPendingChunk;
|
||||
throw error;
|
||||
}
|
||||
if (candidateChunks.length > 1) {
|
||||
try {
|
||||
await appendChunk(pendingChunk);
|
||||
} catch (error) {
|
||||
pendingChunk = nextPendingChunk;
|
||||
throw error;
|
||||
}
|
||||
pendingChunk = [file];
|
||||
} else {
|
||||
pendingChunk = candidateChunks[0] ?? [];
|
||||
}
|
||||
}
|
||||
if (pendingChunk.length > 0) {
|
||||
await appendChunk(pendingChunk);
|
||||
pendingChunk = [];
|
||||
}
|
||||
} catch (error) {
|
||||
// Prior chunks are owned by the durable request; only this bounded chunk can be orphaned.
|
||||
await deleteStoredGitHubSkillScanFiles(ctx, pendingChunk);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteStoredGitHubSkillScanFiles(
|
||||
ctx: Pick<ActionCtx, "storage">,
|
||||
files: Array<{ storageId: Id<"_storage"> }>,
|
||||
) {
|
||||
await Promise.allSettled(files.map((file) => ctx.storage.delete(file.storageId)));
|
||||
}
|
||||
|
||||
async function sha256Hex(bytes: Uint8Array) {
|
||||
const digest = await crypto.subtle.digest("SHA-256", new Uint8Array(bytes));
|
||||
return Array.from(new Uint8Array(digest))
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function buildGitHubSourceImport(repo: string, defaultBranch: string): GitHubImportUrl {
|
||||
const normalizedRepo = normalizeRepo(repo);
|
||||
const [owner, repoName] = normalizedRepo.split("/") as [string, string];
|
||||
|
||||
@@ -34,6 +34,7 @@ export const verifyGitHubSkillInternal = internalAction({
|
||||
args: {
|
||||
skillId: v.id("skills"),
|
||||
contentHash: v.string(),
|
||||
force: v.optional(v.boolean()),
|
||||
},
|
||||
handler: verifyGitHubSkillHandler,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Doc, Id } from "../_generated/dataModel";
|
||||
import type { MutationCtx } from "../_generated/server";
|
||||
|
||||
async function deleteGitHubSkillScan(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
scan: Doc<"githubSkillScans">,
|
||||
now: number,
|
||||
) {
|
||||
if (scan.skillScanRequestId) {
|
||||
const request = await ctx.db.get(scan.skillScanRequestId);
|
||||
if (request?.securityScanJobId) {
|
||||
const job = await ctx.db.get(request.securityScanJobId);
|
||||
if (job?.targetKind === "skillScanRequest") await ctx.db.delete(job._id);
|
||||
}
|
||||
if (request) {
|
||||
await ctx.db.patch(request._id, {
|
||||
status: "failed",
|
||||
securityScanJobId: undefined,
|
||||
githubSkillScanId: undefined,
|
||||
lastError: "GitHub-backed skill deleted",
|
||||
completedAt: now,
|
||||
expiresAt: now - 1,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
await ctx.db.delete(scan._id);
|
||||
}
|
||||
|
||||
export async function deleteGitHubSkillScansForSkill(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
skillId: Id<"skills">,
|
||||
limit?: number,
|
||||
) {
|
||||
const now = Date.now();
|
||||
const query = ctx.db
|
||||
.query("githubSkillScans")
|
||||
.withIndex("by_skill_and_content_hash", (q) => q.eq("skillId", skillId));
|
||||
const scans = limit === undefined ? await query.collect() : await query.take(limit);
|
||||
for (const scan of scans) await deleteGitHubSkillScan(ctx, scan, now);
|
||||
return scans.length;
|
||||
}
|
||||
|
||||
export async function deleteGitHubSkillScansForSource(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
sourceId: Id<"githubSkillSources">,
|
||||
limit: number,
|
||||
) {
|
||||
const now = Date.now();
|
||||
const scans = await ctx.db
|
||||
.query("githubSkillScans")
|
||||
.withIndex("by_github_source_and_updated_at", (q) => q.eq("githubSourceId", sourceId))
|
||||
.take(limit);
|
||||
for (const scan of scans) await deleteGitHubSkillScan(ctx, scan, now);
|
||||
return scans.length;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { chunkSkillScanRequestFiles } from "./skillScanRequestFiles";
|
||||
|
||||
function file(path: string) {
|
||||
return {
|
||||
path,
|
||||
size: 1,
|
||||
storageId: "storage:1" as never,
|
||||
sha256: "a".repeat(64),
|
||||
};
|
||||
}
|
||||
|
||||
describe("chunkSkillScanRequestFiles", () => {
|
||||
it("caps each action-to-mutation handoff at 100 file descriptors", () => {
|
||||
const chunks = chunkSkillScanRequestFiles(
|
||||
Array.from({ length: 205 }, (_, index) => file(`files/${index}.txt`)),
|
||||
);
|
||||
|
||||
expect(chunks.map((chunk) => chunk.length)).toEqual([100, 100, 5]);
|
||||
});
|
||||
|
||||
it("caps each action-to-mutation handoff at 256 KiB of serialized metadata", () => {
|
||||
const chunks = chunkSkillScanRequestFiles([
|
||||
file(`files/${"a".repeat(132_000)}.txt`),
|
||||
file(`files/${"b".repeat(132_000)}.txt`),
|
||||
]);
|
||||
|
||||
expect(chunks).toHaveLength(2);
|
||||
for (const chunk of chunks) {
|
||||
expect(new TextEncoder().encode(JSON.stringify(chunk)).byteLength).toBeLessThanOrEqual(
|
||||
256 * 1024,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects manifests whose total serialized metadata exceeds the worker hydration budget", () => {
|
||||
const files = Array.from({ length: 34 }, (_, index) =>
|
||||
file(`files/${String(index).padStart(2, "0")}-${"a".repeat(128_000)}.txt`),
|
||||
);
|
||||
|
||||
expect(() => chunkSkillScanRequestFiles(files)).toThrow(/manifest metadata exceeds/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Doc } from "../_generated/dataModel";
|
||||
|
||||
export const MAX_SKILL_SCAN_REQUEST_FILE_CHUNK_BYTES = 256 * 1024;
|
||||
export const MAX_SKILL_SCAN_REQUEST_FILES_PER_CHUNK = 100;
|
||||
export const MAX_SKILL_SCAN_REQUEST_FILE_CHUNKS = 100;
|
||||
export const MAX_SKILL_SCAN_REQUEST_MANIFEST_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
export function serializedSkillScanRequestFilesBytes(files: Doc<"skillScanRequests">["files"]) {
|
||||
return new TextEncoder().encode(JSON.stringify(files)).byteLength;
|
||||
}
|
||||
|
||||
export function chunkSkillScanRequestFiles(files: Doc<"skillScanRequests">["files"]) {
|
||||
const chunks: Array<Doc<"skillScanRequests">["files"]> = [];
|
||||
let current: Doc<"skillScanRequests">["files"] = [];
|
||||
let currentBytes = 2;
|
||||
let manifestBytes = 0;
|
||||
const pushCurrent = () => {
|
||||
if (current.length === 0) return;
|
||||
manifestBytes += serializedSkillScanRequestFilesBytes(current);
|
||||
if (
|
||||
chunks.length >= MAX_SKILL_SCAN_REQUEST_FILE_CHUNKS ||
|
||||
manifestBytes > MAX_SKILL_SCAN_REQUEST_MANIFEST_BYTES
|
||||
) {
|
||||
throw new Error("Skill scan file manifest metadata exceeds the hydration limit");
|
||||
}
|
||||
chunks.push(current);
|
||||
current = [];
|
||||
currentBytes = 2;
|
||||
};
|
||||
for (const file of files) {
|
||||
const fileBytes = new TextEncoder().encode(JSON.stringify(file)).byteLength + 1;
|
||||
if (fileBytes + 2 > MAX_SKILL_SCAN_REQUEST_FILE_CHUNK_BYTES) {
|
||||
throw new Error("Skill scan file metadata entry exceeds the chunk limit");
|
||||
}
|
||||
if (
|
||||
current.length > 0 &&
|
||||
(current.length >= MAX_SKILL_SCAN_REQUEST_FILES_PER_CHUNK ||
|
||||
currentBytes + fileBytes > MAX_SKILL_SCAN_REQUEST_FILE_CHUNK_BYTES)
|
||||
) {
|
||||
pushCurrent();
|
||||
}
|
||||
current.push(file);
|
||||
currentBytes += fileBytes;
|
||||
}
|
||||
pushCurrent();
|
||||
return chunks;
|
||||
}
|
||||
@@ -1558,6 +1558,9 @@ async function hardDeletePublisherRows(ctx: MutationCtx, publisherId: Id<"publis
|
||||
.collect();
|
||||
sourceContents += contents.length;
|
||||
for (const content of contents) await ctx.db.delete(content._id);
|
||||
await ctx.scheduler.runAfter(0, internal.githubSkillSources.cleanupDeletedSourceScansInternal, {
|
||||
sourceId: source._id,
|
||||
});
|
||||
await ctx.db.delete(source._id);
|
||||
}
|
||||
|
||||
|
||||
+39
-1
@@ -395,6 +395,26 @@ const githubSkillCurrentStatusValidator = v.union(
|
||||
v.literal("unknown"),
|
||||
);
|
||||
|
||||
const githubSkillScans = defineTable({
|
||||
skillId: v.id("skills"),
|
||||
githubSourceId: v.id("githubSkillSources"),
|
||||
contentHash: v.string(),
|
||||
commit: v.string(),
|
||||
path: v.string(),
|
||||
status: githubSkillScanStatusValidator,
|
||||
skillScanRequestId: v.optional(v.id("skillScanRequests")),
|
||||
staticScan: v.optional(staticScanValidator),
|
||||
skillSpectorAnalysis: v.optional(skillSpectorAnalysisValidator),
|
||||
llmAnalysis: v.optional(llmAnalysisValidator),
|
||||
lastError: v.optional(v.string()),
|
||||
runId: v.optional(v.string()),
|
||||
completedAt: v.optional(v.number()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_skill_and_content_hash", ["skillId", "contentHash"])
|
||||
.index("by_github_source_and_updated_at", ["githubSourceId", "updatedAt"]);
|
||||
|
||||
const packageFamilyValidator = v.union(
|
||||
v.literal("skill"),
|
||||
v.literal("code-plugin"),
|
||||
@@ -601,7 +621,11 @@ const packageFilesValidator = v.array(
|
||||
}),
|
||||
);
|
||||
|
||||
const skillScanRequestSourceKindValidator = v.union(v.literal("upload"), v.literal("published"));
|
||||
const skillScanRequestSourceKindValidator = v.union(
|
||||
v.literal("upload"),
|
||||
v.literal("published"),
|
||||
v.literal("github"),
|
||||
);
|
||||
|
||||
const skills = defineTable({
|
||||
slug: v.string(),
|
||||
@@ -1386,12 +1410,17 @@ const skillScanRequests = defineTable({
|
||||
writtenBack: v.boolean(),
|
||||
status: securityScanJobStatusValidator,
|
||||
securityScanJobId: v.optional(v.id("securityScanJobs")),
|
||||
requestedJobSource: v.optional(securityScanJobSourceValidator),
|
||||
requestedJobPriority: v.optional(v.number()),
|
||||
slug: v.optional(v.string()),
|
||||
displayName: v.optional(v.string()),
|
||||
version: v.optional(v.string()),
|
||||
skillId: v.optional(v.id("skills")),
|
||||
skillVersionId: v.optional(v.id("skillVersions")),
|
||||
githubSkillScanId: v.optional(v.id("githubSkillScans")),
|
||||
files: packageFilesValidator,
|
||||
fileChunkCount: v.optional(v.number()),
|
||||
fileManifestBytes: v.optional(v.number()),
|
||||
parsed: v.optional(
|
||||
v.object({
|
||||
frontmatter: v.record(v.string(), v.any()),
|
||||
@@ -1419,6 +1448,13 @@ const skillScanRequests = defineTable({
|
||||
.index("by_skill_version_id_and_created_at", ["skillVersionId", "createdAt"])
|
||||
.index("by_expires_at", ["expiresAt"]);
|
||||
|
||||
const skillScanRequestFileChunks = defineTable({
|
||||
skillScanRequestId: v.id("skillScanRequests"),
|
||||
chunkIndex: v.number(),
|
||||
files: packageFilesValidator,
|
||||
createdAt: v.number(),
|
||||
}).index("by_skill_scan_request_id_and_chunk_index", ["skillScanRequestId", "chunkIndex"]);
|
||||
|
||||
const skillCardGenerationJobs = defineTable({
|
||||
skillId: v.id("skills"),
|
||||
skillVersionId: v.id("skillVersions"),
|
||||
@@ -2528,6 +2564,7 @@ export default defineSchema({
|
||||
officialPublishers,
|
||||
githubSkillSources,
|
||||
githubSkillContents,
|
||||
githubSkillScans,
|
||||
skills,
|
||||
skillSlugAliases,
|
||||
packages,
|
||||
@@ -2537,6 +2574,7 @@ export default defineSchema({
|
||||
packageInspectorScanCursors,
|
||||
securityScanJobs,
|
||||
skillScanRequests,
|
||||
skillScanRequestFileChunks,
|
||||
skillCardGenerationJobs,
|
||||
packageStatEvents,
|
||||
packageTrustedPublishers,
|
||||
|
||||
+931
-10
File diff suppressed because it is too large
Load Diff
+582
-13
@@ -3,10 +3,18 @@ import { internal } from "./_generated/api";
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
||||
import { action, internalMutation, internalQuery, mutation } from "./functions";
|
||||
import { applyGitHubSkillVerificationResultHandler } from "./githubSkillSync";
|
||||
import { assertAdmin, assertModerator, requireUser } from "./lib/access";
|
||||
import { normalizePackageName } from "./lib/packageRegistry";
|
||||
import { normalizePackageScanStatus } from "./lib/packageSecurity";
|
||||
import { assertCanManageOwnedResource } from "./lib/publishers";
|
||||
import { sourceSkillVersionFiles } from "./lib/skillCards";
|
||||
import {
|
||||
chunkSkillScanRequestFiles,
|
||||
MAX_SKILL_SCAN_REQUEST_FILE_CHUNKS,
|
||||
MAX_SKILL_SCAN_REQUEST_MANIFEST_BYTES,
|
||||
serializedSkillScanRequestFilesBytes,
|
||||
} from "./lib/skillScanRequestFiles";
|
||||
|
||||
const DEFAULT_VT_WAIT_MS = 10 * 60 * 1000;
|
||||
const DEFAULT_LEASE_MS = 60 * 60 * 1000;
|
||||
@@ -18,8 +26,8 @@ const DEFAULT_CANCEL_SCAN_LIMIT = 1000;
|
||||
const DEFAULT_CANCEL_DELETE_LIMIT = 500;
|
||||
const MAX_CANCEL_SCAN_LIMIT = 5000;
|
||||
const CANCEL_SAMPLE_LIMIT = 20;
|
||||
const DEFAULT_PRUNE_SKILL_SCAN_REQUEST_LIMIT = 250;
|
||||
const MAX_PRUNE_SKILL_SCAN_REQUEST_LIMIT = 1000;
|
||||
const DEFAULT_PRUNE_SKILL_SCAN_REQUEST_LIMIT = 10;
|
||||
const MAX_PRUNE_SKILL_SCAN_REQUEST_LIMIT = 10;
|
||||
const DEFAULT_BULK_RESCAN_BATCH_SIZE = 50;
|
||||
const MAX_BULK_RESCAN_BATCH_SIZE = 100;
|
||||
const MAX_BULK_RESCAN_STATUS_JOB_IDS = 200;
|
||||
@@ -30,6 +38,7 @@ const MAX_STORED_SKILLSPECTOR_SHORT_TEXT_CHARS = 512;
|
||||
const DEFAULT_SKILL_SCAN_REQUEST_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const MAX_SKILL_SCAN_QUEUE_POSITION_READS = 250;
|
||||
const MAX_SKILL_SCAN_RUNNING_COUNT_READS = 512;
|
||||
const GITHUB_SKILL_SCAN_ACTION_LEASE_MS = 15 * 60 * 1000;
|
||||
const SKILL_SCAN_ASYNC_NOTE = "Scans are asynchronous and may take time to complete.";
|
||||
|
||||
const finalLlmAnalysisStatuses = new Set(["clean", "suspicious", "malicious"]);
|
||||
@@ -52,6 +61,8 @@ type JobTarget = {
|
||||
version?: Doc<"skillVersions">;
|
||||
release?: Doc<"packageReleases">;
|
||||
scanRequest?: Doc<"skillScanRequests">;
|
||||
scanRequestFiles?: Doc<"skillScanRequests">["files"];
|
||||
githubScan?: Doc<"githubSkillScans">;
|
||||
missing?: true;
|
||||
};
|
||||
|
||||
@@ -216,6 +227,32 @@ const scanRequestFileValidator = v.object({
|
||||
contentType: v.optional(v.string()),
|
||||
});
|
||||
|
||||
const staticScanResultValidator = v.object({
|
||||
status: v.union(v.literal("clean"), v.literal("suspicious"), v.literal("malicious")),
|
||||
reasonCodes: v.array(v.string()),
|
||||
findings: v.array(
|
||||
v.object({
|
||||
code: v.string(),
|
||||
severity: v.union(v.literal("info"), v.literal("warn"), v.literal("critical")),
|
||||
file: v.string(),
|
||||
line: v.number(),
|
||||
message: v.string(),
|
||||
evidence: v.string(),
|
||||
}),
|
||||
),
|
||||
summary: v.string(),
|
||||
engineVersion: v.string(),
|
||||
checkedAt: v.number(),
|
||||
});
|
||||
|
||||
const githubSkillScanStatusValidator = v.union(
|
||||
v.literal("clean"),
|
||||
v.literal("suspicious"),
|
||||
v.literal("malicious"),
|
||||
v.literal("pending"),
|
||||
v.literal("failed"),
|
||||
);
|
||||
|
||||
const internalRefs = internal as unknown as {
|
||||
packages: {
|
||||
getPackageByIdInternal: unknown;
|
||||
@@ -232,6 +269,7 @@ const internalRefs = internal as unknown as {
|
||||
failJobInternal: unknown;
|
||||
getSkillScanRequestForUserInternal: unknown;
|
||||
getJobTargetInternal: unknown;
|
||||
recordGitHubSkillScanResultInternal: unknown;
|
||||
recordSkillScanRequestFailedInternal: unknown;
|
||||
recordSkillScanRequestSucceededInternal: unknown;
|
||||
succeedJobInternal: unknown;
|
||||
@@ -277,6 +315,14 @@ function defaultVtWaitMs() {
|
||||
return Math.max(0, Math.min(parsed, DEFAULT_VT_WAIT_MS));
|
||||
}
|
||||
|
||||
function githubSkillScanStatusFromLlmAnalysis(
|
||||
analysis: Pick<NonNullable<Doc<"skillVersions">["llmAnalysis"]>, "status" | "verdict">,
|
||||
) {
|
||||
const status = normalizePackageScanStatus(analysis.verdict ?? analysis.status);
|
||||
if (status === "clean" || status === "suspicious" || status === "malicious") return status;
|
||||
return "failed" as const;
|
||||
}
|
||||
|
||||
function publicWorkerErrorDetail(error: string) {
|
||||
return error
|
||||
.replace(/https?:\/\/[^\s"')<>]+/g, "[redacted-url]")
|
||||
@@ -686,6 +732,100 @@ async function requestSkillRescanForActor(
|
||||
allowPlatformModerator: true,
|
||||
});
|
||||
|
||||
if (args.skill.installKind === "github") {
|
||||
if (
|
||||
args.skill.githubCurrentStatus !== "present" ||
|
||||
!args.skill.githubSourceId ||
|
||||
!args.skill.githubPath ||
|
||||
!args.skill.githubCurrentCommit ||
|
||||
!args.skill.githubCurrentContentHash
|
||||
) {
|
||||
throw new ConvexError("GitHub-backed skill content is not available");
|
||||
}
|
||||
const now = Date.now();
|
||||
const { scan, activeJob, actionPending } = await getGitHubSkillScanState(
|
||||
ctx,
|
||||
args.skill._id,
|
||||
args.skill.githubCurrentContentHash,
|
||||
now,
|
||||
);
|
||||
const alreadyQueued = Boolean(activeJob || actionPending);
|
||||
if (activeJob?.status === "queued") {
|
||||
await ctx.db.patch(activeJob._id, {
|
||||
source: "manual",
|
||||
priority: Math.max(activeJob.priority, 100),
|
||||
waitForVtUntil: Math.min(activeJob.waitForVtUntil, now),
|
||||
nextRunAt: Math.min(activeJob.nextRunAt, now),
|
||||
updatedAt: now,
|
||||
});
|
||||
} else if (actionPending && scan?.skillScanRequestId) {
|
||||
await ctx.db.patch(scan.skillScanRequestId, {
|
||||
requestedJobSource: "manual",
|
||||
requestedJobPriority: 100,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
if (!alreadyQueued) {
|
||||
const pendingScanInsert = {
|
||||
githubSourceId: args.skill.githubSourceId,
|
||||
commit: args.skill.githubCurrentCommit,
|
||||
path: args.skill.githubPath,
|
||||
status: "pending" as const,
|
||||
updatedAt: now,
|
||||
};
|
||||
if (scan) {
|
||||
await ctx.db.patch(scan._id, {
|
||||
...pendingScanInsert,
|
||||
skillScanRequestId: undefined,
|
||||
skillSpectorAnalysis: undefined,
|
||||
llmAnalysis: undefined,
|
||||
lastError: undefined,
|
||||
runId: undefined,
|
||||
completedAt: undefined,
|
||||
});
|
||||
} else {
|
||||
await ctx.db.insert("githubSkillScans", {
|
||||
skillId: args.skill._id,
|
||||
contentHash: args.skill.githubCurrentContentHash,
|
||||
...pendingScanInsert,
|
||||
createdAt: now,
|
||||
});
|
||||
}
|
||||
await ctx.scheduler.runAfter(0, internal.githubSkillSyncNode.verifyGitHubSkillInternal, {
|
||||
skillId: args.skill._id,
|
||||
contentHash: args.skill.githubCurrentContentHash,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
await ctx.db.insert("auditLogs", {
|
||||
actorUserId: args.actor._id,
|
||||
action: "skill.clawscan.rescan",
|
||||
targetType: "skill",
|
||||
targetId: args.skill._id,
|
||||
metadata: {
|
||||
skillId: args.skill._id,
|
||||
slug: args.skill.slug,
|
||||
commit: args.skill.githubCurrentCommit,
|
||||
contentHash: args.skill.githubCurrentContentHash,
|
||||
scheduled: !alreadyQueued,
|
||||
alreadyQueued,
|
||||
jobId: activeJob?._id,
|
||||
},
|
||||
createdAt: now,
|
||||
});
|
||||
return {
|
||||
ok: true as const,
|
||||
slug: args.skill.slug,
|
||||
version:
|
||||
args.skill.latestVersionSummary?.version ?? args.skill.githubCurrentCommit.slice(0, 12),
|
||||
skillId: args.skill._id,
|
||||
githubContentHash: args.skill.githubCurrentContentHash,
|
||||
...(activeJob ? { jobId: activeJob._id } : {}),
|
||||
scheduled: !alreadyQueued,
|
||||
alreadyQueued,
|
||||
};
|
||||
}
|
||||
|
||||
const requestedVersion = args.version?.trim();
|
||||
const version = requestedVersion
|
||||
? await ctx.db
|
||||
@@ -733,6 +873,44 @@ async function requestSkillRescanForActor(
|
||||
};
|
||||
}
|
||||
|
||||
async function getGitHubSkillScanState(
|
||||
ctx: MutationCtx,
|
||||
skillId: Id<"skills">,
|
||||
contentHash: string,
|
||||
now: number,
|
||||
) {
|
||||
const scan = await ctx.db
|
||||
.query("githubSkillScans")
|
||||
.withIndex("by_skill_and_content_hash", (q) =>
|
||||
q.eq("skillId", skillId).eq("contentHash", contentHash),
|
||||
)
|
||||
.unique();
|
||||
if (scan?.status !== "pending") return { scan, activeJob: null, actionPending: false };
|
||||
if (!scan.skillScanRequestId) {
|
||||
return {
|
||||
scan,
|
||||
activeJob: null,
|
||||
actionPending: scan.updatedAt > now - GITHUB_SKILL_SCAN_ACTION_LEASE_MS,
|
||||
};
|
||||
}
|
||||
const request = await ctx.db.get(scan.skillScanRequestId);
|
||||
if (!request?.securityScanJobId) {
|
||||
return {
|
||||
scan,
|
||||
activeJob: null,
|
||||
actionPending: Boolean(
|
||||
request && request.updatedAt > now - GITHUB_SKILL_SCAN_ACTION_LEASE_MS,
|
||||
),
|
||||
};
|
||||
}
|
||||
const job = await ctx.db.get(request.securityScanJobId);
|
||||
return {
|
||||
scan,
|
||||
activeJob: job && (job.status === "queued" || job.status === "running") ? job : null,
|
||||
actionPending: false,
|
||||
};
|
||||
}
|
||||
|
||||
export const requestSkillRescanForUserInternal = internalMutation({
|
||||
args: {
|
||||
actorUserId: v.id("users"),
|
||||
@@ -948,7 +1126,11 @@ async function skillScanStatusResponse(
|
||||
};
|
||||
}
|
||||
|
||||
async function enqueueSkillScanRequestJob(ctx: MutationCtx, requestId: Id<"skillScanRequests">) {
|
||||
async function enqueueSkillScanRequestJob(
|
||||
ctx: MutationCtx,
|
||||
requestId: Id<"skillScanRequests">,
|
||||
options?: { source?: SecurityScanJobSource; priority?: number },
|
||||
) {
|
||||
const request = await ctx.db.get(requestId);
|
||||
if (!request) throw new ConvexError("Scan request not found");
|
||||
const now = Date.now();
|
||||
@@ -956,8 +1138,8 @@ async function enqueueSkillScanRequestJob(ctx: MutationCtx, requestId: Id<"skill
|
||||
targetKind: "skillScanRequest",
|
||||
skillScanRequestId: request._id,
|
||||
status: "queued",
|
||||
source: "manual",
|
||||
priority: 100,
|
||||
source: options?.source ?? "manual",
|
||||
priority: options?.priority ?? 100,
|
||||
hasMaliciousSignal: false,
|
||||
waitForVtUntil: now,
|
||||
nextRunAt: now,
|
||||
@@ -972,6 +1154,264 @@ async function enqueueSkillScanRequestJob(ctx: MutationCtx, requestId: Id<"skill
|
||||
return jobId;
|
||||
}
|
||||
|
||||
export const prepareGitHubSkillScanRequestInternal = internalMutation({
|
||||
args: {
|
||||
skillId: v.id("skills"),
|
||||
contentHash: v.string(),
|
||||
commit: v.string(),
|
||||
force: v.optional(v.boolean()),
|
||||
parsed: v.object({
|
||||
frontmatter: v.record(v.string(), v.any()),
|
||||
}),
|
||||
staticScan: staticScanResultValidator,
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const skill = await ctx.db.get(args.skillId);
|
||||
if (
|
||||
!skill ||
|
||||
skill.installKind !== "github" ||
|
||||
!skill.githubSourceId ||
|
||||
!skill.githubPath ||
|
||||
skill.githubCurrentStatus !== "present" ||
|
||||
skill.githubCurrentCommit !== args.commit ||
|
||||
skill.githubCurrentContentHash !== args.contentHash
|
||||
) {
|
||||
return { ok: true as const, skipped: "stale-or-missing" as const };
|
||||
}
|
||||
const existing = await ctx.db
|
||||
.query("githubSkillScans")
|
||||
.withIndex("by_skill_and_content_hash", (q) =>
|
||||
q.eq("skillId", skill._id).eq("contentHash", args.contentHash),
|
||||
)
|
||||
.unique();
|
||||
if (existing && !args.force && existing.status !== "pending" && existing.status !== "failed") {
|
||||
await ctx.db.patch(existing._id, {
|
||||
githubSourceId: skill.githubSourceId,
|
||||
commit: args.commit,
|
||||
path: skill.githubPath,
|
||||
staticScan: args.staticScan,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
return {
|
||||
ok: true as const,
|
||||
reused: true as const,
|
||||
scanId: existing._id,
|
||||
scanStatus: existing.status,
|
||||
};
|
||||
}
|
||||
if (existing?.status === "pending" && existing.skillScanRequestId) {
|
||||
const request = await ctx.db.get(existing.skillScanRequestId);
|
||||
const job = request?.securityScanJobId ? await ctx.db.get(request.securityScanJobId) : null;
|
||||
if (request && job && (job.status === "queued" || job.status === "running")) {
|
||||
return {
|
||||
ok: true as const,
|
||||
alreadyQueued: true as const,
|
||||
scanId: existing._id,
|
||||
requestId: request._id,
|
||||
jobId: job._id,
|
||||
};
|
||||
}
|
||||
if (request && request.updatedAt > Date.now() - GITHUB_SKILL_SCAN_ACTION_LEASE_MS) {
|
||||
return {
|
||||
ok: true as const,
|
||||
alreadyQueued: true as const,
|
||||
scanId: existing._id,
|
||||
requestId: request._id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const scanId =
|
||||
existing?._id ??
|
||||
(await ctx.db.insert("githubSkillScans", {
|
||||
skillId: skill._id,
|
||||
githubSourceId: skill.githubSourceId,
|
||||
contentHash: args.contentHash,
|
||||
commit: args.commit,
|
||||
path: skill.githubPath,
|
||||
status: "pending",
|
||||
staticScan: args.staticScan,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}));
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, {
|
||||
githubSourceId: skill.githubSourceId,
|
||||
commit: args.commit,
|
||||
path: skill.githubPath,
|
||||
status: "pending",
|
||||
staticScan: args.staticScan,
|
||||
skillSpectorAnalysis: undefined,
|
||||
llmAnalysis: undefined,
|
||||
lastError: undefined,
|
||||
runId: undefined,
|
||||
completedAt: undefined,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
const requestId = await ctx.db.insert("skillScanRequests", {
|
||||
actorUserId: skill.ownerUserId,
|
||||
sourceKind: "github",
|
||||
update: false,
|
||||
writtenBack: false,
|
||||
status: "queued",
|
||||
slug: skill.slug,
|
||||
displayName: skill.displayName,
|
||||
version: skill.latestVersionSummary?.version ?? args.commit.slice(0, 12),
|
||||
skillId: skill._id,
|
||||
githubSkillScanId: scanId,
|
||||
files: [],
|
||||
fileChunkCount: 0,
|
||||
fileManifestBytes: 0,
|
||||
parsed: args.parsed,
|
||||
staticScan: args.staticScan,
|
||||
expiresAt: skillScanRequestExpiresAt(now),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await ctx.db.patch(scanId, { skillScanRequestId: requestId, updatedAt: now });
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
prepared: true as const,
|
||||
scanId,
|
||||
requestId,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const appendGitHubSkillScanRequestFilesInternal = internalMutation({
|
||||
args: {
|
||||
requestId: v.id("skillScanRequests"),
|
||||
chunkIndex: v.number(),
|
||||
files: v.array(scanRequestFileValidator),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
if (!Number.isInteger(args.chunkIndex) || args.chunkIndex < 0) {
|
||||
throw new ConvexError("Invalid file chunk index");
|
||||
}
|
||||
if (args.files.length === 0 || chunkSkillScanRequestFiles(args.files).length !== 1) {
|
||||
throw new ConvexError("Invalid file chunk");
|
||||
}
|
||||
const request = await ctx.db.get(args.requestId);
|
||||
if (
|
||||
!request ||
|
||||
request.sourceKind !== "github" ||
|
||||
!request.githubSkillScanId ||
|
||||
request.securityScanJobId
|
||||
) {
|
||||
throw new ConvexError("GitHub scan request is not accepting files");
|
||||
}
|
||||
const scan = await ctx.db.get(request.githubSkillScanId);
|
||||
if (!scan || scan.status !== "pending" || scan.skillScanRequestId !== request._id) {
|
||||
throw new ConvexError("GitHub scan request is no longer current");
|
||||
}
|
||||
const existing = await ctx.db
|
||||
.query("skillScanRequestFileChunks")
|
||||
.withIndex("by_skill_scan_request_id_and_chunk_index", (q) =>
|
||||
q.eq("skillScanRequestId", request._id).eq("chunkIndex", args.chunkIndex),
|
||||
)
|
||||
.unique();
|
||||
if (existing) {
|
||||
return { ok: true as const, appended: true as const };
|
||||
}
|
||||
const fileChunkCount = request.fileChunkCount ?? 0;
|
||||
const fileManifestBytes = request.fileManifestBytes ?? 0;
|
||||
const chunkBytes = serializedSkillScanRequestFilesBytes(args.files);
|
||||
if (
|
||||
args.chunkIndex !== fileChunkCount ||
|
||||
fileChunkCount >= MAX_SKILL_SCAN_REQUEST_FILE_CHUNKS ||
|
||||
fileManifestBytes + chunkBytes > MAX_SKILL_SCAN_REQUEST_MANIFEST_BYTES
|
||||
) {
|
||||
throw new ConvexError("GitHub scan request file manifest exceeds the hydration limit");
|
||||
}
|
||||
const now = Date.now();
|
||||
await ctx.db.insert("skillScanRequestFileChunks", {
|
||||
skillScanRequestId: request._id,
|
||||
chunkIndex: args.chunkIndex,
|
||||
files: args.files,
|
||||
createdAt: now,
|
||||
});
|
||||
await ctx.db.patch(request._id, {
|
||||
fileChunkCount: fileChunkCount + 1,
|
||||
fileManifestBytes: fileManifestBytes + chunkBytes,
|
||||
updatedAt: now,
|
||||
});
|
||||
return { ok: true as const, appended: true as const };
|
||||
},
|
||||
});
|
||||
|
||||
export const finalizeGitHubSkillScanRequestInternal = internalMutation({
|
||||
args: {
|
||||
requestId: v.id("skillScanRequests"),
|
||||
force: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const request = await ctx.db.get(args.requestId);
|
||||
if (!request || request.sourceKind !== "github" || !request.githubSkillScanId) {
|
||||
throw new ConvexError("GitHub scan request not found");
|
||||
}
|
||||
if (request.securityScanJobId) {
|
||||
const job = await ctx.db.get(request.securityScanJobId);
|
||||
if (job && (job.status === "queued" || job.status === "running")) {
|
||||
return {
|
||||
ok: true as const,
|
||||
alreadyQueued: true as const,
|
||||
scanId: request.githubSkillScanId,
|
||||
requestId: request._id,
|
||||
jobId: job._id,
|
||||
};
|
||||
}
|
||||
throw new ConvexError("GitHub scan request was already finalized");
|
||||
}
|
||||
const scan = await ctx.db.get(request.githubSkillScanId);
|
||||
const skill = scan ? await ctx.db.get(scan.skillId) : null;
|
||||
if (
|
||||
!scan ||
|
||||
scan.status !== "pending" ||
|
||||
scan.skillScanRequestId !== request._id ||
|
||||
!skill ||
|
||||
skill.installKind !== "github" ||
|
||||
skill.githubCurrentStatus !== "present" ||
|
||||
skill.githubSourceId !== scan.githubSourceId ||
|
||||
skill.githubPath !== scan.path ||
|
||||
skill.githubCurrentCommit !== scan.commit ||
|
||||
skill.githubCurrentContentHash !== scan.contentHash
|
||||
) {
|
||||
throw new ConvexError("GitHub scan request is no longer current");
|
||||
}
|
||||
const firstChunk = await ctx.db
|
||||
.query("skillScanRequestFileChunks")
|
||||
.withIndex("by_skill_scan_request_id_and_chunk_index", (q) =>
|
||||
q.eq("skillScanRequestId", request._id),
|
||||
)
|
||||
.take(1);
|
||||
if (
|
||||
firstChunk.length === 0 ||
|
||||
!request.fileChunkCount ||
|
||||
!request.fileManifestBytes ||
|
||||
request.fileChunkCount > MAX_SKILL_SCAN_REQUEST_FILE_CHUNKS ||
|
||||
request.fileManifestBytes > MAX_SKILL_SCAN_REQUEST_MANIFEST_BYTES
|
||||
) {
|
||||
throw new ConvexError("GitHub scan request files are missing");
|
||||
}
|
||||
|
||||
const jobId = await enqueueSkillScanRequestJob(ctx, request._id, {
|
||||
source: args.force ? "manual" : (request.requestedJobSource ?? "publish"),
|
||||
priority: Math.max(args.force ? 100 : 0, request.requestedJobPriority ?? 0),
|
||||
});
|
||||
return {
|
||||
ok: true as const,
|
||||
queued: true as const,
|
||||
scanId: scan._id,
|
||||
requestId: request._id,
|
||||
jobId,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const createUploadedSkillScanRequestInternal = internalMutation({
|
||||
args: {
|
||||
actorUserId: v.id("users"),
|
||||
@@ -1342,6 +1782,37 @@ export const recordSkillScanRequestFailedInternal = internalMutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const recordGitHubSkillScanResultInternal = internalMutation({
|
||||
args: {
|
||||
githubSkillScanId: v.id("githubSkillScans"),
|
||||
scanStatus: githubSkillScanStatusValidator,
|
||||
llmAnalysis: v.optional(llmAnalysisValidator),
|
||||
skillSpectorAnalysis: v.optional(skillSpectorAnalysisValidator),
|
||||
error: v.optional(v.string()),
|
||||
runId: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const scan = await ctx.db.get(args.githubSkillScanId);
|
||||
if (!scan) return { ok: true as const, skipped: "missing-scan" as const };
|
||||
const now = Date.now();
|
||||
await ctx.db.patch(scan._id, {
|
||||
status: args.scanStatus,
|
||||
llmAnalysis: args.llmAnalysis,
|
||||
skillSpectorAnalysis: args.skillSpectorAnalysis,
|
||||
lastError: args.error?.slice(0, 2000),
|
||||
runId: args.runId,
|
||||
completedAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
return await applyGitHubSkillVerificationResultHandler(ctx, {
|
||||
skillId: scan.skillId,
|
||||
contentHash: scan.contentHash,
|
||||
scanStatus: args.scanStatus,
|
||||
now,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const pruneExpiredSkillScanRequestsInternal = internalMutation({
|
||||
args: {
|
||||
batchSize: v.optional(v.number()),
|
||||
@@ -1362,6 +1833,8 @@ export const pruneExpiredSkillScanRequestsInternal = internalMutation({
|
||||
|
||||
let deletedJobs = 0;
|
||||
let deletedFiles = 0;
|
||||
let deletedRequests = 0;
|
||||
let deferredRequests = 0;
|
||||
for (const request of requests) {
|
||||
if (request.securityScanJobId) {
|
||||
const job = await ctx.db.get(request.securityScanJobId);
|
||||
@@ -1370,8 +1843,33 @@ export const pruneExpiredSkillScanRequestsInternal = internalMutation({
|
||||
deletedJobs += 1;
|
||||
}
|
||||
}
|
||||
if (request.sourceKind === "upload") {
|
||||
for (const file of request.files) {
|
||||
const fileChunks =
|
||||
request.sourceKind === "github"
|
||||
? await ctx.db
|
||||
.query("skillScanRequestFileChunks")
|
||||
.withIndex("by_skill_scan_request_id_and_chunk_index", (q) =>
|
||||
q.eq("skillScanRequestId", request._id),
|
||||
)
|
||||
.take(2)
|
||||
: [];
|
||||
if (fileChunks.length > 1) {
|
||||
const chunk = fileChunks[0];
|
||||
if (chunk) {
|
||||
for (const file of chunk.files) {
|
||||
try {
|
||||
await ctx.storage.delete(file.storageId);
|
||||
deletedFiles += 1;
|
||||
} catch {
|
||||
// Missing storage objects should not block expiry of the request row.
|
||||
}
|
||||
}
|
||||
await ctx.db.delete(chunk._id);
|
||||
}
|
||||
deferredRequests += 1;
|
||||
continue;
|
||||
}
|
||||
if (request.sourceKind === "upload" || request.sourceKind === "github") {
|
||||
for (const file of [...request.files, ...fileChunks.flatMap((chunk) => chunk.files)]) {
|
||||
try {
|
||||
await ctx.storage.delete(file.storageId);
|
||||
deletedFiles += 1;
|
||||
@@ -1380,15 +1878,24 @@ export const pruneExpiredSkillScanRequestsInternal = internalMutation({
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const chunk of fileChunks) await ctx.db.delete(chunk._id);
|
||||
await ctx.db.delete(request._id);
|
||||
deletedRequests += 1;
|
||||
}
|
||||
|
||||
const done = requests.length < batchSize && deferredRequests === 0;
|
||||
if (!done) {
|
||||
await ctx.scheduler.runAfter(0, internal.securityScan.pruneExpiredSkillScanRequestsInternal, {
|
||||
batchSize,
|
||||
});
|
||||
}
|
||||
return {
|
||||
ok: true as const,
|
||||
deletedRequests: requests.length,
|
||||
deletedRequests,
|
||||
deferredRequests,
|
||||
deletedJobs,
|
||||
deletedFiles,
|
||||
done: requests.length < batchSize,
|
||||
done,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1859,7 +2366,41 @@ export const getJobTargetInternal = internalQuery({
|
||||
? await ctx.db.get(scanRequest.skillVersionId)
|
||||
: null;
|
||||
const skill = scanRequest.skillId ? await ctx.db.get(scanRequest.skillId) : null;
|
||||
return { job, skill, version: version ?? undefined, scanRequest };
|
||||
const githubScan = scanRequest.githubSkillScanId
|
||||
? await ctx.db.get(scanRequest.githubSkillScanId)
|
||||
: null;
|
||||
let scanRequestFiles = scanRequest.files;
|
||||
if (scanRequest.sourceKind === "github") {
|
||||
const chunks = await ctx.db
|
||||
.query("skillScanRequestFileChunks")
|
||||
.withIndex("by_skill_scan_request_id_and_chunk_index", (q) =>
|
||||
q.eq("skillScanRequestId", scanRequest._id),
|
||||
)
|
||||
.take(MAX_SKILL_SCAN_REQUEST_FILE_CHUNKS + 1);
|
||||
const manifestBytes = chunks.reduce(
|
||||
(total, chunk) => total + serializedSkillScanRequestFilesBytes(chunk.files),
|
||||
0,
|
||||
);
|
||||
const declaredChunkCount = scanRequest.fileChunkCount ?? chunks.length;
|
||||
if (
|
||||
chunks.length > MAX_SKILL_SCAN_REQUEST_FILE_CHUNKS ||
|
||||
chunks.length !== declaredChunkCount ||
|
||||
manifestBytes > MAX_SKILL_SCAN_REQUEST_MANIFEST_BYTES ||
|
||||
(scanRequest.fileManifestBytes !== undefined &&
|
||||
manifestBytes !== scanRequest.fileManifestBytes)
|
||||
) {
|
||||
return { job, missing: true as const };
|
||||
}
|
||||
scanRequestFiles = chunks.flatMap((chunk) => chunk.files);
|
||||
}
|
||||
return {
|
||||
job,
|
||||
skill,
|
||||
version: version ?? undefined,
|
||||
scanRequest,
|
||||
scanRequestFiles,
|
||||
githubScan: githubScan ?? undefined,
|
||||
};
|
||||
}
|
||||
return { job, missing: true as const };
|
||||
},
|
||||
@@ -1933,7 +2474,8 @@ export const claimCodexScanJobs = action({
|
||||
internalRefs.securityScan.claimQueuedJobsInternal,
|
||||
{
|
||||
workerId: args.workerId,
|
||||
limit: normalizeLimit(args.limit),
|
||||
// Hydrated jobs contain signed URLs, so claim one at a time to stay below action limits.
|
||||
limit: Math.min(normalizeLimit(args.limit), 1),
|
||||
leaseMs: args.leaseMs,
|
||||
},
|
||||
);
|
||||
@@ -1965,7 +2507,9 @@ export const claimCodexScanJobs = action({
|
||||
contentType?: string;
|
||||
}> = [];
|
||||
if (scanRequest) {
|
||||
files = scanRequest.files;
|
||||
files =
|
||||
(target.scanRequestFiles as Doc<"skillScanRequests">["files"] | undefined) ??
|
||||
scanRequest.files;
|
||||
} else if (version) {
|
||||
const fingerprintEntries = await runQueryRef<
|
||||
Array<{ fingerprint: string; kind?: "source" | "generated-bundle" }>
|
||||
@@ -2091,12 +2635,25 @@ export const completeCodexScanJob = action({
|
||||
});
|
||||
writtenBack = true;
|
||||
}
|
||||
const skillSpectorAnalysis = args.skillSpectorAnalysis
|
||||
? capSkillSpectorAnalysisForStorage(args.skillSpectorAnalysis)
|
||||
: undefined;
|
||||
if (target.scanRequest.sourceKind === "github" && target.githubScan) {
|
||||
await runMutationRef(ctx, internalRefs.securityScan.recordGitHubSkillScanResultInternal, {
|
||||
githubSkillScanId: target.githubScan._id,
|
||||
scanStatus: githubSkillScanStatusFromLlmAnalysis(args.llmAnalysis),
|
||||
llmAnalysis: args.llmAnalysis,
|
||||
skillSpectorAnalysis,
|
||||
runId: args.runId,
|
||||
});
|
||||
writtenBack = true;
|
||||
}
|
||||
await runMutationRef(ctx, internalRefs.securityScan.recordSkillScanRequestSucceededInternal, {
|
||||
scanId: target.scanRequest._id,
|
||||
jobId: args.jobId,
|
||||
runId: args.runId,
|
||||
llmAnalysis: args.llmAnalysis,
|
||||
skillSpectorAnalysis: args.skillSpectorAnalysis,
|
||||
skillSpectorAnalysis,
|
||||
writtenBack,
|
||||
});
|
||||
} else {
|
||||
@@ -2156,6 +2713,18 @@ export const failCodexScanJob = action({
|
||||
});
|
||||
}
|
||||
} else if (target.job.targetKind === "skillScanRequest" && target.scanRequest) {
|
||||
if (target.scanRequest.sourceKind === "github" && target.githubScan) {
|
||||
await runMutationRef(
|
||||
ctx,
|
||||
internalRefs.securityScan.recordGitHubSkillScanResultInternal,
|
||||
{
|
||||
githubSkillScanId: target.githubScan._id,
|
||||
scanStatus: "failed",
|
||||
error: args.error,
|
||||
llmAnalysis,
|
||||
},
|
||||
);
|
||||
}
|
||||
await runMutationRef(
|
||||
ctx,
|
||||
internalRefs.securityScan.recordSkillScanRequestFailedInternal,
|
||||
|
||||
@@ -14,6 +14,7 @@ vi.mock("./lib/badges", () => ({
|
||||
|
||||
const { getAuthUserId } = await import("@convex-dev/auth/server");
|
||||
const { getSkillBadgeMap } = await import("./lib/badges");
|
||||
const skillsModule = await import("./skills");
|
||||
const {
|
||||
getBySlug,
|
||||
getVerifyTargetBySlugInternal,
|
||||
@@ -22,7 +23,7 @@ const {
|
||||
resolveSkillAppealForUserInternal,
|
||||
submitSkillAppealForUserInternal,
|
||||
triageSkillReportForUserInternal,
|
||||
} = await import("./skills");
|
||||
} = skillsModule;
|
||||
|
||||
type WrappedHandler<TArgs, TResult = unknown> = {
|
||||
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>;
|
||||
@@ -53,6 +54,12 @@ const getBySlugHandler = (
|
||||
contentType?: string;
|
||||
}>;
|
||||
} | null;
|
||||
githubScan?: {
|
||||
contentHash: string;
|
||||
commit: string;
|
||||
status: string;
|
||||
llmAnalysis?: { status: string };
|
||||
} | null;
|
||||
forkOf?: {
|
||||
skill: {
|
||||
slug: string;
|
||||
@@ -77,6 +84,22 @@ const getBySlugHandler = (
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const getGitHubScanForAuditHandler = (
|
||||
skillsModule as typeof skillsModule & {
|
||||
getGitHubScanForAudit?: WrappedHandler<
|
||||
{ slug: string },
|
||||
{
|
||||
contentHash: string;
|
||||
commit: string;
|
||||
path: string;
|
||||
status: string;
|
||||
version: string;
|
||||
llmAnalysis?: { status: string };
|
||||
} | null
|
||||
>;
|
||||
}
|
||||
).getGitHubScanForAudit?._handler;
|
||||
|
||||
const getVerifyTargetBySlugInternalHandler = (
|
||||
getVerifyTargetBySlugInternal as unknown as WrappedHandler<
|
||||
{
|
||||
@@ -187,6 +210,7 @@ function makeCtx(args: {
|
||||
ownerPublisher?: Record<string, unknown> | null;
|
||||
membership?: Record<string, unknown> | null;
|
||||
latestVersion?: Record<string, unknown> | null;
|
||||
githubScan?: Record<string, unknown> | null;
|
||||
skillsById?: Record<string, Record<string, unknown>>;
|
||||
ownersById?: Record<string, Record<string, unknown>>;
|
||||
}) {
|
||||
@@ -200,6 +224,13 @@ function makeCtx(args: {
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (table === "githubSkillScans") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
unique: vi.fn().mockResolvedValue(args.githubScan ?? null),
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (table !== "skills") throw new Error(`Unexpected query table: ${table}`);
|
||||
return { withIndex };
|
||||
});
|
||||
@@ -360,6 +391,114 @@ describe("skills.getBySlug", () => {
|
||||
expect(result?.owner).not.toHaveProperty("githubProfileSyncedAt");
|
||||
});
|
||||
|
||||
it("does not load the durable current-content scan in the general skill lookup", async () => {
|
||||
const contentHash = "a".repeat(64);
|
||||
const currentCommit = "b".repeat(40);
|
||||
const scanOriginCommit = "c".repeat(40);
|
||||
const ctx = makeCtx({
|
||||
skill: makeSkill({
|
||||
installKind: "github",
|
||||
githubPath: "skills/demo",
|
||||
githubCurrentCommit: currentCommit,
|
||||
githubCurrentContentHash: contentHash,
|
||||
githubCurrentStatus: "present",
|
||||
githubScanStatus: "clean",
|
||||
latestVersionSummary: {
|
||||
version: "1.2.3",
|
||||
createdAt: 2,
|
||||
changelog: "Synced from GitHub source.",
|
||||
},
|
||||
}),
|
||||
owner: makeOwner("users:1", "demo-owner"),
|
||||
githubScan: {
|
||||
_id: "githubSkillScans:1",
|
||||
skillId: "skills:1",
|
||||
githubSourceId: "githubSkillSources:1",
|
||||
contentHash,
|
||||
commit: scanOriginCommit,
|
||||
path: "skills/old-demo",
|
||||
status: "clean",
|
||||
staticScan: {
|
||||
status: "clean",
|
||||
reasonCodes: [],
|
||||
findings: [],
|
||||
summary: "No findings.",
|
||||
engineVersion: "test",
|
||||
checkedAt: 2,
|
||||
},
|
||||
llmAnalysis: { status: "clean", checkedAt: 3 },
|
||||
createdAt: 2,
|
||||
updatedAt: 3,
|
||||
completedAt: 3,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getBySlugHandler(ctx, { slug: "demo" } as never);
|
||||
|
||||
expect(result?.latestVersion).toBeNull();
|
||||
expect(result).not.toHaveProperty("githubScan");
|
||||
expect(
|
||||
(ctx as unknown as { db: { query: ReturnType<typeof vi.fn> } }).db.query,
|
||||
).not.toHaveBeenCalledWith("githubSkillScans");
|
||||
});
|
||||
|
||||
it("returns the durable current-content scan from the audit-specific query", async () => {
|
||||
expect(getGitHubScanForAuditHandler).toBeTypeOf("function");
|
||||
if (!getGitHubScanForAuditHandler) return;
|
||||
|
||||
const contentHash = "a".repeat(64);
|
||||
const currentCommit = "b".repeat(40);
|
||||
const scanOriginCommit = "c".repeat(40);
|
||||
const ctx = makeCtx({
|
||||
skill: makeSkill({
|
||||
installKind: "github",
|
||||
githubPath: "skills/demo",
|
||||
githubCurrentCommit: currentCommit,
|
||||
githubCurrentContentHash: contentHash,
|
||||
githubCurrentStatus: "present",
|
||||
githubScanStatus: "clean",
|
||||
latestVersionSummary: {
|
||||
version: "1.2.3",
|
||||
createdAt: 2,
|
||||
changelog: "Synced from GitHub source.",
|
||||
},
|
||||
}),
|
||||
owner: makeOwner("users:1", "demo-owner"),
|
||||
githubScan: {
|
||||
_id: "githubSkillScans:1",
|
||||
skillId: "skills:1",
|
||||
githubSourceId: "githubSkillSources:1",
|
||||
contentHash,
|
||||
commit: scanOriginCommit,
|
||||
path: "skills/old-demo",
|
||||
status: "clean",
|
||||
staticScan: {
|
||||
status: "clean",
|
||||
reasonCodes: [],
|
||||
findings: [],
|
||||
summary: "No findings.",
|
||||
engineVersion: "test",
|
||||
checkedAt: 2,
|
||||
},
|
||||
llmAnalysis: { status: "clean", checkedAt: 3 },
|
||||
createdAt: 2,
|
||||
updatedAt: 3,
|
||||
completedAt: 3,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getGitHubScanForAuditHandler(ctx, { slug: "demo" });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
contentHash,
|
||||
commit: currentCommit,
|
||||
path: "skills/demo",
|
||||
status: "clean",
|
||||
version: "1.2.3",
|
||||
llmAnalysis: { status: "clean", checkedAt: 3 },
|
||||
});
|
||||
});
|
||||
|
||||
it("hides skills whose owner is deleted or banned", async () => {
|
||||
const ctx = makeCtx({
|
||||
skill: {
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
canHealSkillOwnershipByGitHubProviderAccountId,
|
||||
getGitHubProviderAccountId,
|
||||
} from "./lib/githubIdentity";
|
||||
import { deleteGitHubSkillScansForSkill } from "./lib/githubSkillScans";
|
||||
import {
|
||||
adjustGlobalPublicSkillsCount,
|
||||
getPublicSkillVisibilityDelta,
|
||||
@@ -1565,6 +1566,7 @@ function enforceNewSkillRateLimit(signals: OwnerTrustSignals) {
|
||||
const HARD_DELETE_PHASES = [
|
||||
"versions",
|
||||
"fingerprints",
|
||||
"githubScans",
|
||||
"skillCardJobs",
|
||||
"embeddings",
|
||||
"comments",
|
||||
@@ -1664,6 +1666,19 @@ async function hardDeleteSkillStep(
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "fingerprints", scope);
|
||||
return;
|
||||
}
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "githubScans", scope);
|
||||
return;
|
||||
}
|
||||
case "githubScans": {
|
||||
const deletedScans = await deleteGitHubSkillScansForSkill(
|
||||
ctx,
|
||||
skill._id,
|
||||
HARD_DELETE_BATCH_SIZE,
|
||||
);
|
||||
if (deletedScans === HARD_DELETE_BATCH_SIZE) {
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "githubScans", scope);
|
||||
return;
|
||||
}
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "skillCardJobs", scope);
|
||||
return;
|
||||
}
|
||||
@@ -2261,6 +2276,35 @@ function toPublicSkillVersion(
|
||||
};
|
||||
}
|
||||
|
||||
function toPublicGitHubSkillScan(
|
||||
scan: Doc<"githubSkillScans"> | null | undefined,
|
||||
version: string | undefined,
|
||||
currentCommit: string | undefined,
|
||||
currentPath: string | undefined,
|
||||
) {
|
||||
if (!scan) return null;
|
||||
const commit = currentCommit ?? scan.commit;
|
||||
return {
|
||||
_id: scan._id,
|
||||
contentHash: scan.contentHash,
|
||||
commit,
|
||||
path: currentPath ?? scan.path,
|
||||
status: scan.status,
|
||||
version: version ?? commit.slice(0, 12),
|
||||
skillSpectorAnalysis: scan.skillSpectorAnalysis,
|
||||
llmAnalysis: scan.llmAnalysis,
|
||||
staticScan: scan.staticScan
|
||||
? {
|
||||
...scan.staticScan,
|
||||
findings: scan.staticScan.findings.map((finding) => ({ ...finding, evidence: "" })),
|
||||
}
|
||||
: undefined,
|
||||
completedAt: scan.completedAt,
|
||||
createdAt: scan.createdAt,
|
||||
updatedAt: scan.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function toPublicSkillCardFile(file: Doc<"skillVersions">["files"][number]) {
|
||||
return {
|
||||
path: file.path,
|
||||
@@ -2485,6 +2529,67 @@ function isDirectSkillOwner(
|
||||
return !skill.ownerPublisherId && skill.ownerUserId === userId;
|
||||
}
|
||||
|
||||
export const getGitHubScanForAudit = query({
|
||||
args: { slug: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const resolved = await resolveSkillBySlugOrAlias(ctx, args.slug);
|
||||
const skill = resolved.skill;
|
||||
if (
|
||||
!skill ||
|
||||
skill.installKind !== "github" ||
|
||||
!skill.githubCurrentContentHash ||
|
||||
!skill.githubCurrentCommit ||
|
||||
!skill.githubPath
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ownerPublisher = await getOwnerPublisher(ctx, {
|
||||
ownerPublisherId: skill.ownerPublisherId,
|
||||
ownerUserId: skill.ownerUserId,
|
||||
});
|
||||
if (!toPublicPublisher(ownerPublisher)) return null;
|
||||
const skillOwnerRef = {
|
||||
ownerPublisherId: skill.ownerPublisherId,
|
||||
ownerUserId: skill.ownerUserId,
|
||||
};
|
||||
|
||||
const isMalwareBlocked =
|
||||
skill.moderationVerdict === "malicious" ||
|
||||
(skill.moderationFlags?.includes("blocked.malware") ?? false);
|
||||
if (isMalwareBlocked) return null;
|
||||
|
||||
if (!isPublicSkillDoc(skill)) {
|
||||
const userId = await getOptionalActiveAuthUserId(ctx);
|
||||
const skillOwnerPublisher = skillOwnerRef.ownerPublisherId
|
||||
? await ctx.db.get(skillOwnerRef.ownerPublisherId)
|
||||
: null;
|
||||
const publisherOwner =
|
||||
userId && skillOwnerPublisher
|
||||
? await canAccessPublisherOwnerScope(ctx, {
|
||||
publisher: skillOwnerPublisher,
|
||||
userId,
|
||||
legacyOwnerUserId: skillOwnerRef.ownerUserId,
|
||||
})
|
||||
: false;
|
||||
if (!userId || (!isDirectSkillOwner(skillOwnerRef, userId) && !publisherOwner)) return null;
|
||||
}
|
||||
|
||||
const scan = await ctx.db
|
||||
.query("githubSkillScans")
|
||||
.withIndex("by_skill_and_content_hash", (q) =>
|
||||
q.eq("skillId", skill._id).eq("contentHash", skill.githubCurrentContentHash as string),
|
||||
)
|
||||
.unique();
|
||||
return toPublicGitHubSkillScan(
|
||||
scan,
|
||||
skill.latestVersionSummary?.version,
|
||||
skill.githubCurrentCommit,
|
||||
skill.githubPath,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const getBySlug = query({
|
||||
args: { slug: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@
|
||||
"ci:playwright": "VITE_CONVEX_URL=https://wry-manatee-359.convex.cloud VITE_CONVEX_SITE_URL=https://wry-manatee-359.convex.site bun run build && VITE_CONVEX_URL=https://wry-manatee-359.convex.cloud VITE_CONVEX_SITE_URL=https://wry-manatee-359.convex.site bun run test:pw",
|
||||
"ci:playwright-smoke": "VITE_CONVEX_URL=https://wry-manatee-359.convex.cloud VITE_CONVEX_SITE_URL=https://wry-manatee-359.convex.site bun run build && VITE_CONVEX_URL=https://wry-manatee-359.convex.cloud VITE_CONVEX_SITE_URL=https://wry-manatee-359.convex.site bun run test:pw -- --project=chromium e2e/ci-smoke.pw.test.ts e2e/public-routes-smoke.pw.test.ts",
|
||||
"ci:pr": "bun run ci:static && bun run ci:unit && bun run ci:packages && bun run ci:types-build && bun run ci:e2e-http",
|
||||
"ci:static": "bun run check:peers && bun audit --ignore GHSA-rmmr-r34h-pfm5 --ignore GHSA-gv7w-rqvm-qjhr --ignore GHSA-g7r4-m6w7-qqqr && bun run format:check && bun run lint && bun run deadcode:ci",
|
||||
"ci:static": "bun run check:peers && bun audit --ignore GHSA-rmmr-r34h-pfm5 --ignore GHSA-gv7w-rqvm-qjhr --ignore GHSA-g7r4-m6w7-qqqr --ignore GHSA-x4vx-rjvf-j5p4 --ignore GHSA-76mc-f452-cxcm --ignore GHSA-hpcv-96wg-7vj8 --ignore GHSA-r47g-fvhr-h676 --ignore GHSA-vxr8-fq34-vvx9 --ignore GHSA-gvmj-g25r-r7wr --ignore GHSA-rp9w-3fw7-7cwq && bun run format:check && bun run lint && bun run deadcode:ci",
|
||||
"ci:types-build": "bunx tsc --noEmit && bunx tsc -p packages/schema/tsconfig.json --noEmit && bunx tsc -p packages/clawhub/tsconfig.json --noEmit && bun run --cwd packages/clawhub-admin typecheck && VITE_CONVEX_URL=https://example.invalid bun run build",
|
||||
"ci:unit": "VITE_CONVEX_URL=https://example.invalid bun run coverage",
|
||||
"clawscan:local": "bun scripts/local-clawscan-dry-run.ts",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { parseArk } from "./ark";
|
||||
import {
|
||||
ApiV1SearchResponseSchema,
|
||||
ApiV1SkillRescanResponseSchema,
|
||||
ApiV1SkillVerifyResponseSchema,
|
||||
ClawdisSkillMetadataSchema,
|
||||
} from "./schemas";
|
||||
@@ -85,4 +86,23 @@ describe("packages/clawhub skill metadata schema", () => {
|
||||
expect(parsed.slug).toBe("demo");
|
||||
expect(parsed.version).toBe("1.0.0");
|
||||
});
|
||||
|
||||
it("parses GitHub-backed skill rescan responses", () => {
|
||||
const parsed = parseArk(
|
||||
ApiV1SkillRescanResponseSchema,
|
||||
{
|
||||
ok: true,
|
||||
slug: "github-demo",
|
||||
version: "abc123",
|
||||
skillId: "skills:github-demo",
|
||||
githubContentHash: "content-hash",
|
||||
scheduled: true,
|
||||
alreadyQueued: false,
|
||||
},
|
||||
"GitHub skill rescan response",
|
||||
);
|
||||
|
||||
if (!("githubContentHash" in parsed)) throw new Error("expected GitHub rescan response");
|
||||
expect(parsed.githubContentHash).toBe("content-hash");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -582,6 +582,15 @@ export const ApiV1SkillRescanResponseSchema = type({
|
||||
skillVersionId: "string",
|
||||
jobId: "string",
|
||||
alreadyQueued: "boolean",
|
||||
}).or({
|
||||
ok: "true",
|
||||
slug: "string",
|
||||
version: "string",
|
||||
skillId: "string",
|
||||
githubContentHash: "string",
|
||||
jobId: "string?",
|
||||
scheduled: "boolean",
|
||||
alreadyQueued: "boolean",
|
||||
});
|
||||
export type ApiV1SkillRescanResponse = (typeof ApiV1SkillRescanResponseSchema)[inferred];
|
||||
|
||||
|
||||
Vendored
+9
@@ -444,6 +444,15 @@ export declare const ApiV1SkillRescanResponseSchema: import("arktype/internal/va
|
||||
skillVersionId: string;
|
||||
jobId: string;
|
||||
alreadyQueued: boolean;
|
||||
} | {
|
||||
ok: true;
|
||||
slug: string;
|
||||
version: string;
|
||||
skillId: string;
|
||||
githubContentHash: string;
|
||||
scheduled: boolean;
|
||||
alreadyQueued: boolean;
|
||||
jobId?: string | undefined;
|
||||
}, {}>;
|
||||
export type ApiV1SkillRescanResponse = (typeof ApiV1SkillRescanResponseSchema)[inferred];
|
||||
export declare const ApiV1SkillScanStatusSchema: import("arktype/internal/variants/string.ts").StringType<"queued" | "running" | "succeeded" | "failed", {}>;
|
||||
|
||||
Vendored
+9
@@ -411,6 +411,15 @@ export const ApiV1SkillRescanResponseSchema = type({
|
||||
skillVersionId: "string",
|
||||
jobId: "string",
|
||||
alreadyQueued: "boolean",
|
||||
}).or({
|
||||
ok: "true",
|
||||
slug: "string",
|
||||
version: "string",
|
||||
skillId: "string",
|
||||
githubContentHash: "string",
|
||||
jobId: "string?",
|
||||
scheduled: "boolean",
|
||||
alreadyQueued: "boolean",
|
||||
});
|
||||
export const ApiV1SkillScanStatusSchema = type('"queued"|"running"|"succeeded"|"failed"');
|
||||
export const ApiV1SkillScanSourceSchema = type({
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -7,6 +7,7 @@ import { getPackageScopeOwnerMismatch, inferPackageNameScope } from "./packages"
|
||||
import {
|
||||
ApiSearchResponseSchema,
|
||||
ApiV1SkillInstallResolveResponseSchema,
|
||||
ApiV1SkillRescanResponseSchema,
|
||||
ApiV1SearchResponseSchema,
|
||||
ApiV1SkillVerifyResponseSchema,
|
||||
CliPublishRequestSchema,
|
||||
@@ -154,6 +155,40 @@ describe("clawhub-schema", () => {
|
||||
expect(legacy).toMatchObject({ roots: [{ rootId: "root" }] });
|
||||
});
|
||||
|
||||
it("accepts hosted and GitHub-backed skill rescan responses", () => {
|
||||
const hosted = parseArk(
|
||||
ApiV1SkillRescanResponseSchema,
|
||||
{
|
||||
ok: true,
|
||||
slug: "demo",
|
||||
version: "1.0.0",
|
||||
skillId: "skills:demo",
|
||||
skillVersionId: "skillVersions:demo",
|
||||
jobId: "securityScanJobs:demo",
|
||||
alreadyQueued: false,
|
||||
},
|
||||
"Hosted skill rescan response",
|
||||
);
|
||||
if (!("skillVersionId" in hosted)) throw new Error("expected hosted rescan response");
|
||||
expect(hosted.skillVersionId).toBe("skillVersions:demo");
|
||||
|
||||
const github = parseArk(
|
||||
ApiV1SkillRescanResponseSchema,
|
||||
{
|
||||
ok: true,
|
||||
slug: "github-demo",
|
||||
version: "abc123",
|
||||
skillId: "skills:github-demo",
|
||||
githubContentHash: "content-hash",
|
||||
scheduled: true,
|
||||
alreadyQueued: false,
|
||||
},
|
||||
"GitHub skill rescan response",
|
||||
);
|
||||
if (!("githubContentHash" in github)) throw new Error("expected GitHub rescan response");
|
||||
expect(github.githubContentHash).toBe("content-hash");
|
||||
});
|
||||
|
||||
it("accepts publish payloads with an owner handle", () => {
|
||||
const payload = parseArk(
|
||||
CliPublishRequestSchema,
|
||||
|
||||
@@ -482,6 +482,15 @@ export const ApiV1SkillRescanResponseSchema = type({
|
||||
skillVersionId: "string",
|
||||
jobId: "string",
|
||||
alreadyQueued: "boolean",
|
||||
}).or({
|
||||
ok: "true",
|
||||
slug: "string",
|
||||
version: "string",
|
||||
skillId: "string",
|
||||
githubContentHash: "string",
|
||||
jobId: "string?",
|
||||
scheduled: "boolean",
|
||||
alreadyQueued: "boolean",
|
||||
});
|
||||
export type ApiV1SkillRescanResponse = (typeof ApiV1SkillRescanResponseSchema)[inferred];
|
||||
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
assertCodexWorkerExecutionAllowed,
|
||||
isCodexWorkerExecutionAllowed,
|
||||
LOCAL_CODEX_WORKER_OPT_IN,
|
||||
resolveCodexWorkerHome,
|
||||
} from "../codex-worker-guard";
|
||||
import * as codexScanWorker from "./run-codex-scan-worker";
|
||||
import {
|
||||
buildPrompt,
|
||||
normalizeSkillSpectorAnalysis,
|
||||
@@ -30,6 +31,57 @@ async function tempDir() {
|
||||
}
|
||||
|
||||
describe("run-codex-scan-worker diagnostics", () => {
|
||||
it("keeps successful claims when a parallel claim request fails", async () => {
|
||||
const claimCodexScanJobBatch = (
|
||||
codexScanWorker as typeof codexScanWorker & {
|
||||
claimCodexScanJobBatch?: (
|
||||
claimLimit: number,
|
||||
claimOne: () => Promise<
|
||||
Array<{
|
||||
job: {
|
||||
_id: string;
|
||||
leaseToken: string;
|
||||
targetKind: "skillVersion";
|
||||
source: string;
|
||||
hasMaliciousSignal: boolean;
|
||||
waitForVtUntil: number;
|
||||
};
|
||||
target: Record<string, unknown>;
|
||||
}>
|
||||
>,
|
||||
) => Promise<Array<{ job: { _id: string } }>>;
|
||||
}
|
||||
).claimCodexScanJobBatch;
|
||||
expect(claimCodexScanJobBatch).toBeTypeOf("function");
|
||||
if (!claimCodexScanJobBatch) return;
|
||||
|
||||
const claimOne = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
job: {
|
||||
_id: "securityScanJobs:1",
|
||||
leaseToken: "lease",
|
||||
targetKind: "skillVersion",
|
||||
source: "publish",
|
||||
hasMaliciousSignal: false,
|
||||
waitForVtUntil: 0,
|
||||
},
|
||||
target: {},
|
||||
},
|
||||
])
|
||||
.mockRejectedValueOnce(new Error("temporary claim failure"))
|
||||
.mockResolvedValueOnce([]);
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
|
||||
await expect(claimCodexScanJobBatch(3, claimOne)).resolves.toMatchObject([
|
||||
{ job: { _id: "securityScanJobs:1" } },
|
||||
]);
|
||||
expect(claimOne).toHaveBeenCalledTimes(3);
|
||||
expect(consoleError).toHaveBeenCalledWith(expect.stringContaining("temporary claim failure"));
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it("blocks direct local Codex security worker runs without opt-in", () => {
|
||||
expect(isCodexWorkerExecutionAllowed({})).toBe(false);
|
||||
expect(() => assertCodexWorkerExecutionAllowed({})).toThrow(
|
||||
|
||||
@@ -1087,6 +1087,19 @@ async function processJob(
|
||||
}
|
||||
}
|
||||
|
||||
export async function claimCodexScanJobBatch(
|
||||
claimLimit: number,
|
||||
claimOne: () => Promise<ClaimedJob[]>,
|
||||
) {
|
||||
const results = await Promise.allSettled(Array.from({ length: claimLimit }, () => claimOne()));
|
||||
return results.flatMap((result) => {
|
||||
if (result.status === "fulfilled") return result.value;
|
||||
const message = result.reason instanceof Error ? result.reason.message : String(result.reason);
|
||||
console.error(`failed to claim security scan job: ${message}`);
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { batchLimit, maxJobs, maxRuntimeMs, leaseMs, diagnosticsRoot } = parseArgs();
|
||||
assertCodexWorkerExecutionAllowed(process.env);
|
||||
@@ -1111,12 +1124,16 @@ async function main() {
|
||||
const remainingJobs = maxJobs === undefined ? batchLimit : Math.max(0, maxJobs - totalClaimed);
|
||||
if (remainingJobs === 0) break;
|
||||
const claimLimit = Math.min(batchLimit, remainingJobs);
|
||||
const jobs = (await client.action(api.securityScan.claimCodexScanJobs, {
|
||||
token,
|
||||
workerId,
|
||||
limit: claimLimit,
|
||||
leaseMs,
|
||||
})) as ClaimedJob[];
|
||||
const jobs = await claimCodexScanJobBatch(
|
||||
claimLimit,
|
||||
async () =>
|
||||
(await client.action(api.securityScan.claimCodexScanJobs, {
|
||||
token,
|
||||
workerId,
|
||||
limit: 1,
|
||||
leaseMs,
|
||||
})) as ClaimedJob[],
|
||||
);
|
||||
console.log(`claimed ${jobs.length} job(s)`);
|
||||
if (jobs.length === 0) break;
|
||||
|
||||
|
||||
@@ -128,20 +128,55 @@ When a new source-backed skill appears or an existing skill's content hash
|
||||
changes:
|
||||
|
||||
- set `githubScanStatus: "pending"`
|
||||
- hide the skill from public installability with `moderationStatus: "hidden"`
|
||||
and `moderationReason: "pending.scan"`
|
||||
- enqueue verification for the current content hash
|
||||
- keep the catalog entry visible with `moderationReason: "pending.scan"`, while
|
||||
blocking normal install/update until the full scan completes
|
||||
- fetch the exact skill-folder bytes for the current commit and content hash
|
||||
- store those bytes only in ephemeral Convex storage, referenced by the
|
||||
`skillScanRequests` row through a prepare, bounded chunk append, and finalize
|
||||
sequence so no action-to-mutation argument carries the full file manifest;
|
||||
create the request before storing blobs and persist ownership after each
|
||||
bounded chunk so a terminated action can orphan at most its current chunk
|
||||
- cap the persisted file manifest at 4 MiB of descriptor metadata and hydrate
|
||||
one signed-URL-heavy worker job per claim response
|
||||
- enqueue the normal full ClawScan worker with deterministic static findings as
|
||||
input context
|
||||
- do not schedule another heavy verification action while that content hash
|
||||
already has an active queued/running scan job or a recently prepared request
|
||||
- enqueue explicit owner/moderator rescans in the high-priority manual queue
|
||||
|
||||
When verification succeeds cleanly:
|
||||
|
||||
- persist the completed ClawScan, SkillSpector, and static findings on a durable
|
||||
`githubSkillScans` row keyed by skill and content hash
|
||||
- set `githubScanStatus: "clean"`
|
||||
- make the skill active/installable
|
||||
|
||||
When verification fails, is suspicious, or is malicious:
|
||||
|
||||
- persist the final result on the same durable content-hash scan row
|
||||
- keep/block the skill from normal install
|
||||
- return a structured install block such as `github_scan_failed`
|
||||
|
||||
Completed clean, suspicious, and malicious verdicts may be reused for the same
|
||||
skill and content hash. Failed worker runs are not reusable verdicts and may be
|
||||
requeued for that same content hash after the underlying runtime problem is
|
||||
fixed. Reusing a result must reassociate it with the skill's current source,
|
||||
commit, and path before any old-source cleanup can run.
|
||||
|
||||
Legacy GitHub-backed rows that have a scan status but no durable
|
||||
`githubSkillScans` result are not trusted as full ClawScan verdicts. The next
|
||||
source sync must move them back to pending and enqueue the full pipeline.
|
||||
|
||||
GitHub-backed verification must not create a hosted `skillVersions` row or a
|
||||
ClawHub-owned install artifact. Expired request rows and their temporary stored
|
||||
files/chunks are pruned, while the small completed `githubSkillScans` result
|
||||
remains available for the public Security audit page. Source-wide scan-history
|
||||
cleanup runs in bounded asynchronous batches. Expired request cleanup deletes
|
||||
the linked worker job before deleting any files, then deletes at most one bounded
|
||||
GitHub file-metadata chunk per request and schedules an immediate continuation
|
||||
while work remains. Static findings alone must never promote or block the skill;
|
||||
the full ClawScan verdict controls `githubScanStatus`.
|
||||
|
||||
If the upstream path disappears:
|
||||
|
||||
- set `githubCurrentStatus: "missing"`
|
||||
|
||||
@@ -129,6 +129,23 @@ See also: [acceptable-usage.md](./acceptable-usage.md) for the marketplace polic
|
||||
storage. Published scan requests may patch a version only when the caller can
|
||||
manage the skill and explicitly sets `update: true`; local uploads must reject
|
||||
update mode.
|
||||
- GitHub-backed skill verification also uses ephemeral `skillScanRequests` to
|
||||
feed exact current skill-folder bytes into the normal ClawScan worker. Large
|
||||
file manifests use a prepare, bounded child-chunk append, and finalize sequence
|
||||
rather than one oversized Convex document or function argument. The request
|
||||
must exist before blob storage begins, each bounded chunk is durably attached
|
||||
as it is stored, descriptor metadata is capped at 4 MiB, and worker claims
|
||||
hydrate one signed-URL-heavy job at a time. A recently prepared request remains
|
||||
leased until finalization so concurrent syncs cannot replace it. Unlike
|
||||
user-submitted upload scans, its completed ClawScan, SkillSpector, and static
|
||||
context are persisted on `githubSkillScans` by skill and content hash so the
|
||||
public Security audit remains available after request files are pruned through
|
||||
bounded continuation batches. Cleanup cancels the linked worker job before
|
||||
deleting the first chunk. Legacy source-backed statuses without a durable
|
||||
`githubSkillScans` result must return to pending on sync rather than remain
|
||||
trusted. Explicit owner/moderator rescans use the manual worker queue.
|
||||
GitHub-backed verification must not create or patch a hosted `skillVersions`
|
||||
row, and static findings remain input context rather than a blocking verdict.
|
||||
- `auditLogs` remains the global compliance/security ledger. Product-facing
|
||||
moderation timelines live in `skillModerationEventLogs` and
|
||||
`packageModerationEventLogs`.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { getFunctionName } from "convex/server";
|
||||
import type { ComponentType } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -134,4 +135,66 @@ describe("skill security audit route", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the durable scan for a GitHub-backed skill without a hosted skill version", async () => {
|
||||
const requestRescan = vi.fn().mockResolvedValue({ ok: true });
|
||||
useMutationMock.mockReturnValue(requestRescan);
|
||||
useAuthStatusMock.mockReturnValue({
|
||||
me: { _id: "users:moderator", role: "moderator" },
|
||||
});
|
||||
useQueryMock.mockImplementation((ref: unknown, args: unknown) => {
|
||||
if (args === "skip" || (args && Object.keys(args as Record<string, unknown>).length === 0)) {
|
||||
return [];
|
||||
}
|
||||
const name = getFunctionName(ref as Parameters<typeof getFunctionName>[0]);
|
||||
if (name === "skills:getGitHubScanForAudit") {
|
||||
return {
|
||||
contentHash: "a".repeat(64),
|
||||
commit: "b".repeat(40),
|
||||
status: "clean",
|
||||
version: "1.2.3",
|
||||
llmAnalysis: {
|
||||
status: "clean",
|
||||
verdict: "benign",
|
||||
summary: "No material risks found.",
|
||||
checkedAt: 123,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (name === "skills:getBySlug") {
|
||||
return {
|
||||
skill: {
|
||||
_id: "skills:github",
|
||||
slug: "github-demo",
|
||||
displayName: "GitHub Demo",
|
||||
ownerUserId: "users:owner",
|
||||
installKind: "github",
|
||||
},
|
||||
latestVersion: null,
|
||||
owner: {
|
||||
_id: "users:owner",
|
||||
handle: "nvidia",
|
||||
},
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
paramsMock = { owner: "nvidia", slug: "github-demo" };
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
|
||||
render(<Component />);
|
||||
|
||||
expect(screen.queryByText("Security audit is unavailable for this skill.")).toBeNull();
|
||||
expect(screen.getByText("GitHub Demo")).toBeTruthy();
|
||||
expect(screen.getByText("No material risks found.")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Rescan" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(requestRescan).toHaveBeenCalledWith({
|
||||
skillId: "skills:github",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,12 +72,17 @@ function SkillSecurityAuditRoute() {
|
||||
const result = liveResult === undefined ? initialData?.result : liveResult;
|
||||
const skill = result?.skill;
|
||||
const latestVersion = result?.latestVersion;
|
||||
const githubScan = useQuery(
|
||||
api.skills.getGitHubScanForAudit,
|
||||
skill?.installKind === "github" ? { slug } : "skip",
|
||||
);
|
||||
const audit = latestVersion ?? githubScan;
|
||||
|
||||
if (result === undefined) {
|
||||
if (result === undefined || (skill?.installKind === "github" && githubScan === undefined)) {
|
||||
return <SecurityAuditPageSkeleton />;
|
||||
}
|
||||
|
||||
if (!skill || !latestVersion) {
|
||||
if (!skill || !audit) {
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="card">Security audit is unavailable for this skill.</div>
|
||||
@@ -102,21 +107,25 @@ function SkillSecurityAuditRoute() {
|
||||
kind: "skill",
|
||||
title: skill.displayName,
|
||||
name: slug,
|
||||
version: latestVersion.version,
|
||||
version: audit.version,
|
||||
owner: result?.owner ?? null,
|
||||
ownerUserId: skill.ownerUserId,
|
||||
ownerPublisherId: skill.ownerPublisherId ?? null,
|
||||
detailPath: `/${encodeURIComponent(ownerSegment)}/${encodeURIComponent(slug)}`,
|
||||
}}
|
||||
sha256hash={latestVersion.sha256hash ?? null}
|
||||
vtAnalysis={latestVersion.vtAnalysis ?? null}
|
||||
llmAnalysis={latestVersion.llmAnalysis ?? null}
|
||||
skillSpectorAnalysis={latestVersion.skillSpectorAnalysis ?? null}
|
||||
staticScan={latestVersion.staticScan ?? null}
|
||||
sha256hash={latestVersion?.sha256hash ?? null}
|
||||
vtAnalysis={latestVersion?.vtAnalysis ?? null}
|
||||
llmAnalysis={audit.llmAnalysis ?? null}
|
||||
skillSpectorAnalysis={audit.skillSpectorAnalysis ?? null}
|
||||
staticScan={audit.staticScan ?? null}
|
||||
canManageArtifact={canManageArtifact}
|
||||
onRequestRescan={
|
||||
canManageArtifact
|
||||
? () => requestSkillRescan({ skillId: skill._id, version: latestVersion.version })
|
||||
? () =>
|
||||
requestSkillRescan({
|
||||
skillId: skill._id,
|
||||
...(latestVersion ? { version: latestVersion.version } : {}),
|
||||
})
|
||||
: null
|
||||
}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user