mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
feat: seed local rescan fixtures
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { seedRescanUxFixturesHandler } from "./devSeed";
|
||||
import { MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE } from "./model/rescans/policy";
|
||||
|
||||
function chainEq(constraints: Record<string, unknown>) {
|
||||
return {
|
||||
eq(field: string, value: unknown) {
|
||||
constraints[field] = value;
|
||||
return chainEq(constraints);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function matches(doc: Record<string, unknown>, constraints: Record<string, unknown>) {
|
||||
return Object.entries(constraints).every(([key, value]) => doc[key] === value);
|
||||
}
|
||||
|
||||
function createDb() {
|
||||
const tables: Record<string, Array<Record<string, unknown> & { _id: string }>> = {};
|
||||
const counters: Record<string, number> = {};
|
||||
|
||||
const list = (table: string) => {
|
||||
tables[table] ??= [];
|
||||
return tables[table];
|
||||
};
|
||||
|
||||
const db = {
|
||||
get: async (id: string) => {
|
||||
const table = id.split(":")[0] ?? "";
|
||||
return list(table).find((doc) => doc._id === id) ?? null;
|
||||
},
|
||||
insert: async (table: string, doc: Record<string, unknown>) => {
|
||||
counters[table] = (counters[table] ?? 0) + 1;
|
||||
const inserted = {
|
||||
_id: `${table}:${counters[table]}`,
|
||||
_creationTime: counters[table],
|
||||
...doc,
|
||||
};
|
||||
list(table).push(inserted);
|
||||
return inserted._id;
|
||||
},
|
||||
patch: async (id: string, patch: Record<string, unknown>) => {
|
||||
const table = id.split(":")[0] ?? "";
|
||||
const doc = list(table).find((candidate) => candidate._id === id);
|
||||
if (doc) Object.assign(doc, patch);
|
||||
},
|
||||
delete: async (id: string) => {
|
||||
const table = id.split(":")[0] ?? "";
|
||||
const rows = list(table);
|
||||
const index = rows.findIndex((doc) => doc._id === id);
|
||||
if (index !== -1) rows.splice(index, 1);
|
||||
},
|
||||
query: (table: string) => ({
|
||||
withIndex: (_name: string, build: (q: ReturnType<typeof chainEq>) => unknown) => {
|
||||
const constraints: Record<string, unknown> = {};
|
||||
build(chainEq(constraints));
|
||||
const matched = () =>
|
||||
list(table).filter((doc) => matches(doc as Record<string, unknown>, constraints));
|
||||
return {
|
||||
collect: async () => matched(),
|
||||
unique: async () => matched()[0] ?? null,
|
||||
order: () => ({
|
||||
collect: async () => matched(),
|
||||
}),
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
return { db, tables };
|
||||
}
|
||||
|
||||
describe("devSeed rescan UX fixtures", () => {
|
||||
it("seeds flagged local owner inventory and deterministic rescan counts idempotently", async () => {
|
||||
const { db, tables } = createDb();
|
||||
const args = {
|
||||
flaggedSkillStorageId: "storage:skill",
|
||||
flaggedSkillMd: "# Flagged skill",
|
||||
flaggedPluginStorageId: "storage:plugin",
|
||||
flaggedPluginReadme: "# Flagged plugin",
|
||||
};
|
||||
|
||||
await seedRescanUxFixturesHandler({ db } as never, args as never);
|
||||
await seedRescanUxFixturesHandler({ db } as never, args as never);
|
||||
await seedRescanUxFixturesHandler({ db } as never, { ...args, reset: true } as never);
|
||||
|
||||
expect(tables.users).toHaveLength(1);
|
||||
expect(tables.users?.[0]).toEqual(expect.objectContaining({ handle: "local" }));
|
||||
expect(tables.publishers).toHaveLength(1);
|
||||
expect(tables.skills).toHaveLength(1);
|
||||
expect(tables.skills?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
ownerUserId: tables.users?.[0]?._id,
|
||||
ownerPublisherId: tables.publishers?.[0]?._id,
|
||||
moderationStatus: "hidden",
|
||||
moderationVerdict: "malicious",
|
||||
}),
|
||||
);
|
||||
expect(tables.packages).toHaveLength(1);
|
||||
expect(tables.packages?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
ownerUserId: tables.users?.[0]?._id,
|
||||
ownerPublisherId: tables.publishers?.[0]?._id,
|
||||
scanStatus: "malicious",
|
||||
}),
|
||||
);
|
||||
|
||||
const skillRequests =
|
||||
tables.rescanRequests?.filter((request) => request.targetKind === "skill") ?? [];
|
||||
const pluginRequests =
|
||||
tables.rescanRequests?.filter((request) => request.targetKind === "plugin") ?? [];
|
||||
expect(skillRequests).toHaveLength(1);
|
||||
expect(pluginRequests).toHaveLength(MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE);
|
||||
});
|
||||
});
|
||||
+503
-21
@@ -1,9 +1,13 @@
|
||||
import { v } from "convex/values";
|
||||
import { internal } from "./_generated/api";
|
||||
import type { ActionCtx } from "./_generated/server";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import type { ActionCtx, MutationCtx } from "./_generated/server";
|
||||
import { internalAction, internalMutation } from "./functions";
|
||||
import { EMBEDDING_DIMENSIONS } from "./lib/embeddings";
|
||||
import { normalizePackageName } from "./lib/packageRegistry";
|
||||
import { ensurePersonalPublisherForUser } from "./lib/publishers";
|
||||
import { parseClawdisMetadata, parseFrontmatter } from "./lib/skills";
|
||||
import { MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE } from "./model/rescans/policy";
|
||||
|
||||
type SeedSkillSpec = {
|
||||
slug: string;
|
||||
@@ -25,6 +29,25 @@ type SeedActionResult = {
|
||||
|
||||
type SeedMutationResult = Record<string, unknown>;
|
||||
|
||||
const LOCAL_SEED_HANDLE = "local";
|
||||
const FLAGGED_SKILL_SLUG = "local-flagged-wallet-sync";
|
||||
const FLAGGED_PLUGIN_NAME = "local-flagged-runtime-plugin";
|
||||
const FLAGGED_SKILL_MD = `---
|
||||
name: local-flagged-wallet-sync
|
||||
description: Local dev fixture for flagged dashboard and rescan UI.
|
||||
---
|
||||
|
||||
# Local Flagged Wallet Sync
|
||||
|
||||
This seeded skill is intentionally flagged so local development can exercise owner-only recovery
|
||||
flows, dashboard unavailable states, and rescan request limits.
|
||||
`;
|
||||
const FLAGGED_PLUGIN_README = `# Local Flagged Runtime Plugin
|
||||
|
||||
This seeded plugin is intentionally flagged so local development can exercise plugin owner
|
||||
inventory and cap-exhausted rescan UI.
|
||||
`;
|
||||
|
||||
const SEED_SKILLS: SeedSkillSpec[] = [
|
||||
{
|
||||
slug: "padel",
|
||||
@@ -345,6 +368,22 @@ async function seedNixSkillsHandler(
|
||||
results.push({ slug: spec.slug, ...result });
|
||||
}
|
||||
|
||||
const [flaggedSkillStorageId, flaggedPluginStorageId] = await Promise.all([
|
||||
ctx.storage.store(new Blob([FLAGGED_SKILL_MD], { type: "text/markdown" })),
|
||||
ctx.storage.store(new Blob([FLAGGED_PLUGIN_README], { type: "text/markdown" })),
|
||||
]);
|
||||
const fixtureResult: SeedMutationResult = await ctx.runMutation(
|
||||
internal.devSeed.seedRescanUxFixturesMutation,
|
||||
{
|
||||
reset: args.reset,
|
||||
flaggedSkillStorageId,
|
||||
flaggedSkillMd: FLAGGED_SKILL_MD,
|
||||
flaggedPluginStorageId,
|
||||
flaggedPluginReadme: FLAGGED_PLUGIN_README,
|
||||
},
|
||||
);
|
||||
results.push({ slug: FLAGGED_SKILL_SLUG, ...fixtureResult });
|
||||
|
||||
return { ok: true, results };
|
||||
}
|
||||
|
||||
@@ -388,6 +427,467 @@ export const seedPadelSkill: ReturnType<typeof internalAction> = internalAction(
|
||||
handler: seedPadelSkillHandler,
|
||||
});
|
||||
|
||||
async function ensureLocalSeedOwner(ctx: MutationCtx) {
|
||||
const now = Date.now();
|
||||
const existingUsers = await ctx.db
|
||||
.query("users")
|
||||
.withIndex("handle", (q) => q.eq("handle", LOCAL_SEED_HANDLE))
|
||||
.collect();
|
||||
|
||||
const userId =
|
||||
existingUsers[0]?._id ??
|
||||
(await ctx.db.insert("users", {
|
||||
handle: LOCAL_SEED_HANDLE,
|
||||
displayName: "Local Dev",
|
||||
role: "admin",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}));
|
||||
const user = await ctx.db.get(userId);
|
||||
if (!user) throw new Error("Local seed user was not created");
|
||||
const publisher = await ensurePersonalPublisherForUser(ctx, user);
|
||||
if (!publisher) throw new Error("Local seed publisher was not created");
|
||||
return { userId, publisherId: publisher._id };
|
||||
}
|
||||
|
||||
async function deleteRescanRequestsForSkillVersion(ctx: MutationCtx, versionId: unknown) {
|
||||
if (!versionId) return;
|
||||
const requests = await ctx.db
|
||||
.query("rescanRequests")
|
||||
.withIndex("by_skill_version", (q) =>
|
||||
q.eq("targetKind", "skill").eq("skillVersionId", versionId as never),
|
||||
)
|
||||
.collect();
|
||||
for (const request of requests) await ctx.db.delete(request._id);
|
||||
}
|
||||
|
||||
async function deleteRescanRequestsForPackageRelease(ctx: MutationCtx, releaseId: unknown) {
|
||||
if (!releaseId) return;
|
||||
const requests = await ctx.db
|
||||
.query("rescanRequests")
|
||||
.withIndex("by_package_release", (q) =>
|
||||
q.eq("targetKind", "plugin").eq("packageReleaseId", releaseId as never),
|
||||
)
|
||||
.collect();
|
||||
for (const request of requests) await ctx.db.delete(request._id);
|
||||
}
|
||||
|
||||
async function deleteSeedSkillFixture(ctx: MutationCtx) {
|
||||
const existing = await findSeedSkillFixture(ctx);
|
||||
if (!existing) return;
|
||||
|
||||
const versions = await ctx.db
|
||||
.query("skillVersions")
|
||||
.withIndex("by_skill", (q) => q.eq("skillId", existing._id))
|
||||
.collect();
|
||||
for (const version of versions) {
|
||||
await deleteRescanRequestsForSkillVersion(ctx, version._id);
|
||||
await ctx.db.delete(version._id);
|
||||
}
|
||||
const embeddings = await ctx.db
|
||||
.query("skillEmbeddings")
|
||||
.withIndex("by_skill", (q) => q.eq("skillId", existing._id))
|
||||
.collect();
|
||||
for (const embedding of embeddings) {
|
||||
const maps = await ctx.db
|
||||
.query("embeddingSkillMap")
|
||||
.withIndex("by_embedding", (q) => q.eq("embeddingId", embedding._id))
|
||||
.collect();
|
||||
for (const map of maps) await ctx.db.delete(map._id);
|
||||
await ctx.db.delete(embedding._id);
|
||||
}
|
||||
await ctx.db.delete(existing._id);
|
||||
}
|
||||
|
||||
async function findSeedSkillFixture(ctx: MutationCtx) {
|
||||
return await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_slug", (q) => q.eq("slug", FLAGGED_SKILL_SLUG))
|
||||
.unique();
|
||||
}
|
||||
|
||||
async function deleteSeedPluginFixture(ctx: MutationCtx) {
|
||||
const existing = await findSeedPluginFixture(ctx);
|
||||
if (!existing) return;
|
||||
|
||||
const releases = await ctx.db
|
||||
.query("packageReleases")
|
||||
.withIndex("by_package", (q) => q.eq("packageId", existing._id))
|
||||
.collect();
|
||||
for (const release of releases) {
|
||||
await deleteRescanRequestsForPackageRelease(ctx, release._id);
|
||||
await ctx.db.delete(release._id);
|
||||
}
|
||||
await ctx.db.delete(existing._id);
|
||||
}
|
||||
|
||||
async function findSeedPluginFixture(ctx: MutationCtx) {
|
||||
return await ctx.db
|
||||
.query("packages")
|
||||
.withIndex("by_name", (q) => q.eq("normalizedName", normalizePackageName(FLAGGED_PLUGIN_NAME)))
|
||||
.unique();
|
||||
}
|
||||
|
||||
function staticMaliciousScan(now: number) {
|
||||
return {
|
||||
status: "malicious" as const,
|
||||
reasonCodes: ["malicious.local_dev_fixture"],
|
||||
findings: [
|
||||
{
|
||||
code: "malicious.local_dev_fixture",
|
||||
severity: "critical" as const,
|
||||
file: "SKILL.md",
|
||||
line: 1,
|
||||
message: "Local dev fixture intentionally flagged for owner recovery testing.",
|
||||
evidence: "seeded fixture",
|
||||
},
|
||||
],
|
||||
summary: "Local dev fixture intentionally flagged as malicious.",
|
||||
engineVersion: "local-dev-fixture",
|
||||
checkedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
async function insertCompletedRescanRequests(
|
||||
ctx: MutationCtx,
|
||||
params:
|
||||
| {
|
||||
targetKind: "skill";
|
||||
skillId: unknown;
|
||||
skillVersionId: unknown;
|
||||
packageId?: never;
|
||||
packageReleaseId?: never;
|
||||
targetVersion: string;
|
||||
ownerUserId: unknown;
|
||||
ownerPublisherId: unknown;
|
||||
count: number;
|
||||
now: number;
|
||||
}
|
||||
| {
|
||||
targetKind: "plugin";
|
||||
packageId: unknown;
|
||||
packageReleaseId: unknown;
|
||||
skillId?: never;
|
||||
skillVersionId?: never;
|
||||
targetVersion: string;
|
||||
ownerUserId: unknown;
|
||||
ownerPublisherId: unknown;
|
||||
count: number;
|
||||
now: number;
|
||||
},
|
||||
) {
|
||||
for (let index = 0; index < params.count; index += 1) {
|
||||
const createdAt = params.now - (params.count - index) * 60_000;
|
||||
await ctx.db.insert("rescanRequests", {
|
||||
targetKind: params.targetKind,
|
||||
skillId: params.skillId as never,
|
||||
skillVersionId: params.skillVersionId as never,
|
||||
packageId: params.packageId as never,
|
||||
packageReleaseId: params.packageReleaseId as never,
|
||||
targetVersion: params.targetVersion,
|
||||
requestedByUserId: params.ownerUserId as never,
|
||||
ownerUserId: params.ownerUserId as never,
|
||||
ownerPublisherId: params.ownerPublisherId as never,
|
||||
status: "completed",
|
||||
createdAt,
|
||||
updatedAt: createdAt + 30_000,
|
||||
completedAt: createdAt + 30_000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
type SeedRescanUxFixturesArgs = {
|
||||
reset?: boolean;
|
||||
flaggedSkillStorageId: Id<"_storage">;
|
||||
flaggedSkillMd: string;
|
||||
flaggedPluginStorageId: Id<"_storage">;
|
||||
flaggedPluginReadme: string;
|
||||
};
|
||||
|
||||
export async function seedRescanUxFixturesHandler(
|
||||
ctx: MutationCtx,
|
||||
args: SeedRescanUxFixturesArgs,
|
||||
) {
|
||||
const existingSkill = await findSeedSkillFixture(ctx);
|
||||
const existingPlugin = await findSeedPluginFixture(ctx);
|
||||
if (existingSkill && existingPlugin && !args.reset) {
|
||||
return {
|
||||
ok: true,
|
||||
skipped: true,
|
||||
ownerUserId: existingSkill.ownerUserId,
|
||||
ownerPublisherId: existingSkill.ownerPublisherId ?? existingPlugin.ownerPublisherId,
|
||||
flaggedSkillId: existingSkill._id,
|
||||
flaggedSkillVersionId: existingSkill.latestVersionId,
|
||||
flaggedPluginId: existingPlugin._id,
|
||||
flaggedPluginReleaseId: existingPlugin.latestReleaseId,
|
||||
};
|
||||
}
|
||||
|
||||
await deleteSeedSkillFixture(ctx);
|
||||
await deleteSeedPluginFixture(ctx);
|
||||
|
||||
const now = Date.now();
|
||||
const { userId, publisherId } = await ensureLocalSeedOwner(ctx);
|
||||
const staticScan = staticMaliciousScan(now);
|
||||
|
||||
const skillId = await ctx.db.insert("skills", {
|
||||
slug: FLAGGED_SKILL_SLUG,
|
||||
displayName: "Local Flagged Wallet Sync",
|
||||
summary: "Seeded flagged skill for local owner inventory and rescan UI testing.",
|
||||
ownerUserId: userId,
|
||||
ownerPublisherId: publisherId,
|
||||
latestVersionId: undefined,
|
||||
tags: {},
|
||||
softDeletedAt: undefined,
|
||||
badges: { redactionApproved: undefined },
|
||||
moderationStatus: "hidden",
|
||||
moderationReason: "scanner.static.malicious",
|
||||
moderationVerdict: "malicious",
|
||||
moderationReasonCodes: ["malicious.local_dev_fixture"],
|
||||
moderationEvidence: staticScan.findings,
|
||||
moderationSummary: staticScan.summary,
|
||||
moderationEngineVersion: staticScan.engineVersion,
|
||||
moderationEvaluatedAt: now,
|
||||
moderationFlags: ["blocked.malware"],
|
||||
isSuspicious: true,
|
||||
statsDownloads: 4,
|
||||
statsStars: 1,
|
||||
statsInstallsCurrent: 0,
|
||||
statsInstallsAllTime: 2,
|
||||
stats: {
|
||||
downloads: 4,
|
||||
installsCurrent: 0,
|
||||
installsAllTime: 2,
|
||||
stars: 1,
|
||||
versions: 0,
|
||||
comments: 0,
|
||||
},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
const skillVersionId = await ctx.db.insert("skillVersions", {
|
||||
skillId,
|
||||
version: "0.1.0",
|
||||
changelog: "Seeded flagged local version for rescan UI testing.",
|
||||
files: [
|
||||
{
|
||||
path: "SKILL.md",
|
||||
size: args.flaggedSkillMd.length,
|
||||
storageId: args.flaggedSkillStorageId,
|
||||
sha256: "seeded-flagged-skill",
|
||||
contentType: "text/markdown",
|
||||
},
|
||||
],
|
||||
parsed: {
|
||||
frontmatter: {
|
||||
name: FLAGGED_SKILL_SLUG,
|
||||
description: "Local dev fixture for flagged dashboard and rescan UI.",
|
||||
},
|
||||
},
|
||||
createdBy: userId,
|
||||
createdAt: now,
|
||||
softDeletedAt: undefined,
|
||||
sha256hash: "seeded-flagged-skill-hash",
|
||||
vtAnalysis: {
|
||||
status: "malicious",
|
||||
verdict: "malicious",
|
||||
analysis: "Local dev fixture intentionally flagged by VirusTotal.",
|
||||
source: "local-dev-seed",
|
||||
checkedAt: now,
|
||||
},
|
||||
llmAnalysis: {
|
||||
status: "suspicious",
|
||||
verdict: "suspicious",
|
||||
confidence: "high",
|
||||
summary: "Local dev fixture intentionally flagged by OpenClaw.",
|
||||
model: "local-dev-seed",
|
||||
checkedAt: now,
|
||||
},
|
||||
staticScan,
|
||||
});
|
||||
await ctx.db.patch(skillId, {
|
||||
latestVersionId: skillVersionId,
|
||||
moderationSourceVersionId: skillVersionId,
|
||||
tags: { latest: skillVersionId },
|
||||
stats: {
|
||||
downloads: 4,
|
||||
installsCurrent: 0,
|
||||
installsAllTime: 2,
|
||||
stars: 1,
|
||||
versions: 1,
|
||||
comments: 0,
|
||||
},
|
||||
updatedAt: now,
|
||||
});
|
||||
await insertCompletedRescanRequests(ctx, {
|
||||
targetKind: "skill",
|
||||
skillId,
|
||||
skillVersionId,
|
||||
targetVersion: "0.1.0",
|
||||
ownerUserId: userId,
|
||||
ownerPublisherId: publisherId,
|
||||
count: 1,
|
||||
now,
|
||||
});
|
||||
|
||||
const packageId = await ctx.db.insert("packages", {
|
||||
name: FLAGGED_PLUGIN_NAME,
|
||||
normalizedName: normalizePackageName(FLAGGED_PLUGIN_NAME),
|
||||
displayName: "Local Flagged Runtime Plugin",
|
||||
summary: "Seeded flagged plugin for local owner inventory and cap-exhausted rescan UI testing.",
|
||||
ownerUserId: userId,
|
||||
ownerPublisherId: publisherId,
|
||||
family: "code-plugin",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
runtimeId: "local.flagged.runtime",
|
||||
sourceRepo: "openclaw/local-dev-fixture",
|
||||
latestReleaseId: undefined,
|
||||
latestVersionSummary: undefined,
|
||||
tags: {},
|
||||
capabilityTags: ["dev-tools"],
|
||||
executesCode: true,
|
||||
compatibility: { pluginApiRange: ">=0.1.0" },
|
||||
capabilities: {
|
||||
executesCode: true,
|
||||
runtimeId: "local.flagged.runtime",
|
||||
pluginKind: "runtime",
|
||||
capabilityTags: ["dev-tools"],
|
||||
},
|
||||
verification: {
|
||||
tier: "structural",
|
||||
scope: "artifact-only",
|
||||
summary: "Local dev fixture intentionally flagged.",
|
||||
sourceRepo: "openclaw/local-dev-fixture",
|
||||
scanStatus: "malicious",
|
||||
},
|
||||
scanStatus: "malicious",
|
||||
stats: { downloads: 2, installs: 0, stars: 0, versions: 0 },
|
||||
softDeletedAt: undefined,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
const packageReleaseId = await ctx.db.insert("packageReleases", {
|
||||
packageId,
|
||||
version: "0.1.0",
|
||||
changelog: "Seeded flagged local release for cap-exhausted rescan UI testing.",
|
||||
summary: "Seeded flagged plugin release.",
|
||||
distTags: ["latest"],
|
||||
files: [
|
||||
{
|
||||
path: "README.md",
|
||||
size: args.flaggedPluginReadme.length,
|
||||
storageId: args.flaggedPluginStorageId,
|
||||
sha256: "seeded-flagged-plugin",
|
||||
contentType: "text/markdown",
|
||||
},
|
||||
],
|
||||
integritySha256: "seeded-flagged-plugin-integrity",
|
||||
extractedPackageJson: {
|
||||
name: FLAGGED_PLUGIN_NAME,
|
||||
version: "0.1.0",
|
||||
},
|
||||
compatibility: { pluginApiRange: ">=0.1.0" },
|
||||
capabilities: {
|
||||
executesCode: true,
|
||||
runtimeId: "local.flagged.runtime",
|
||||
pluginKind: "runtime",
|
||||
capabilityTags: ["dev-tools"],
|
||||
},
|
||||
verification: {
|
||||
tier: "structural",
|
||||
scope: "artifact-only",
|
||||
summary: "Local dev fixture intentionally flagged.",
|
||||
sourceRepo: "openclaw/local-dev-fixture",
|
||||
scanStatus: "malicious",
|
||||
},
|
||||
sha256hash: "seeded-flagged-plugin-hash",
|
||||
vtAnalysis: {
|
||||
status: "malicious",
|
||||
verdict: "malicious",
|
||||
analysis: "Local dev fixture intentionally flagged by VirusTotal.",
|
||||
source: "local-dev-seed",
|
||||
checkedAt: now,
|
||||
},
|
||||
llmAnalysis: {
|
||||
status: "suspicious",
|
||||
verdict: "suspicious",
|
||||
confidence: "high",
|
||||
summary: "Local dev fixture intentionally flagged by OpenClaw.",
|
||||
model: "local-dev-seed",
|
||||
checkedAt: now,
|
||||
},
|
||||
staticScan,
|
||||
source: { kind: "github", repo: "openclaw/local-dev-fixture", path: "." },
|
||||
createdBy: userId,
|
||||
publishActor: { kind: "user", userId },
|
||||
createdAt: now,
|
||||
softDeletedAt: undefined,
|
||||
});
|
||||
await ctx.db.patch(packageId, {
|
||||
latestReleaseId: packageReleaseId,
|
||||
latestVersionSummary: {
|
||||
version: "0.1.0",
|
||||
createdAt: now,
|
||||
changelog: "Seeded flagged local release for cap-exhausted rescan UI testing.",
|
||||
compatibility: { pluginApiRange: ">=0.1.0" },
|
||||
capabilities: {
|
||||
executesCode: true,
|
||||
runtimeId: "local.flagged.runtime",
|
||||
pluginKind: "runtime",
|
||||
capabilityTags: ["dev-tools"],
|
||||
},
|
||||
verification: {
|
||||
tier: "structural",
|
||||
scope: "artifact-only",
|
||||
summary: "Local dev fixture intentionally flagged.",
|
||||
sourceRepo: "openclaw/local-dev-fixture",
|
||||
scanStatus: "malicious",
|
||||
},
|
||||
},
|
||||
tags: { latest: packageReleaseId },
|
||||
stats: { downloads: 2, installs: 0, stars: 0, versions: 1 },
|
||||
updatedAt: now,
|
||||
});
|
||||
await insertCompletedRescanRequests(ctx, {
|
||||
targetKind: "plugin",
|
||||
packageId,
|
||||
packageReleaseId,
|
||||
targetVersion: "0.1.0",
|
||||
ownerUserId: userId,
|
||||
ownerPublisherId: publisherId,
|
||||
count: MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE,
|
||||
now,
|
||||
});
|
||||
await ctx.db.patch(userId, {
|
||||
publishedSkills: 5,
|
||||
totalStars: 1,
|
||||
totalDownloads: 4,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
ownerUserId: userId,
|
||||
ownerPublisherId: publisherId,
|
||||
flaggedSkillId: skillId,
|
||||
flaggedSkillVersionId: skillVersionId,
|
||||
flaggedPluginId: packageId,
|
||||
flaggedPluginReleaseId: packageReleaseId,
|
||||
};
|
||||
}
|
||||
|
||||
export const seedRescanUxFixturesMutation = internalMutation({
|
||||
args: {
|
||||
reset: v.optional(v.boolean()),
|
||||
flaggedSkillStorageId: v.id("_storage"),
|
||||
flaggedSkillMd: v.string(),
|
||||
flaggedPluginStorageId: v.id("_storage"),
|
||||
flaggedPluginReadme: v.string(),
|
||||
},
|
||||
handler: seedRescanUxFixturesHandler,
|
||||
});
|
||||
|
||||
export const seedSkillMutation = internalMutation({
|
||||
args: {
|
||||
reset: v.optional(v.boolean()),
|
||||
@@ -430,26 +930,14 @@ export const seedSkillMutation = internalMutation({
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const existingUsers = await ctx.db
|
||||
.query("users")
|
||||
.withIndex("handle", (q) => q.eq("handle", "local"))
|
||||
.collect();
|
||||
|
||||
const userId =
|
||||
existingUsers[0]?._id ??
|
||||
(await ctx.db.insert("users", {
|
||||
handle: "local",
|
||||
displayName: "Local Dev",
|
||||
role: "admin",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}));
|
||||
const { userId, publisherId } = await ensureLocalSeedOwner(ctx);
|
||||
|
||||
const skillId = await ctx.db.insert("skills", {
|
||||
slug: args.slug,
|
||||
displayName: args.displayName,
|
||||
summary: args.summary,
|
||||
ownerUserId: userId,
|
||||
ownerPublisherId: publisherId,
|
||||
latestVersionId: undefined,
|
||||
tags: {},
|
||||
softDeletedAt: undefined,
|
||||
@@ -469,12 +957,6 @@ export const seedSkillMutation = internalMutation({
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await ctx.db.patch(userId, {
|
||||
publishedSkills: 1,
|
||||
totalStars: 0,
|
||||
totalDownloads: 0,
|
||||
});
|
||||
|
||||
const versionId = await ctx.db.insert("skillVersions", {
|
||||
skillId,
|
||||
version: args.version,
|
||||
|
||||
+62
-6
@@ -5,17 +5,61 @@ import type { ActionCtx, MutationCtx, QueryCtx } from "../_generated/server";
|
||||
|
||||
export type Role = "admin" | "moderator" | "user";
|
||||
|
||||
const DEV_IMPERSONATE_LOCAL_HANDLE = "local";
|
||||
|
||||
function readEnv(name: string) {
|
||||
const value = process.env[name]?.trim();
|
||||
return value ? value : undefined;
|
||||
}
|
||||
|
||||
function isDevImpersonationAllowed() {
|
||||
const requestedHandle = readEnv("CLAW_HUB_DEV_IMPERSONATE_USER_HANDLE");
|
||||
if (requestedHandle !== DEV_IMPERSONATE_LOCAL_HANDLE) return false;
|
||||
|
||||
const deployment = readEnv("CONVEX_DEPLOYMENT") ?? "";
|
||||
if (deployment.startsWith("prod:") || deployment.includes("production")) return false;
|
||||
return (
|
||||
deployment.startsWith("anonymous:") ||
|
||||
deployment.startsWith("dev:") ||
|
||||
deployment.startsWith("local:") ||
|
||||
readEnv("CLAW_HUB_ENABLE_DEV_IMPERSONATION") === "1"
|
||||
);
|
||||
}
|
||||
|
||||
async function getDevImpersonatedUserId(
|
||||
ctx: Pick<MutationCtx | QueryCtx, "db">,
|
||||
): Promise<Id<"users"> | undefined> {
|
||||
if (!isDevImpersonationAllowed()) return undefined;
|
||||
const user = await ctx.db
|
||||
.query("users")
|
||||
.withIndex("handle", (q) => q.eq("handle", DEV_IMPERSONATE_LOCAL_HANDLE))
|
||||
.unique();
|
||||
if (!user || user.deletedAt || user.deactivatedAt) return undefined;
|
||||
return user._id;
|
||||
}
|
||||
|
||||
async function getDevImpersonatedUserIdFromAction(
|
||||
ctx: ActionCtx,
|
||||
): Promise<Id<"users"> | undefined> {
|
||||
if (!isDevImpersonationAllowed()) return undefined;
|
||||
const user = await ctx.runQuery(internal.users.getByHandleInternal, {
|
||||
handle: DEV_IMPERSONATE_LOCAL_HANDLE,
|
||||
});
|
||||
if (!user || user.deletedAt || user.deactivatedAt) return undefined;
|
||||
return user._id;
|
||||
}
|
||||
|
||||
export async function getOptionalActiveAuthUserId(
|
||||
ctx: MutationCtx | QueryCtx,
|
||||
): Promise<Id<"users"> | undefined> {
|
||||
try {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) return undefined;
|
||||
if (!userId) return await getDevImpersonatedUserId(ctx);
|
||||
const user = await ctx.db.get(userId);
|
||||
if (!user || user.deletedAt || user.deactivatedAt) return undefined;
|
||||
return userId;
|
||||
} catch {
|
||||
return undefined;
|
||||
return await getDevImpersonatedUserId(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,17 +68,23 @@ export async function getOptionalActiveAuthUserIdFromAction(
|
||||
): Promise<Id<"users"> | undefined> {
|
||||
try {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) return undefined;
|
||||
if (!userId) return await getDevImpersonatedUserIdFromAction(ctx);
|
||||
const user = await ctx.runQuery(internal.users.getByIdInternal, { userId });
|
||||
if (!user || user.deletedAt || user.deactivatedAt) return undefined;
|
||||
return userId;
|
||||
} catch {
|
||||
return undefined;
|
||||
return await getDevImpersonatedUserIdFromAction(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireUser(ctx: MutationCtx | QueryCtx) {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
let userId: Id<"users"> | null | undefined = null;
|
||||
try {
|
||||
userId = await getAuthUserId(ctx);
|
||||
} catch {
|
||||
userId = null;
|
||||
}
|
||||
userId ??= await getDevImpersonatedUserId(ctx);
|
||||
if (!userId) throw new Error("Unauthorized");
|
||||
let user: Doc<"users"> | null;
|
||||
try {
|
||||
@@ -49,7 +99,13 @@ export async function requireUser(ctx: MutationCtx | QueryCtx) {
|
||||
export async function requireUserFromAction(
|
||||
ctx: ActionCtx,
|
||||
): Promise<{ userId: Id<"users">; user: Doc<"users"> }> {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
let userId: Id<"users"> | null | undefined = null;
|
||||
try {
|
||||
userId = await getAuthUserId(ctx);
|
||||
} catch {
|
||||
userId = null;
|
||||
}
|
||||
userId ??= await getDevImpersonatedUserIdFromAction(ctx);
|
||||
if (!userId) throw new Error("Unauthorized");
|
||||
let user: Doc<"users"> | null;
|
||||
try {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||
import { ConvexError, v } from "convex/values";
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import type { MutationCtx } from "./_generated/server";
|
||||
import { internalMutation, internalQuery, mutation, query } from "./functions";
|
||||
import { assertAdmin, requireUser } from "./lib/access";
|
||||
import { assertAdmin, getOptionalActiveAuthUserId, requireUser } from "./lib/access";
|
||||
import { toPublicPublisher } from "./lib/public";
|
||||
import {
|
||||
ensurePersonalPublisherForUser,
|
||||
@@ -404,7 +403,7 @@ export const resolvePublishTargetForUserInternal = internalMutation({
|
||||
export const listMine = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
const userId = await getOptionalActiveAuthUserId(ctx);
|
||||
if (!userId) return [];
|
||||
const user = await ctx.db.get(userId);
|
||||
if (!user || user.deletedAt || user.deactivatedAt) return [];
|
||||
|
||||
@@ -153,7 +153,17 @@ describe("skills.list", () => {
|
||||
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn().mockResolvedValue(null),
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "users:owner") {
|
||||
return {
|
||||
_id: "users:owner",
|
||||
_creationTime: 1,
|
||||
handle: "owner",
|
||||
displayName: "Owner",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "skills") {
|
||||
return {
|
||||
|
||||
+24
-12
@@ -14,7 +14,14 @@ import {
|
||||
mutation,
|
||||
query,
|
||||
} from "./functions";
|
||||
import { assertAdmin, assertModerator, requireUser, requireUserFromAction } from "./lib/access";
|
||||
import {
|
||||
assertAdmin,
|
||||
assertModerator,
|
||||
getOptionalActiveAuthUserId,
|
||||
getOptionalActiveAuthUserIdFromAction,
|
||||
requireUser,
|
||||
requireUserFromAction,
|
||||
} from "./lib/access";
|
||||
import { getSkillBadgeMap, getSkillBadgeMaps, isSkillHighlighted } from "./lib/badges";
|
||||
import { scheduleNextBatchIfNeeded } from "./lib/batching";
|
||||
import { generateChangelogPreview as buildChangelogPreview } from "./lib/changelog";
|
||||
@@ -87,13 +94,13 @@ import {
|
||||
import { readCanonicalStat } from "./lib/skillStats";
|
||||
import { runStaticPublishScan } from "./lib/staticPublishScan";
|
||||
import { adjustUserSkillStatsForSkillChange } from "./lib/userSkillStats";
|
||||
import { getLatestSkillRescanTarget, insertSkillRescanRequest } from "./model/skills/rescans";
|
||||
import {
|
||||
assertCanRequestRescan,
|
||||
buildRescanState,
|
||||
errorMessage,
|
||||
finalizeInProgressRescanRequestsForTarget,
|
||||
} from "./model/rescans/policy";
|
||||
import { getLatestSkillRescanTarget, insertSkillRescanRequest } from "./model/skills/rescans";
|
||||
import schema from "./schema";
|
||||
|
||||
export { publishVersionForUser } from "./lib/skillPublish";
|
||||
@@ -1482,7 +1489,7 @@ export const getBySlug = query({
|
||||
const skill = resolved.skill;
|
||||
if (!skill) return null;
|
||||
|
||||
const userId = await getAuthUserId(ctx);
|
||||
const userId = await getOptionalActiveAuthUserId(ctx);
|
||||
const ownerPublisher = await getOwnerPublisher(ctx, {
|
||||
ownerPublisherId: skill.ownerPublisherId,
|
||||
ownerUserId: skill.ownerUserId,
|
||||
@@ -2170,7 +2177,7 @@ export const list = query({
|
||||
}
|
||||
const ownerPublisherId = args.ownerPublisherId;
|
||||
if (ownerPublisherId) {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
const userId = await getOptionalActiveAuthUserId(ctx);
|
||||
const ownerPublisher = await ctx.db.get(ownerPublisherId);
|
||||
const membership =
|
||||
userId &&
|
||||
@@ -2219,7 +2226,7 @@ export const list = query({
|
||||
}
|
||||
const ownerUserId = args.ownerUserId;
|
||||
if (ownerUserId) {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
const userId = await getOptionalActiveAuthUserId(ctx);
|
||||
const isOwnDashboard = Boolean(userId && userId === ownerUserId);
|
||||
const entries = await ctx.db
|
||||
.query("skills")
|
||||
@@ -2870,7 +2877,9 @@ export const listPublicPageV4 = query({
|
||||
},
|
||||
});
|
||||
|
||||
function buildPublicSkillEntryFromDigest(digest: Doc<"skillSearchDigest">): PublicSkillEntry | null {
|
||||
function buildPublicSkillEntryFromDigest(
|
||||
digest: Doc<"skillSearchDigest">,
|
||||
): PublicSkillEntry | null {
|
||||
const hydratable = digestToHydratableSkill(digest);
|
||||
const publicSkill = toPublicSkill(hydratable);
|
||||
if (!publicSkill) return null;
|
||||
@@ -4050,11 +4059,14 @@ async function markSkillRescanRequest(
|
||||
status: "completed" | "failed",
|
||||
error?: string,
|
||||
) {
|
||||
await ctx.runMutation(internal.rescanRequests.markStatusInternal as never, {
|
||||
requestId,
|
||||
status,
|
||||
error,
|
||||
} as never);
|
||||
await ctx.runMutation(
|
||||
internal.rescanRequests.markStatusInternal as never,
|
||||
{
|
||||
requestId,
|
||||
status,
|
||||
error,
|
||||
} as never,
|
||||
);
|
||||
}
|
||||
|
||||
export const getRescanState = query({
|
||||
@@ -5028,7 +5040,7 @@ async function canReadSkillVersionFiles(ctx: ActionCtx, version: Doc<"skillVersi
|
||||
})) as Doc<"skills"> | null;
|
||||
if (!skill) return false;
|
||||
|
||||
const authUserId = await getAuthUserId(ctx);
|
||||
const authUserId = await getOptionalActiveAuthUserIdFromAction(ctx);
|
||||
if (authUserId) {
|
||||
if (authUserId === skill.ownerUserId && !skill.softDeletedAt && !version.softDeletedAt) {
|
||||
return true;
|
||||
|
||||
+15
-5
@@ -3,15 +3,20 @@ import { internal } from "./_generated/api";
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import type { ActionCtx, MutationCtx, QueryCtx } from "./_generated/server";
|
||||
import { internalAction, internalMutation, internalQuery, mutation, query } from "./functions";
|
||||
import { assertAdmin, assertModerator, getOptionalActiveAuthUserId, requireUser } from "./lib/access";
|
||||
import {
|
||||
assertAdmin,
|
||||
assertModerator,
|
||||
getOptionalActiveAuthUserId,
|
||||
requireUser,
|
||||
} from "./lib/access";
|
||||
import { syncGitHubProfile } from "./lib/githubAccount";
|
||||
import { toPublicUser } from "./lib/public";
|
||||
import {
|
||||
ensurePersonalPublisherForUser,
|
||||
getActiveUserByHandleOrPersonalPublisher,
|
||||
getPublisherByHandle,
|
||||
getUserByHandleOrPersonalPublisher,
|
||||
} from "./lib/publishers";
|
||||
import { toPublicUser } from "./lib/public";
|
||||
import {
|
||||
getLatestActiveReservedHandle,
|
||||
isHandleReservedForAnotherUser,
|
||||
@@ -296,7 +301,9 @@ export async function ensureHandler(ctx: MutationCtx) {
|
||||
updates.updatedAt = Date.now();
|
||||
await ctx.db.patch(userId, updates);
|
||||
}
|
||||
const ensuredUser = hasUpdates ? ({ ...user, ...updates } as Doc<"users">) : ((await ctx.db.get(userId)) ?? user);
|
||||
const ensuredUser = hasUpdates
|
||||
? ({ ...user, ...updates } as Doc<"users">)
|
||||
: ((await ctx.db.get(userId)) ?? user);
|
||||
await ensurePersonalPublisherForUser(ctx, ensuredUser);
|
||||
return await ctx.db.get(userId);
|
||||
}
|
||||
@@ -445,7 +452,9 @@ async function queryUsersForPublicList(
|
||||
: clampInt(args.limit * 6, args.limit, MAX_USER_SEARCH_SCAN);
|
||||
const scannedUsers = await ctx.db
|
||||
.query("users")
|
||||
.withIndex("by_active_handle", (q) => q.eq("deletedAt", undefined).eq("deactivatedAt", undefined))
|
||||
.withIndex("by_active_handle", (q) =>
|
||||
q.eq("deletedAt", undefined).eq("deactivatedAt", undefined),
|
||||
)
|
||||
.order("desc")
|
||||
.take(scanLimit);
|
||||
const activeUsers = scannedUsers.filter((user) => Boolean(user.handle));
|
||||
@@ -899,7 +908,8 @@ async function ensurePublisherHandleWithActor(
|
||||
|
||||
if (existing) {
|
||||
const nextDisplayName =
|
||||
args.displayName?.trim() && (!existing.displayName || existing.displayName === existing.handle)
|
||||
args.displayName?.trim() &&
|
||||
(!existing.displayName || existing.displayName === existing.handle)
|
||||
? displayName
|
||||
: existing.displayName;
|
||||
await ctx.db.patch(existing._id, {
|
||||
|
||||
@@ -92,6 +92,7 @@ describe("version file access actions", () => {
|
||||
it("allows owners to read hidden skill versions", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
|
||||
const ctx = makeActionCtx({
|
||||
actor: { _id: "users:owner", role: "user" },
|
||||
version: makeSkillVersion(),
|
||||
skill: {
|
||||
_id: "skills:1",
|
||||
@@ -133,6 +134,7 @@ describe("version file access actions", () => {
|
||||
it("allows owners to read hidden skill files", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
|
||||
const ctx = makeActionCtx({
|
||||
actor: { _id: "users:owner", role: "user" },
|
||||
version: makeSkillVersion(),
|
||||
skill: {
|
||||
_id: "skills:1",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
|
||||
import { createFileRoute, notFound, Outlet, redirect, useRouterState } from "@tanstack/react-router";
|
||||
import { SkillDetailPage } from "../../components/SkillDetailPage";
|
||||
import { buildSkillMeta } from "../../lib/og";
|
||||
import { fetchSkillPageData } from "../../lib/skillPage";
|
||||
@@ -72,5 +72,9 @@ export const Route = createFileRoute("/$owner/$slug")({
|
||||
function OwnerSkill() {
|
||||
const { owner, slug } = Route.useParams();
|
||||
const { initialData } = Route.useLoaderData();
|
||||
const pathname = useRouterState({ select: (state) => state.location.pathname });
|
||||
if (pathname.includes(`/${encodeURIComponent(slug)}/security/`)) {
|
||||
return <Outlet />;
|
||||
}
|
||||
return <SkillDetailPage slug={slug} canonicalOwner={owner} initialData={initialData} />;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
|
||||
import { useQuery } from "convex/react";
|
||||
import { api } from "../../../../../convex/_generated/api";
|
||||
import {
|
||||
SecurityScannerPage,
|
||||
type ScannerSlug,
|
||||
@@ -74,10 +76,19 @@ export const Route = createFileRoute("/$owner/$slug/security/$scanner")({
|
||||
function SkillSecurityScannerRoute() {
|
||||
const { owner, slug, scanner } = Route.useParams();
|
||||
const { initialData } = Route.useLoaderData();
|
||||
const result = initialData?.result;
|
||||
const liveResult = useQuery(api.skills.getBySlug, { slug });
|
||||
const result = liveResult === undefined ? initialData?.result : liveResult;
|
||||
const skill = result?.skill;
|
||||
const latestVersion = result?.latestVersion;
|
||||
|
||||
if (result === undefined) {
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="card">Loading security details...</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!skill || !latestVersion) {
|
||||
return (
|
||||
<main className="section">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { createFileRoute, Link, Outlet, useRouterState } from "@tanstack/react-router";
|
||||
import { AlertTriangle, ExternalLink, Copy, Check, Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { EmptyState } from "../../components/EmptyState";
|
||||
@@ -234,6 +234,11 @@ function isEmptyObject(obj: unknown): boolean {
|
||||
function PluginDetailRoute() {
|
||||
const { name } = Route.useParams();
|
||||
const { detail, version, readme, rateLimited } = Route.useLoaderData() as PluginDetailLoaderData;
|
||||
const pathname = useRouterState({ select: (state) => state.location.pathname });
|
||||
|
||||
if (pathname.includes("/security/")) {
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
if (rateLimited?.scope === "detail") {
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user