mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
fix: resolve owner-scoped skill scans (#3322)
This commit is contained in:
@@ -353,6 +353,45 @@ describe("httpApiV1 handlers", () => {
|
||||
expect(await response.text()).toContain("clawhub scan download <slug> --version <version>");
|
||||
});
|
||||
|
||||
it("forwards the owner namespace when submitting a published skill scan", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:owner",
|
||||
user: { _id: "users:owner", role: "user" },
|
||||
} as never);
|
||||
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
expect(args).toMatchObject({
|
||||
actorUserId: "users:owner",
|
||||
slug: "clawtopics-link",
|
||||
ownerHandle: "tekoai",
|
||||
version: "1.0.2",
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
scanId: "skillScanRequests:scan",
|
||||
status: "queued",
|
||||
sourceKind: "published",
|
||||
update: false,
|
||||
};
|
||||
});
|
||||
const response = await __handlers.skillScanSubmitV1Handler(
|
||||
makeCtx({ runMutation }),
|
||||
new Request("https://example.com/api/v1/skills/-/scan", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
source: {
|
||||
kind: "published",
|
||||
slug: "clawtopics-link",
|
||||
ownerHandle: "tekoai",
|
||||
version: "1.0.2",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(202);
|
||||
});
|
||||
|
||||
it("downloads stored scan reports for submitted skill versions", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:owner",
|
||||
@@ -409,6 +448,49 @@ describe("httpApiV1 handlers", () => {
|
||||
expect(readme).toContain("`clawscan.json`: final ClawScan verdict");
|
||||
});
|
||||
|
||||
it("forwards the owner namespace when downloading a stored skill report", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:owner",
|
||||
user: { _id: "users:owner", role: "user" },
|
||||
} as never);
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
expect(args).toMatchObject({
|
||||
actorUserId: "users:owner",
|
||||
kind: "skill",
|
||||
name: "clawtopics-link",
|
||||
ownerHandle: "tekoai",
|
||||
version: "1.0.2",
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
scanId: "skill:clawtopics-link:1.0.2",
|
||||
status: "succeeded",
|
||||
sourceKind: "published",
|
||||
update: false,
|
||||
writtenBack: true,
|
||||
artifact: { kind: "skill", slug: "clawtopics-link", version: "1.0.2" },
|
||||
report: {
|
||||
clawscan: { status: "clean", checkedAt: 1 },
|
||||
skillspector: null,
|
||||
staticAnalysis: null,
|
||||
virustotal: null,
|
||||
},
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
completedAt: 1,
|
||||
};
|
||||
});
|
||||
const response = await __handlers.skillScanGetRouterV1Handler(
|
||||
makeCtx({ runQuery }),
|
||||
new Request(
|
||||
"https://example.com/api/v1/skills/-/scan/download/clawtopics-link?version=1.0.2&ownerHandle=tekoai",
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("search returns empty results for blank query", async () => {
|
||||
const runAction = vi.fn();
|
||||
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
|
||||
|
||||
@@ -1233,7 +1233,9 @@ export async function skillScanSubmitV1Handler(ctx: ActionCtx, request: Request)
|
||||
await request.json(),
|
||||
"Skill scan payload",
|
||||
) as {
|
||||
source: { kind: "upload" } | { kind: "published"; slug: string; version?: string };
|
||||
source:
|
||||
| { kind: "upload" }
|
||||
| { kind: "published"; slug: string; ownerHandle?: string; version?: string };
|
||||
update?: boolean;
|
||||
};
|
||||
if (body.source.kind === "upload") {
|
||||
@@ -1249,6 +1251,7 @@ export async function skillScanSubmitV1Handler(ctx: ActionCtx, request: Request)
|
||||
{
|
||||
actorUserId: auth.userId,
|
||||
slug: body.source.slug,
|
||||
...(body.source.ownerHandle ? { ownerHandle: body.source.ownerHandle } : {}),
|
||||
...(body.source.version ? { version: body.source.version } : {}),
|
||||
update: body.update === true,
|
||||
},
|
||||
@@ -1280,6 +1283,7 @@ export async function skillScanGetRouterV1Handler(ctx: ActionCtx, request: Reque
|
||||
const url = new URL(request.url);
|
||||
const version = url.searchParams.get("version")?.trim() ?? "";
|
||||
const kind = url.searchParams.get("kind")?.trim() === "plugin" ? "plugin" : "skill";
|
||||
const ownerHandle = url.searchParams.get("ownerHandle")?.trim() || undefined;
|
||||
if (!name) return text("name required", 400, rate.headers);
|
||||
if (!version) return text("version required", 400, rate.headers);
|
||||
|
||||
@@ -1290,6 +1294,7 @@ export async function skillScanGetRouterV1Handler(ctx: ActionCtx, request: Reque
|
||||
actorUserId: auth.userId,
|
||||
kind,
|
||||
name,
|
||||
...(ownerHandle ? { ownerHandle } : {}),
|
||||
version,
|
||||
},
|
||||
)) as Record<string, unknown>;
|
||||
|
||||
+243
-25
@@ -8,6 +8,7 @@ import {
|
||||
clearQueuedBackfillJobsForLocalDev,
|
||||
claimQueuedJobsInternal,
|
||||
completeCodexScanJob,
|
||||
createPublishedSkillScanRequestInternal,
|
||||
enqueueBulkSkillRescanBatchForAdminInternal,
|
||||
enqueuePackageReleaseScanInternal,
|
||||
enqueueSkillVersionScanInternal,
|
||||
@@ -534,12 +535,26 @@ const getSkillScanRequestForUserInternalHandler = (
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const createPublishedSkillScanRequestInternalHandler = (
|
||||
createPublishedSkillScanRequestInternal as unknown as WrappedHandler<
|
||||
{
|
||||
actorUserId: string;
|
||||
slug: string;
|
||||
ownerHandle?: string;
|
||||
version?: string;
|
||||
update?: boolean;
|
||||
},
|
||||
{ ok: true; scanId: string; status: string; sourceKind: string }
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const getStoredScanReportForUserInternalHandler = (
|
||||
getStoredScanReportForUserInternal as unknown as WrappedHandler<
|
||||
{
|
||||
actorUserId: string;
|
||||
kind: "skill" | "plugin";
|
||||
name: string;
|
||||
ownerHandle?: string;
|
||||
version: string;
|
||||
},
|
||||
{
|
||||
@@ -769,6 +784,11 @@ function makeTarget(llmStatus?: string) {
|
||||
};
|
||||
}
|
||||
|
||||
type RescanRangeBuilder = {
|
||||
eq: (field: string, value: unknown) => RescanRangeBuilder;
|
||||
lte: (field: string, value: number) => RescanRangeBuilder;
|
||||
};
|
||||
|
||||
function makeRescanCtx(options: {
|
||||
actorId: string;
|
||||
actorRole?: "admin" | "moderator" | "user";
|
||||
@@ -800,13 +820,35 @@ function makeRescanCtx(options: {
|
||||
patches.push({ id, patch: doc });
|
||||
});
|
||||
const query = vi.fn((table: string) => ({
|
||||
withIndex: vi.fn((_indexName: string, buildRange: (q: { eq: typeof eq }) => unknown) => {
|
||||
withIndex: vi.fn((_indexName: string, buildRange: (q: RescanRangeBuilder) => unknown) => {
|
||||
const equals = new Map<string, unknown>();
|
||||
function eq(field: string, value: unknown) {
|
||||
equals.set(field, value);
|
||||
return { eq };
|
||||
}
|
||||
buildRange({ eq });
|
||||
const upperBounds = new Map<string, number>();
|
||||
const range: RescanRangeBuilder = {
|
||||
eq(field, value) {
|
||||
equals.set(field, value);
|
||||
return range;
|
||||
},
|
||||
lte(field, value) {
|
||||
upperBounds.set(field, value);
|
||||
return range;
|
||||
},
|
||||
};
|
||||
buildRange(range);
|
||||
const matchingSecurityJobs = () =>
|
||||
Array.from(docs.values())
|
||||
.filter(
|
||||
(doc) =>
|
||||
String(doc._id).startsWith("securityScanJobs:") &&
|
||||
Array.from(equals.entries()).every(([field, value]) => doc[field] === value) &&
|
||||
Array.from(upperBounds.entries()).every(
|
||||
([field, value]) => Number(doc[field] ?? Number.POSITIVE_INFINITY) <= value,
|
||||
),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Number(a.nextRunAt ?? 0) - Number(b.nextRunAt ?? 0) ||
|
||||
Number(a._creationTime ?? 0) - Number(b._creationTime ?? 0),
|
||||
);
|
||||
return {
|
||||
collect: vi.fn(async () => {
|
||||
if (table === "securityScanJobs") {
|
||||
@@ -817,24 +859,14 @@ function makeRescanCtx(options: {
|
||||
return [];
|
||||
}),
|
||||
order: vi.fn(() => ({
|
||||
first: vi.fn(async () => {
|
||||
if (table !== "securityScanJobs") return null;
|
||||
return (
|
||||
Array.from(docs.values())
|
||||
.filter(
|
||||
(doc) =>
|
||||
String(doc._id).startsWith("securityScanJobs:") &&
|
||||
(!equals.has("status") || doc.status === equals.get("status")),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Number(a.nextRunAt ?? 0) - Number(b.nextRunAt ?? 0) ||
|
||||
Number(a._creationTime ?? 0) - Number(b._creationTime ?? 0),
|
||||
)[0] ?? null
|
||||
);
|
||||
}),
|
||||
first: vi.fn(async () =>
|
||||
table === "securityScanJobs" ? (matchingSecurityJobs()[0] ?? null) : null,
|
||||
),
|
||||
take: vi.fn(async (limit: number) =>
|
||||
table === "securityScanJobs" ? matchingSecurityJobs().slice(0, limit) : [],
|
||||
),
|
||||
})),
|
||||
take: vi.fn(async () => {
|
||||
take: vi.fn(async (limit: number) => {
|
||||
if (table === "skills") {
|
||||
return Array.from(docs.values()).filter((doc) => {
|
||||
if (!doc._id?.toString().startsWith("skills:")) return false;
|
||||
@@ -843,6 +875,7 @@ function makeRescanCtx(options: {
|
||||
return !ownerPublisherId || doc.ownerPublisherId === ownerPublisherId;
|
||||
});
|
||||
}
|
||||
if (table === "securityScanJobs") return matchingSecurityJobs().slice(0, limit);
|
||||
return [];
|
||||
}),
|
||||
unique: vi.fn(async () => {
|
||||
@@ -1354,16 +1387,35 @@ function makeStoredScanReportCtx(options: {
|
||||
return { eq };
|
||||
}
|
||||
buildRange({ eq });
|
||||
const matchingSkills = () =>
|
||||
Array.from(docs.values()).filter(
|
||||
(doc) =>
|
||||
String(doc._id).startsWith("skills:") &&
|
||||
doc.slug === equals.get("slug") &&
|
||||
(!equals.has("ownerPublisherId") ||
|
||||
doc.ownerPublisherId === equals.get("ownerPublisherId")),
|
||||
);
|
||||
return {
|
||||
take: vi.fn(async (limit: number) => {
|
||||
if (table === "skills") return matchingSkills().slice(0, limit);
|
||||
if (table === "skillSlugAliases") return [];
|
||||
return [];
|
||||
}),
|
||||
unique: vi.fn(async () => {
|
||||
if (table === "publisherMembers") return options.membership ?? null;
|
||||
if (table === "skills") {
|
||||
if (table === "publishers") {
|
||||
return (
|
||||
Array.from(docs.values()).find(
|
||||
(doc) => String(doc._id).startsWith("skills:") && doc.slug === equals.get("slug"),
|
||||
(doc) =>
|
||||
String(doc._id).startsWith("publishers:") && doc.handle === equals.get("handle"),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
if (table === "skills") {
|
||||
const matches = matchingSkills();
|
||||
if (matches.length > 1) throw new Error("unique() query returned more than one result");
|
||||
return matches[0] ?? null;
|
||||
}
|
||||
if (table === "skillVersions") {
|
||||
return (
|
||||
Array.from(docs.values()).find(
|
||||
@@ -1563,6 +1615,59 @@ describe("securityScan", () => {
|
||||
expect(patches).toEqual([]);
|
||||
});
|
||||
|
||||
it("submits a scan for the active target when a merged source shares its bare slug", async () => {
|
||||
const { ctx, inserts } = makeRescanCtx({
|
||||
actorId: "users:owner",
|
||||
docs: {
|
||||
"skills:merged-source": {
|
||||
_id: "skills:merged-source",
|
||||
slug: "clawtopics-link",
|
||||
ownerUserId: "users:source-owner",
|
||||
softDeletedAt: 1_700_000_000_000,
|
||||
moderationStatus: "hidden",
|
||||
moderationReason: "owner.merged",
|
||||
canonicalSkillId: "skills:target",
|
||||
},
|
||||
"skills:target": {
|
||||
_id: "skills:target",
|
||||
slug: "clawtopics-link",
|
||||
displayName: "Clawtopics Link",
|
||||
ownerUserId: "users:owner",
|
||||
latestVersionId: "skillVersions:target",
|
||||
moderationStatus: "active",
|
||||
},
|
||||
"skillVersions:target": {
|
||||
_id: "skillVersions:target",
|
||||
skillId: "skills:target",
|
||||
version: "1.0.2",
|
||||
files: [],
|
||||
createdAt: 1_700_000_100_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await createPublishedSkillScanRequestInternalHandler(ctx, {
|
||||
actorUserId: "users:owner",
|
||||
slug: "clawtopics-link",
|
||||
version: "1.0.2",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
status: "queued",
|
||||
sourceKind: "published",
|
||||
});
|
||||
expect(inserts).toContainEqual({
|
||||
table: "skillScanRequests",
|
||||
doc: expect.objectContaining({
|
||||
skillId: "skills:target",
|
||||
skillVersionId: "skillVersions:target",
|
||||
slug: "clawtopics-link",
|
||||
version: "1.0.2",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps an unscanned skill publish at publish priority when VirusTotal finishes", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1_000_000);
|
||||
@@ -2042,6 +2147,119 @@ describe("securityScan", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves a stored report from the active target when a merged source shares its slug", async () => {
|
||||
const ctx = makeStoredScanReportCtx({
|
||||
actor: { _id: "users:owner", role: "user" },
|
||||
docs: {
|
||||
"skills:merged-source": {
|
||||
_id: "skills:merged-source",
|
||||
slug: "clawtopics-link",
|
||||
displayName: "Merged Source",
|
||||
ownerUserId: "users:source-owner",
|
||||
softDeletedAt: 1_700_000_000_000,
|
||||
moderationStatus: "hidden",
|
||||
moderationReason: "owner.merged",
|
||||
canonicalSkillId: "skills:target",
|
||||
},
|
||||
"skills:target": {
|
||||
_id: "skills:target",
|
||||
slug: "clawtopics-link",
|
||||
displayName: "Clawtopics Link",
|
||||
ownerUserId: "users:owner",
|
||||
moderationStatus: "active",
|
||||
},
|
||||
"skillVersions:target": {
|
||||
_id: "skillVersions:target",
|
||||
skillId: "skills:target",
|
||||
version: "1.0.2",
|
||||
files: [],
|
||||
llmAnalysis: { status: "clean", checkedAt: 1_700_000_100_000 },
|
||||
createdAt: 1_700_000_100_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const report = await getStoredScanReportForUserInternalHandler(ctx, {
|
||||
actorUserId: "users:owner",
|
||||
kind: "skill",
|
||||
name: "clawtopics-link",
|
||||
version: "1.0.2",
|
||||
});
|
||||
|
||||
expect(report).toMatchObject({
|
||||
ok: true,
|
||||
artifact: {
|
||||
slug: "clawtopics-link",
|
||||
displayName: "Clawtopics Link",
|
||||
version: "1.0.2",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("downloads the requested owner's report when active publishers share a slug", async () => {
|
||||
const ctx = makeStoredScanReportCtx({
|
||||
actor: { _id: "users:member", role: "user" },
|
||||
membership: {
|
||||
_id: "publisherMembers:member",
|
||||
publisherId: "publishers:tekoai",
|
||||
userId: "users:member",
|
||||
role: "publisher",
|
||||
},
|
||||
docs: {
|
||||
"publishers:felix": {
|
||||
_id: "publishers:felix",
|
||||
kind: "user",
|
||||
handle: "felixzhou2005",
|
||||
},
|
||||
"publishers:tekoai": {
|
||||
_id: "publishers:tekoai",
|
||||
kind: "org",
|
||||
handle: "tekoai",
|
||||
},
|
||||
"skills:felix": {
|
||||
_id: "skills:felix",
|
||||
slug: "clawtopics-link",
|
||||
displayName: "Felix Clawtopics Link",
|
||||
ownerUserId: "users:felix",
|
||||
ownerPublisherId: "publishers:felix",
|
||||
moderationStatus: "active",
|
||||
},
|
||||
"skills:tekoai": {
|
||||
_id: "skills:tekoai",
|
||||
slug: "clawtopics-link",
|
||||
displayName: "Clawtopics Link",
|
||||
ownerUserId: "users:owner",
|
||||
ownerPublisherId: "publishers:tekoai",
|
||||
moderationStatus: "active",
|
||||
},
|
||||
"skillVersions:tekoai": {
|
||||
_id: "skillVersions:tekoai",
|
||||
skillId: "skills:tekoai",
|
||||
version: "1.0.2",
|
||||
files: [],
|
||||
llmAnalysis: { status: "clean", checkedAt: 1_700_000_100_000 },
|
||||
createdAt: 1_700_000_100_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const report = await getStoredScanReportForUserInternalHandler(ctx, {
|
||||
actorUserId: "users:member",
|
||||
kind: "skill",
|
||||
name: "clawtopics-link",
|
||||
ownerHandle: "tekoai",
|
||||
version: "1.0.2",
|
||||
});
|
||||
|
||||
expect(report).toMatchObject({
|
||||
artifact: {
|
||||
slug: "clawtopics-link",
|
||||
displayName: "Clawtopics Link",
|
||||
version: "1.0.2",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns stored scan reports for hidden org skill versions to publisher-role uploaders", async () => {
|
||||
const ctx = makeStoredScanReportCtx({
|
||||
actor: { _id: "users:member", role: "user" },
|
||||
|
||||
+11
-12
@@ -139,7 +139,11 @@ type SkillSpectorAnalysisForStorage = {
|
||||
checkedAt: number;
|
||||
};
|
||||
|
||||
async function resolveSkillForRescan(ctx: MutationCtx, slug: string, ownerHandle?: string) {
|
||||
async function resolveSkillForRescan(
|
||||
ctx: Pick<QueryCtx | MutationCtx, "db">,
|
||||
slug: string,
|
||||
ownerHandle?: string,
|
||||
) {
|
||||
const normalizedSlug = slug.trim().toLowerCase();
|
||||
if (!normalizedSlug) throw new ConvexError("Slug required");
|
||||
|
||||
@@ -1745,6 +1749,7 @@ export const createPublishedSkillScanRequestInternal = internalMutation({
|
||||
args: {
|
||||
actorUserId: v.id("users"),
|
||||
slug: v.string(),
|
||||
ownerHandle: v.optional(v.string()),
|
||||
version: v.optional(v.string()),
|
||||
update: v.optional(v.boolean()),
|
||||
},
|
||||
@@ -1752,12 +1757,7 @@ export const createPublishedSkillScanRequestInternal = internalMutation({
|
||||
const actor = await ctx.db.get(args.actorUserId);
|
||||
if (!actor) throw new ConvexError("Unauthorized");
|
||||
|
||||
const slug = args.slug.trim().toLowerCase();
|
||||
if (!slug) throw new ConvexError("Slug required");
|
||||
const skill = await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_slug", (q) => q.eq("slug", slug))
|
||||
.unique();
|
||||
const skill = await resolveSkillForRescan(ctx, args.slug, args.ownerHandle);
|
||||
if (!skill || skill.softDeletedAt) throw new ConvexError("Skill not found");
|
||||
|
||||
await assertCanManageOwnedResource(ctx, {
|
||||
@@ -1866,6 +1866,7 @@ export const getStoredScanReportForUserInternal = internalQuery({
|
||||
actorUserId: v.id("users"),
|
||||
kind: v.union(v.literal("skill"), v.literal("plugin")),
|
||||
name: v.string(),
|
||||
ownerHandle: v.optional(v.string()),
|
||||
version: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
@@ -1888,6 +1889,7 @@ export const getStoredScanReportForUserInternal = internalQuery({
|
||||
actor,
|
||||
kind: args.kind,
|
||||
name,
|
||||
ownerHandle: args.ownerHandle,
|
||||
version: versionLabel,
|
||||
});
|
||||
},
|
||||
@@ -1899,14 +1901,11 @@ async function getStoredSkillScanReportForUser(
|
||||
actor: Doc<"users">;
|
||||
kind: StoredScanArtifactKind;
|
||||
name: string;
|
||||
ownerHandle?: string;
|
||||
version: string;
|
||||
},
|
||||
) {
|
||||
const slug = args.name.toLowerCase();
|
||||
const skill = await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_slug", (q) => q.eq("slug", slug))
|
||||
.unique();
|
||||
const skill = await resolveSkillForRescan(ctx, args.name, args.ownerHandle);
|
||||
if (!skill) throw new ConvexError("Skill not found");
|
||||
|
||||
await assertCanManageOwnedResource(ctx, {
|
||||
|
||||
@@ -138,6 +138,39 @@ describe("cmdScan", () => {
|
||||
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining("Written back: yes"));
|
||||
});
|
||||
|
||||
it("submits an owner-qualified published skill without folding the owner into the slug", async () => {
|
||||
httpMocks.apiRequest
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
scanId: "scan_123",
|
||||
jobId: "job_123",
|
||||
status: "queued",
|
||||
sourceKind: "published",
|
||||
update: false,
|
||||
})
|
||||
.mockResolvedValueOnce(completedScan());
|
||||
|
||||
await cmdScan(makeGlobalOpts(), undefined, {
|
||||
slug: "@tekoai/clawtopics-link",
|
||||
version: "1.0.2",
|
||||
});
|
||||
|
||||
expect(httpMocks.apiRequest.mock.calls[0]?.[1]).toMatchObject({
|
||||
method: "POST",
|
||||
path: ApiRoutes.skillScans,
|
||||
token: "tkn",
|
||||
body: {
|
||||
source: {
|
||||
kind: "published",
|
||||
slug: "clawtopics-link",
|
||||
ownerHandle: "tekoai",
|
||||
version: "1.0.2",
|
||||
},
|
||||
update: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("downloads the canonical report zip when --output is set", async () => {
|
||||
const workdir = await makeTmpWorkdir();
|
||||
try {
|
||||
@@ -201,6 +234,25 @@ describe("cmdScanDownload", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("downloads an owner-qualified stored skill report from the canonical slug", async () => {
|
||||
const workdir = await makeTmpWorkdir();
|
||||
try {
|
||||
httpMocks.fetchBinary.mockResolvedValueOnce(new Uint8Array([80, 75, 3, 4]));
|
||||
|
||||
await cmdScanDownload(makeGlobalOpts(workdir), "@tekoai/clawtopics-link", {
|
||||
version: "1.0.2",
|
||||
output: "scan.zip",
|
||||
});
|
||||
|
||||
expect(httpMocks.fetchBinary).toHaveBeenCalledWith("https://clawhub.ai", {
|
||||
path: `${ApiRoutes.skillScans}/download/clawtopics-link?version=1.0.2&kind=skill&ownerHandle=tekoai`,
|
||||
token: "tkn",
|
||||
});
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("requires a version because rejected versions must be addressed explicitly", async () => {
|
||||
await expect(cmdScanDownload(makeGlobalOpts(), "demo-skill", {})).rejects.toThrow(
|
||||
"--version required",
|
||||
|
||||
@@ -31,6 +31,18 @@ type ScanDownloadOptions = {
|
||||
|
||||
type ReportRecord = Record<string, unknown>;
|
||||
|
||||
function parsePublishedSkillRef(raw: string) {
|
||||
const value = raw.trim();
|
||||
if (!value) fail("Skill required");
|
||||
const slashIndex = value.indexOf("/");
|
||||
if (slashIndex < 0) return { slug: value };
|
||||
if (value.indexOf("/", slashIndex + 1) >= 0) fail(`Invalid skill: ${value}`);
|
||||
const ownerHandle = value.slice(0, slashIndex).trim().replace(/^@+/, "");
|
||||
const slug = value.slice(slashIndex + 1).trim();
|
||||
if (!ownerHandle || !slug) fail(`Invalid skill: ${value}`);
|
||||
return { slug, ownerHandle };
|
||||
}
|
||||
|
||||
export async function cmdScan(opts: GlobalOpts, pathArg: string | undefined, options: ScanOptions) {
|
||||
validateScanOptions(pathArg, options);
|
||||
if (pathArg?.trim()) rejectLocalScan();
|
||||
@@ -83,6 +95,7 @@ export async function cmdScanDownload(
|
||||
if (!version) fail("--version required");
|
||||
const kind = options.kind ?? "skill";
|
||||
if (kind !== "skill" && kind !== "plugin") fail('--kind must be "skill" or "plugin"');
|
||||
const requested = kind === "skill" ? parsePublishedSkillRef(name) : { slug: name };
|
||||
|
||||
const token = await requireAuthToken();
|
||||
const registry = await getRegistry(opts, { cache: true });
|
||||
@@ -91,8 +104,9 @@ export async function cmdScanDownload(
|
||||
options.output ?? `clawhub-scan-${safeOutputName(name)}-${safeOutputName(version)}.zip`,
|
||||
);
|
||||
const query = new URLSearchParams({ version, kind });
|
||||
if (requested.ownerHandle) query.set("ownerHandle", requested.ownerHandle);
|
||||
const bytes = await fetchBinary(registry, {
|
||||
path: `${ApiRoutes.skillScans}/download/${encodeURIComponent(name)}?${query.toString()}`,
|
||||
path: `${ApiRoutes.skillScans}/download/${encodeURIComponent(requested.slug)}?${query.toString()}`,
|
||||
token,
|
||||
});
|
||||
await mkdir(dirname(output), { recursive: true });
|
||||
@@ -115,8 +129,9 @@ function rejectLocalScan(): never {
|
||||
}
|
||||
|
||||
async function submitPublishedScan(registry: string, token: string, options: ScanOptions) {
|
||||
const slug = options.slug?.trim();
|
||||
if (!slug) fail("--slug required");
|
||||
const skillRef = options.slug?.trim();
|
||||
if (!skillRef) fail("--slug required");
|
||||
const requested = parsePublishedSkillRef(skillRef);
|
||||
const version = options.version?.trim();
|
||||
return await apiRequest(
|
||||
registry,
|
||||
@@ -127,7 +142,8 @@ async function submitPublishedScan(registry: string, token: string, options: Sca
|
||||
body: {
|
||||
source: {
|
||||
kind: "published",
|
||||
slug,
|
||||
slug: requested.slug,
|
||||
...(requested.ownerHandle ? { ownerHandle: requested.ownerHandle } : {}),
|
||||
...(version ? { version } : {}),
|
||||
},
|
||||
update: options.update === true,
|
||||
|
||||
@@ -775,6 +775,7 @@ export const ApiV1SkillScanSourceSchema = type({
|
||||
}).or({
|
||||
kind: '"published"',
|
||||
slug: "string",
|
||||
ownerHandle: "string?",
|
||||
version: "string?",
|
||||
});
|
||||
export type ApiV1SkillScanSource = (typeof ApiV1SkillScanSourceSchema)[inferred];
|
||||
|
||||
Vendored
+2
@@ -575,6 +575,7 @@ export declare const ApiV1SkillScanSourceSchema: import("arktype/internal/varian
|
||||
} | {
|
||||
kind: "published";
|
||||
slug: string;
|
||||
ownerHandle?: string | undefined;
|
||||
version?: string | undefined;
|
||||
}, {}>;
|
||||
export type ApiV1SkillScanSource = (typeof ApiV1SkillScanSourceSchema)[inferred];
|
||||
@@ -584,6 +585,7 @@ export declare const ApiV1SkillScanSubmitRequestSchema: import("arktype/internal
|
||||
} | {
|
||||
kind: "published";
|
||||
slug: string;
|
||||
ownerHandle?: string | undefined;
|
||||
version?: string | undefined;
|
||||
};
|
||||
update?: boolean | undefined;
|
||||
|
||||
Vendored
+1
@@ -538,6 +538,7 @@ export const ApiV1SkillScanSourceSchema = type({
|
||||
}).or({
|
||||
kind: '"published"',
|
||||
slug: "string",
|
||||
ownerHandle: "string?",
|
||||
version: "string?",
|
||||
});
|
||||
export const ApiV1SkillScanSubmitRequestSchema = type({
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -626,6 +626,7 @@ export const ApiV1SkillScanSourceSchema = type({
|
||||
}).or({
|
||||
kind: '"published"',
|
||||
slug: "string",
|
||||
ownerHandle: "string?",
|
||||
version: "string?",
|
||||
});
|
||||
export type ApiV1SkillScanSource = (typeof ApiV1SkillScanSourceSchema)[inferred];
|
||||
|
||||
Reference in New Issue
Block a user