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 { action, internalAction, internalMutation, internalQuery, mutation } from "./functions"; import { applyGitHubSkillVerificationResultHandler } from "./githubSkillSync"; import { assertAdmin, assertModerator, requireUser } from "./lib/access"; import { Events, logEvent } from "./lib/observabilityEvents"; import { normalizePackageName } from "./lib/packageRegistry"; import { normalizePackageScanStatus } from "./lib/packageSecurity"; import { assertCanManageOwnedResource } from "./lib/publishers"; import { getRuntimeRolloutCapabilities, isLegacyNvidiaSkillSource, } from "./lib/rolloutCapabilities"; import { sourceSkillVersionFiles } from "./lib/skillCards"; import { getSkillBySlugForPublisher, resolveLegacySkillBySlugOrAlias, resolvePublisherByOwnerHandle, } from "./lib/skills/slugResolution"; import { chunkSkillScanRequestFiles, MAX_SKILL_SCAN_REQUEST_FILE_CHUNKS, MAX_SKILL_SCAN_REQUEST_MANIFEST_BYTES, serializedSkillScanRequestFilesBytes, } from "./lib/skillScanRequestFiles"; import { getSkillsShFixtureEnvironmentPolicy } from "./lib/skillsShCatalogEnvironment"; import { isExactSkillsShCatalogAttempt, shouldPublishSkillsShCatalogEntry, } from "./lib/skillsShCatalogPublication"; import { redactWorkerPublicText } from "./lib/workerTextRedaction"; import { requestSecurityScanDispatch } from "./securityScanDispatch"; const DEFAULT_VT_WAIT_MS = 10 * 60 * 1000; const DEFAULT_LEASE_MS = 60 * 60 * 1000; const MAX_ATTEMPTS = 3; const DEFAULT_CODEX_SCAN_CLAIM_LIMIT = 64; const MAX_CODEX_SCAN_CLAIM_LIMIT = 512; const MAX_EXPIRED_CODEX_SCAN_LEASE_REQUEUES = 512; const DEFAULT_FAILED_SCAN_RECOVERY_LIMIT = 250; const MAX_FAILED_SCAN_RECOVERY_LIMIT = 1000; const FAILED_SCAN_RECOVERY_SAMPLE_LIMIT = 20; 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 = 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; const BULK_RESCAN_SAMPLE_LIMIT = 10; const MAX_STORED_SKILLSPECTOR_ISSUES = 25; const MAX_STORED_SKILLSPECTOR_TEXT_CHARS = 2_000; 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 MAX_SECURITY_SCAN_QUEUE_HEALTH_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"]); const artifactBackedLlmAnalysisStatuses = new Set(["clean", "benign", "suspicious", "malicious"]); async function isGitHubSkillScanAllowed( ctx: Pick, githubSourceId: Id<"githubSkillSources">, ) { if (getRuntimeRolloutCapabilities().githubSkillSync.runtimeEnabled) return true; const source = await ctx.db.get(githubSourceId); return Boolean(source && isLegacyNvidiaSkillSource(source.repo)); } async function assertGitHubSkillScanAllowed( ctx: Pick, githubSourceId: Id<"githubSkillSources">, ) { if (!(await isGitHubSkillScanAllowed(ctx, githubSourceId))) { throw new ConvexError("GitHub Skill Sync rollout is disabled"); } } type CancelSkipReason = | "not-queued" | "not-vt-update" | "not-queued-vt-update" | "malicious-signal" | "missing-target-id" | "missing-target" | "missing-llm-analysis" | "non-final-llm-analysis" | "delete-limit-reached"; type JobTarget = { job: Doc<"securityScanJobs">; skill?: Doc<"skills"> | null; version?: Doc<"skillVersions">; release?: Doc<"packageReleases">; scanRequest?: Doc<"skillScanRequests">; scanRequestFiles?: Doc<"skillScanRequests">["files"]; githubScan?: Doc<"githubSkillScans">; missing?: true; }; type ExistingLlmAnalysis = { status?: string; verdict?: string; }; type SkillSpectorIssueForStorage = { issueId: string; category?: string; pattern?: string; severity: string; confidence?: number; file?: string; startLine?: number; endLine?: number; explanation: string; remediation?: string; finding?: string; codeSnippet?: string; }; type SkillSpectorAnalysisForStorage = { status: string; score?: number; severity?: string; recommendation?: string; issueCount: number; issues: SkillSpectorIssueForStorage[]; scannerVersion?: string; summary?: string; error?: string; checkedAt: number; }; async function resolveSkillForRescan(ctx: MutationCtx, slug: string, ownerHandle?: string) { const normalizedSlug = slug.trim().toLowerCase(); if (!normalizedSlug) throw new ConvexError("Slug required"); if (ownerHandle) { const { requestedHandle, publisher } = await resolvePublisherByOwnerHandle(ctx, ownerHandle); if (!publisher) throw new ConvexError(`Owner @${requestedHandle ?? ownerHandle} was not found`); return await getSkillBySlugForPublisher(ctx, normalizedSlug, publisher); } const resolved = await resolveLegacySkillBySlugOrAlias(ctx, normalizedSlug); if (resolved.ambiguous) { throw new ConvexError( "Slug is used by multiple publishers. Use ownerHandle to rescan a specific skill.", ); } return resolved.skill; } type StoredScanArtifactKind = "skill" | "plugin"; const jobSourceValidator = v.union( v.literal("publish"), v.literal("vt-update"), v.literal("backfill"), v.literal("bulk-rescan"), v.literal("manual"), v.literal("skills-sh-catalog-test"), ); type SecurityScanJobSource = | "publish" | "vt-update" | "backfill" | "bulk-rescan" | "manual" | "skills-sh-catalog-test"; const codexScanWorkerLaneValidator = v.union( v.literal("priority"), v.literal("shared"), v.literal("catalog"), ); type CodexScanQueueHealth = { snapshotAt: number; queueDepth: number; queueDepthIsEstimate: boolean; readyQueueDepth: number; readyQueueDepthIsEstimate: boolean; oldestReadyJobAgeSeconds: number; oldestReadyJobNextRunAt: number | null; }; const CLAIM_SOURCE_ORDER: SecurityScanJobSource[] = [ "publish", "backfill", "vt-update", "bulk-rescan", "skills-sh-catalog-test", ]; const SOURCE_PRIORITY: Record = { manual: 5, publish: 4, backfill: 3, "vt-update": 2, "bulk-rescan": 1, "skills-sh-catalog-test": 0, }; function higherPrioritySource( current: SecurityScanJobSource, requested: SecurityScanJobSource, ): SecurityScanJobSource { return SOURCE_PRIORITY[requested] > SOURCE_PRIORITY[current] ? requested : current; } type EnqueueSkillVersionScanArgs = { versionId: Id<"skillVersions">; source: SecurityScanJobSource; priority?: number; waitForVtMs?: number; preserveActiveJob?: boolean; preserveExistingJob?: boolean; }; type EnqueuePackageReleaseScanArgs = { releaseId: Id<"packageReleases">; source: SecurityScanJobSource; priority?: number; waitForVtMs?: number; }; const llmAgenticRiskEvidenceValidator = v.object({ path: v.string(), snippet: v.string(), explanation: v.string(), }); const llmAgenticRiskFindingValidator = v.object({ categoryId: v.string(), categoryLabel: v.string(), riskBucket: v.union( v.literal("abnormal_behavior_control"), v.literal("permission_boundary"), v.literal("sensitive_data_protection"), ), status: v.union(v.literal("none"), v.literal("note"), v.literal("concern")), severity: v.string(), confidence: v.union(v.literal("high"), v.literal("medium"), v.literal("low")), evidence: v.optional(llmAgenticRiskEvidenceValidator), userImpact: v.string(), recommendation: v.string(), }); const llmRiskSummaryBucketValidator = v.object({ status: v.union(v.literal("none"), v.literal("note"), v.literal("concern")), summary: v.string(), highestSeverity: v.optional(v.string()), }); const llmAnalysisValidator = v.object({ status: v.string(), verdict: v.optional(v.string()), confidence: v.optional(v.string()), summary: v.optional(v.string()), dimensions: v.optional( v.array( v.object({ name: v.string(), label: v.string(), rating: v.string(), detail: v.string(), }), ), ), guidance: v.optional(v.string()), findings: v.optional(v.string()), agenticRiskFindings: v.optional(v.array(llmAgenticRiskFindingValidator)), riskSummary: v.optional( v.object({ abnormal_behavior_control: llmRiskSummaryBucketValidator, permission_boundary: llmRiskSummaryBucketValidator, sensitive_data_protection: llmRiskSummaryBucketValidator, }), ), model: v.optional(v.string()), checkedAt: v.number(), }); const skillSpectorIssueValidator = v.object({ issueId: v.string(), category: v.optional(v.string()), pattern: v.optional(v.string()), severity: v.string(), confidence: v.optional(v.number()), file: v.optional(v.string()), startLine: v.optional(v.number()), endLine: v.optional(v.number()), explanation: v.string(), remediation: v.optional(v.string()), finding: v.optional(v.string()), codeSnippet: v.optional(v.string()), }); const skillSpectorAnalysisValidator = v.object({ status: v.string(), score: v.optional(v.number()), severity: v.optional(v.string()), recommendation: v.optional(v.string()), issueCount: v.number(), // Scanner/action boundaries cap this array before storage; Convex validators cannot express max length. issues: v.array(skillSpectorIssueValidator), scannerVersion: v.optional(v.string()), summary: v.optional(v.string()), error: v.optional(v.string()), checkedAt: v.number(), }); const scanRequestFileValidator = v.object({ path: v.string(), size: v.number(), storageId: v.id("_storage"), sha256: v.string(), 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 catalogScanVerdictValidator = v.union( v.literal("clean"), v.literal("suspicious"), v.literal("malicious"), v.literal("failed"), ); const internalRefs = internal as unknown as { packages: { getPackageByIdInternal: unknown; getReleaseByIdInternal: unknown; updateReleaseLlmAnalysisInternal: unknown; updateReleaseSkillSpectorAnalysisInternal: unknown; }; securityScan: { claimQueuedJobsInternal: unknown; createUploadedSkillScanRequestInternal: unknown; createPublishedSkillScanRequestInternal: unknown; enqueuePackageReleaseScanInternal: unknown; enqueueSkillVersionScanInternal: unknown; failJobInternal: unknown; getCodexScanQueueHealthInternal: unknown; getSkillScanRequestForUserInternal: unknown; getJobTargetInternal: unknown; listReadySourceJobsForClaimInternal: unknown; recordGitHubSkillScanResultInternal: unknown; completeCatalogSkillScanJobInternal: unknown; recordSkillScanRequestFailedInternal: unknown; recordSkillScanRequestSucceededInternal: unknown; requeueJobLeaseInternal: unknown; succeedJobInternal: unknown; }; securityScanDispatch: { requestSecurityScanDispatchInternal: unknown; }; skills: { getSkillByIdInternal: unknown; getVersionByIdInternal: unknown; listVersionFingerprintsInternal: unknown; updateVersionLlmAnalysisInternal: unknown; updateVersionSkillSpectorAnalysisInternal: unknown; }; skillCards: { enqueueForVersionInternal: unknown; }; }; async function runQueryRef( ctx: { runQuery: (ref: never, args: never) => Promise }, ref: unknown, args: unknown, ): Promise { return (await ctx.runQuery(ref as never, args as never)) as T; } async function runMutationRef( ctx: { runMutation: (ref: never, args: never) => Promise }, ref: unknown, args: unknown, ): Promise { return (await ctx.runMutation(ref as never, args as never)) as T; } function assertWorkerToken(token: string) { const expected = process.env.SECURITY_SCAN_WORKER_TOKEN; if (!expected || token !== expected) throw new ConvexError("Unauthorized"); } function defaultVtWaitMs() { const raw = process.env.SECURITY_SCAN_DEFAULT_VT_WAIT_MS?.trim(); if (!raw) return DEFAULT_VT_WAIT_MS; const parsed = Number(raw); if (!Number.isFinite(parsed)) return DEFAULT_VT_WAIT_MS; return Math.max(0, Math.min(parsed, DEFAULT_VT_WAIT_MS)); } function githubSkillScanStatusFromLlmAnalysis( analysis: Pick["llmAnalysis"]>, "status" | "verdict">, ) { const status = normalizePackageScanStatus(analysis.verdict ?? analysis.status); if (status === "clean" || status === "suspicious" || status === "malicious") return status; return "failed" as const; } function sanitizeWorkerErrorDetail(error: string, maxChars = 500) { const redacted = redactWorkerPublicText(error); return redacted.slice(0, maxChars); } function publicWorkerErrorDetail(error: string) { return sanitizeWorkerErrorDetail(error, 500); } function truncateSkillSpectorStorageText( value: string | undefined, maxChars = MAX_STORED_SKILLSPECTOR_TEXT_CHARS, ) { if (value === undefined) return undefined; if (value.length <= maxChars) return value; return `${value.slice(0, maxChars)}\n...[truncated ${value.length - maxChars} chars]`; } function capSkillSpectorIssueForStorage( issue: SkillSpectorIssueForStorage, ): SkillSpectorIssueForStorage { return { issueId: truncateSkillSpectorStorageText(issue.issueId, MAX_STORED_SKILLSPECTOR_SHORT_TEXT_CHARS) ?? "skillspector-issue", category: truncateSkillSpectorStorageText( issue.category, MAX_STORED_SKILLSPECTOR_SHORT_TEXT_CHARS, ), pattern: truncateSkillSpectorStorageText( issue.pattern, MAX_STORED_SKILLSPECTOR_SHORT_TEXT_CHARS, ), severity: truncateSkillSpectorStorageText(issue.severity, MAX_STORED_SKILLSPECTOR_SHORT_TEXT_CHARS) ?? "UNKNOWN", confidence: issue.confidence, file: truncateSkillSpectorStorageText(issue.file, MAX_STORED_SKILLSPECTOR_SHORT_TEXT_CHARS), startLine: issue.startLine, endLine: issue.endLine, explanation: truncateSkillSpectorStorageText(issue.explanation) ?? "SkillSpector reported this issue without additional explanation.", remediation: truncateSkillSpectorStorageText(issue.remediation), finding: truncateSkillSpectorStorageText(issue.finding), codeSnippet: truncateSkillSpectorStorageText(issue.codeSnippet), }; } function capSkillSpectorAnalysisForStorage( analysis: SkillSpectorAnalysisForStorage, ): SkillSpectorAnalysisForStorage { return { status: truncateSkillSpectorStorageText(analysis.status, MAX_STORED_SKILLSPECTOR_SHORT_TEXT_CHARS) ?? "error", score: analysis.score, severity: truncateSkillSpectorStorageText( analysis.severity, MAX_STORED_SKILLSPECTOR_SHORT_TEXT_CHARS, ), recommendation: truncateSkillSpectorStorageText( analysis.recommendation, MAX_STORED_SKILLSPECTOR_SHORT_TEXT_CHARS, ), issueCount: Math.max(analysis.issueCount, analysis.issues.length), issues: analysis.issues .slice(0, MAX_STORED_SKILLSPECTOR_ISSUES) .map(capSkillSpectorIssueForStorage), scannerVersion: truncateSkillSpectorStorageText( analysis.scannerVersion, MAX_STORED_SKILLSPECTOR_SHORT_TEXT_CHARS, ), summary: truncateSkillSpectorStorageText(analysis.summary), error: truncateSkillSpectorStorageText(analysis.error), checkedAt: analysis.checkedAt, }; } function buildWorkerFailureLlmAnalysis(error: string) { return { status: "error", confidence: "low", summary: "ClawScan could not complete because the scanner failed before an artifact-backed review could finish.", guidance: "Treat this scan as incomplete. Retry ClawScan before inferring safety or risk from this result.", findings: `Worker error: ${publicWorkerErrorDetail(error)}`, model: "codex-security-worker", checkedAt: Date.now(), }; } function hasArtifactBackedLlmAnalysis(analysis: ExistingLlmAnalysis | undefined) { const status = analysis?.status?.trim().toLowerCase(); const verdict = analysis?.verdict?.trim().toLowerCase(); return ( artifactBackedLlmAnalysisStatuses.has(status ?? "") || artifactBackedLlmAnalysisStatuses.has(verdict ?? "") ); } function normalizeLimit(limit: number | undefined) { const normalized = Number.isFinite(limit) ? Math.floor(limit ?? DEFAULT_CODEX_SCAN_CLAIM_LIMIT) : DEFAULT_CODEX_SCAN_CLAIM_LIMIT; return Math.max(1, Math.min(normalized, MAX_CODEX_SCAN_CLAIM_LIMIT)); } function normalizeBulkRescanBatchSize(batchSize: number | undefined) { const normalized = Number.isFinite(batchSize) ? Math.floor(batchSize ?? DEFAULT_BULK_RESCAN_BATCH_SIZE) : DEFAULT_BULK_RESCAN_BATCH_SIZE; return Math.max(1, Math.min(normalized, MAX_BULK_RESCAN_BATCH_SIZE)); } async function getBulkSkillRescanBatchStatus(ctx: QueryCtx, jobIds: Id<"securityScanJobs">[]) { let queued = 0; let running = 0; let succeeded = 0; let failed = 0; let missing = 0; const failedJobIds: Id<"securityScanJobs">[] = []; for (const jobId of jobIds) { const job = await ctx.db.get(jobId); if (!job) { missing += 1; continue; } if (job.status === "queued") queued += 1; else if (job.status === "running") running += 1; else if (job.status === "succeeded") succeeded += 1; else if (job.status === "failed") { failed += 1; failedJobIds.push(job._id); } } const terminal = succeeded + failed + missing; return { ok: true as const, total: jobIds.length, queued, running, succeeded, failed, missing, terminal, done: queued + running === 0, failedJobIds, }; } function normalizeMaintenanceScanLimit(limit: number | undefined) { const normalized = Number.isFinite(limit) ? Math.floor(limit ?? DEFAULT_CANCEL_SCAN_LIMIT) : null; return Math.max(1, Math.min(normalized ?? DEFAULT_CANCEL_SCAN_LIMIT, MAX_CANCEL_SCAN_LIMIT)); } function normalizeMaintenanceDeleteLimit(limit: number | undefined, scanLimit: number) { const normalized = Number.isFinite(limit) ? Math.floor(limit ?? DEFAULT_CANCEL_DELETE_LIMIT) : null; return Math.max(0, Math.min(normalized ?? DEFAULT_CANCEL_DELETE_LIMIT, scanLimit)); } function incrementSkip( skippedByReason: Partial>, reason: CancelSkipReason, ) { skippedByReason[reason] = (skippedByReason[reason] ?? 0) + 1; } function isOpenClawPluginPackage( pkg: Doc<"packages"> | null | undefined, ownerPublisher: Pick, "handle" | "deletedAt"> | null | undefined, ) { if (!pkg) return false; if (pkg.family !== "code-plugin" && pkg.family !== "bundle-plugin") return false; if (!pkg.normalizedName.startsWith("@openclaw/")) return false; return ownerPublisher?.handle.trim().toLowerCase() === "openclaw" && !ownerPublisher.deletedAt; } export const enqueueSkillVersionScanInternal = internalMutation({ args: { versionId: v.id("skillVersions"), source: jobSourceValidator, priority: v.optional(v.number()), waitForVtMs: v.optional(v.number()), preserveActiveJob: v.optional(v.boolean()), preserveExistingJob: v.optional(v.boolean()), }, handler: async (ctx, args) => { return enqueueSkillVersionScan(ctx, args); }, }); export const enqueueBulkSkillRescanBatchForAdminInternal = internalMutation({ args: { actorUserId: v.id("users"), mode: v.optional(v.literal("all-active-latest")), cursor: v.optional(v.union(v.string(), v.null())), batchSize: v.optional(v.number()), dryRun: v.optional(v.boolean()), }, handler: async (ctx, args) => { const actor = await ctx.db.get(args.actorUserId); if (!actor) throw new ConvexError("Unauthorized"); assertAdmin(actor); const mode = args.mode ?? "all-active-latest"; const batchSize = normalizeBulkRescanBatchSize(args.batchSize); const dryRun = args.dryRun === true; const page = await ctx.db .query("skills") .withIndex("by_active_created", (q) => q.eq("softDeletedAt", undefined)) .order("asc") .paginate({ cursor: args.cursor ?? null, numItems: batchSize, }); let queued = 0; let alreadyQueued = 0; let skipped = 0; const jobIds: Id<"securityScanJobs">[] = []; const sampleSlugs: string[] = []; for (const skill of page.page) { if (sampleSlugs.length < BULK_RESCAN_SAMPLE_LIMIT) sampleSlugs.push(skill.slug); if ((skill.moderationStatus ?? "active") !== "active" || !skill.latestVersionId) { skipped += 1; continue; } const version = await ctx.db.get(skill.latestVersionId); if (!version || version.softDeletedAt) { skipped += 1; continue; } if (dryRun) { const existing = await ctx.db .query("securityScanJobs") .withIndex("by_skill_version", (q) => q.eq("skillVersionId", version._id)) .collect(); const active = existing.find((job) => job.status === "queued" || job.status === "running"); if (active) alreadyQueued += 1; else queued += 1; continue; } const result = await enqueueSkillVersionScan(ctx, { versionId: version._id, source: "bulk-rescan", priority: 0, waitForVtMs: 0, preserveActiveJob: true, }); if (!result.jobId) { skipped += 1; continue; } jobIds.push(result.jobId); if (result.alreadyQueued) alreadyQueued += 1; else queued += 1; } const nextCursor = page.isDone ? null : page.continueCursor; if (!dryRun) { const now = Date.now(); await ctx.db.insert("auditLogs", { actorUserId: actor._id, action: "skill.clawscan.bulk_rescan_batch", targetType: "securityScanBatch", targetId: `bulk-rescan:${now}`, metadata: { mode, batchSize, queued, alreadyQueued, skipped, cursor: args.cursor ?? null, nextCursor, sampleSlugs, }, createdAt: now, }); } return { ok: true as const, mode, queued, alreadyQueued, skipped, jobIds, nextCursor, done: page.isDone, sampleSlugs, }; }, }); export const getBulkSkillRescanBatchStatusForAdminInternal = internalQuery({ args: { actorUserId: v.id("users"), jobIds: v.array(v.id("securityScanJobs")), }, handler: async (ctx, args) => { const actor = await ctx.db.get(args.actorUserId); if (!actor) throw new ConvexError("Unauthorized"); assertAdmin(actor); return getBulkSkillRescanBatchStatus(ctx, args.jobIds.slice(0, MAX_BULK_RESCAN_STATUS_JOB_IDS)); }, }); export const enqueueSkillRescanForModeratorInternal = internalMutation({ args: { actorUserId: v.id("users"), slug: v.string(), ownerHandle: v.optional(v.string()), version: v.optional(v.string()), }, handler: async (ctx, args) => { const actor = await ctx.db.get(args.actorUserId); if (!actor) throw new ConvexError("Unauthorized"); assertModerator(actor); const skill = await resolveSkillForRescan(ctx, args.slug, args.ownerHandle); if (!skill || skill.softDeletedAt) throw new ConvexError("Skill not found"); const requestedVersion = args.version?.trim(); const version = requestedVersion ? await ctx.db .query("skillVersions") .withIndex("by_skill_version", (q) => q.eq("skillId", skill._id).eq("version", requestedVersion), ) .unique() : skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null; if (!version || version.softDeletedAt) throw new ConvexError("Skill version not found"); const queued = await enqueueSkillVersionScan(ctx, { versionId: version._id, source: "manual", priority: 100, waitForVtMs: 0, }); if (!queued.jobId) throw new ConvexError("Skill version not found"); await ctx.db.insert("auditLogs", { actorUserId: actor._id, action: "skill.clawscan.rescan", targetType: "skillVersion", targetId: version._id, metadata: { skillId: skill._id, slug: skill.slug, version: version.version, jobId: queued.jobId, alreadyQueued: queued.alreadyQueued === true, }, createdAt: Date.now(), }); return { ok: true as const, slug: skill.slug, version: version.version, skillId: skill._id, skillVersionId: version._id, jobId: queued.jobId, alreadyQueued: queued.alreadyQueued === true, }; }, }); async function requestSkillRescanForActor( ctx: MutationCtx, args: { actor: Doc<"users">; skill: Doc<"skills">; version?: string; }, ) { await assertCanManageOwnedResource(ctx, { actor: args.actor, ownerUserId: args.skill.ownerUserId, ownerPublisherId: args.skill.ownerPublisherId, 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"); } await assertGitHubSkillScanAllowed(ctx, args.skill.githubSourceId); 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 .query("skillVersions") .withIndex("by_skill_version", (q) => q.eq("skillId", args.skill._id).eq("version", requestedVersion), ) .unique() : args.skill.latestVersionId ? await ctx.db.get(args.skill.latestVersionId) : null; if (!version || version.softDeletedAt) throw new ConvexError("Skill version not found"); const queued = await enqueueSkillVersionScan(ctx, { versionId: version._id, source: "manual", priority: 100, waitForVtMs: 0, }); if (!queued.jobId) throw new ConvexError("Skill version not found"); await ctx.db.insert("auditLogs", { actorUserId: args.actor._id, action: "skill.clawscan.rescan", targetType: "skillVersion", targetId: version._id, metadata: { skillId: args.skill._id, slug: args.skill.slug, version: version.version, jobId: queued.jobId, alreadyQueued: queued.alreadyQueued === true, }, createdAt: Date.now(), }); return { ok: true as const, slug: args.skill.slug, version: version.version, skillId: args.skill._id, skillVersionId: version._id, jobId: queued.jobId, alreadyQueued: queued.alreadyQueued === true, }; } 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"), slug: v.string(), ownerHandle: v.optional(v.string()), version: v.optional(v.string()), }, handler: async (ctx, args) => { const actor = await ctx.db.get(args.actorUserId); if (!actor) throw new ConvexError("Unauthorized"); const skill = await resolveSkillForRescan(ctx, args.slug, args.ownerHandle); if (!skill || skill.softDeletedAt) throw new ConvexError("Skill not found"); return requestSkillRescanForActor(ctx, { actor, skill, version: args.version }); }, }); export const requestSkillRescan = mutation({ args: { skillId: v.id("skills"), version: v.optional(v.string()), }, handler: async (ctx, args) => { const { user } = await requireUser(ctx); const skill = await ctx.db.get(args.skillId); if (!skill || skill.softDeletedAt) throw new ConvexError("Skill not found"); return requestSkillRescanForActor(ctx, { actor: user, skill, version: args.version }); }, }); function skillScanRequestExpiresAt(now: number) { return now + DEFAULT_SKILL_SCAN_REQUEST_RETENTION_MS; } function skillScanReportFromRequest(request: Doc<"skillScanRequests">) { return { clawscan: request.llmAnalysis ?? null, skillspector: request.skillSpectorAnalysis ?? null, staticAnalysis: request.staticScan ?? null, virustotal: request.vtAnalysis ? { ...request.vtAnalysis, ...request.vtAnalysis.engineStats, } : null, }; } function storedScanReportFromArtifact( artifact: Pick< Doc<"skillVersions"> | Doc<"packageReleases">, "llmAnalysis" | "skillSpectorAnalysis" | "staticScan" | "vtAnalysis" >, ) { return { clawscan: artifact.llmAnalysis ?? null, skillspector: artifact.skillSpectorAnalysis ?? null, staticAnalysis: artifact.staticScan ?? null, virustotal: artifact.vtAnalysis ? { ...artifact.vtAnalysis, ...artifact.vtAnalysis.engineStats, } : null, }; } function hasStoredScanReport( artifact: Pick< Doc<"skillVersions"> | Doc<"packageReleases">, "llmAnalysis" | "skillSpectorAnalysis" | "staticScan" | "vtAnalysis" >, ) { return Boolean( artifact.llmAnalysis || artifact.skillSpectorAnalysis || artifact.staticScan || artifact.vtAnalysis, ); } function completedAtFromStoredScanReport( artifact: Pick< Doc<"skillVersions"> | Doc<"packageReleases">, "llmAnalysis" | "skillSpectorAnalysis" | "staticScan" | "vtAnalysis" >, ) { const checkedAtValues = [ artifact.llmAnalysis?.checkedAt, artifact.skillSpectorAnalysis?.checkedAt, artifact.staticScan?.checkedAt, artifact.vtAnalysis?.checkedAt, ].filter((value): value is number => typeof value === "number" && Number.isFinite(value)); return checkedAtValues.length > 0 ? Math.max(...checkedAtValues) : undefined; } function skillScanArtifactFromRequest(request: Doc<"skillScanRequests">) { return { ...(request.slug ? { slug: request.slug } : {}), ...(request.displayName ? { displayName: request.displayName } : {}), ...(request.version ? { version: request.version } : {}), ...(request.sha256hash ? { sha256hash: request.sha256hash } : {}), fileCount: request.files.length, }; } async function countSecurityScanJobs( ctx: QueryCtx | MutationCtx, status: Doc<"securityScanJobs">["status"], source: SecurityScanJobSource, ) { const jobs = await ctx.db .query("securityScanJobs") .withIndex("by_status_source_created_at", (q) => q.eq("status", status).eq("source", source)) .take(MAX_SKILL_SCAN_RUNNING_COUNT_READS + 1); return { count: Math.min(jobs.length, MAX_SKILL_SCAN_RUNNING_COUNT_READS), isEstimate: jobs.length > MAX_SKILL_SCAN_RUNNING_COUNT_READS, }; } export const getCodexScanQueueHealthInternal = internalQuery({ args: {}, handler: async (ctx) => { const snapshotAt = Date.now(); const queuedJobs = await ctx.db .query("securityScanJobs") .withIndex("by_status_and_next_run_at", (q) => q.eq("status", "queued")) .order("asc") .take(MAX_SECURITY_SCAN_QUEUE_HEALTH_READS + 1); const sampledJobs = queuedJobs.slice(0, MAX_SECURITY_SCAN_QUEUE_HEALTH_READS); const firstFutureJobIndex = sampledJobs.findIndex((job) => job.nextRunAt > snapshotAt); const readyQueueDepth = firstFutureJobIndex === -1 ? sampledJobs.length : firstFutureJobIndex; const queueDepthIsEstimate = queuedJobs.length > MAX_SECURITY_SCAN_QUEUE_HEALTH_READS; const oldestReadyJob = readyQueueDepth > 0 ? sampledJobs[0] : null; return { snapshotAt, queueDepth: sampledJobs.length, queueDepthIsEstimate, readyQueueDepth, readyQueueDepthIsEstimate: queueDepthIsEstimate && readyQueueDepth === MAX_SECURITY_SCAN_QUEUE_HEALTH_READS, oldestReadyJobAgeSeconds: oldestReadyJob ? Math.max(0, Math.floor((snapshotAt - oldestReadyJob.nextRunAt) / 1000)) : 0, oldestReadyJobNextRunAt: oldestReadyJob?.nextRunAt ?? null, }; }, }); export const logCodexScanQueueHealthInternal = internalAction({ args: {}, handler: async (ctx): Promise => { const snapshot = await runQueryRef( ctx, internalRefs.securityScan.getCodexScanQueueHealthInternal, {}, ); logEvent(Events.SecurityScanQueueSnapshot, snapshot); return snapshot; }, }); export const getCodexScanQueueHealth = action({ args: { token: v.string(), }, handler: async (ctx, args): Promise => { assertWorkerToken(args.token); return await runQueryRef( ctx, internalRefs.securityScan.getCodexScanQueueHealthInternal, {}, ); }, }); function compareQueuedScanClaimOrder(a: Doc<"securityScanJobs">, b: Doc<"securityScanJobs">) { if (a.nextRunAt !== b.nextRunAt) return a.nextRunAt - b.nextRunAt; if (a._creationTime !== b._creationTime) return a._creationTime - b._creationTime; return a._id.localeCompare(b._id); } async function countQueuedJobsAhead(ctx: QueryCtx | MutationCtx, job: Doc<"securityScanJobs">) { const candidates = await ctx.db .query("securityScanJobs") .withIndex("by_status_source_next_run_at", (q) => q.eq("status", "queued").eq("source", job.source).lte("nextRunAt", job.nextRunAt), ) .order("asc") .take(MAX_SKILL_SCAN_QUEUE_POSITION_READS + 1); const queuedAhead = candidates.reduce((count, candidate) => { if (candidate._id === job._id) return count; return compareQueuedScanClaimOrder(candidate, job) < 0 ? count + 1 : count; }, 0); const sawTarget = candidates.some((candidate) => candidate._id === job._id); const isEstimate = !sawTarget || candidates.length > MAX_SKILL_SCAN_QUEUE_POSITION_READS || queuedAhead > MAX_SKILL_SCAN_QUEUE_POSITION_READS; return { queuedAhead: Math.min(queuedAhead, MAX_SKILL_SCAN_QUEUE_POSITION_READS), isEstimate, }; } async function skillScanQueueState( ctx: QueryCtx | MutationCtx, job: Doc<"securityScanJobs"> | null, ) { if (!job) { return { queuedAhead: 0, position: null, running: 0, note: SKILL_SCAN_ASYNC_NOTE, }; } const running = await countSecurityScanJobs(ctx, "running", job.source); const queuedAhead = job.status === "queued" ? await countQueuedJobsAhead(ctx, job) : { queuedAhead: 0, isEstimate: false }; return { queuedAhead: queuedAhead.queuedAhead, queuedAheadIsEstimate: queuedAhead.isEstimate, position: job.status === "queued" && !queuedAhead.isEstimate ? queuedAhead.queuedAhead + 1 : null, running: running.count, runningIsEstimate: running.isEstimate, note: SKILL_SCAN_ASYNC_NOTE, }; } async function skillScanStatusResponse( ctx: QueryCtx | MutationCtx, request: Doc<"skillScanRequests">, job: Doc<"securityScanJobs"> | null, ) { const status = request.status === "succeeded" || request.status === "failed" ? request.status : (job?.status ?? request.status); return { ok: true as const, scanId: request._id, jobId: request.securityScanJobId, status, sourceKind: request.sourceKind, update: request.update, writtenBack: request.writtenBack, artifact: skillScanArtifactFromRequest(request), report: skillScanReportFromRequest(request), queue: await skillScanQueueState(ctx, job), lastError: request.lastError ?? job?.lastError, createdAt: request.createdAt, updatedAt: Math.max(request.updatedAt, job?.updatedAt ?? request.updatedAt), completedAt: request.completedAt ?? job?.completedAt, }; } 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"); let rolloutGate: "github-skill-sync" | undefined; if (request.sourceKind === "github" && request.githubSkillScanId) { const scan = await ctx.db.get(request.githubSkillScanId); const source = scan ? await ctx.db.get(scan.githubSourceId) : null; if (source && !isLegacyNvidiaSkillSource(source.repo)) { rolloutGate = "github-skill-sync"; } } const now = Date.now(); const jobId = await ctx.db.insert("securityScanJobs", { targetKind: "skillScanRequest", skillScanRequestId: request._id, rolloutGate, status: "queued", source: options?.source ?? "manual", priority: options?.priority ?? 100, hasMaliciousSignal: false, waitForVtUntil: now, nextRunAt: now, attempts: 0, createdAt: now, updatedAt: now, }); await ctx.db.patch(request._id, { securityScanJobId: jobId, updatedAt: now, }); return jobId; } export async function enqueueSkillsShCatalogScanRequest( ctx: MutationCtx, args: { actorUserId: Id<"users">; attemptId: Id<"skillsShCatalogScanAttempts">; slug: string; displayName: string; artifactContentHash: string; files: Doc<"skillScanRequests">["files"]; }, ) { const now = Date.now(); const requestId = await ctx.db.insert("skillScanRequests", { actorUserId: args.actorUserId, sourceKind: "skills-sh-catalog", update: false, writtenBack: false, status: "queued", requestedJobSource: "skills-sh-catalog-test", requestedJobPriority: -100, slug: args.slug, displayName: args.displayName, skillsShCatalogAttemptId: args.attemptId, files: args.files, sha256hash: args.artifactContentHash, expiresAt: skillScanRequestExpiresAt(now), createdAt: now, updatedAt: now, }); const jobId = await enqueueSkillScanRequestJob(ctx, requestId, { source: "skills-sh-catalog-test", priority: -100, }); return { requestId, jobId }; } async function resolveGitHubSkillScanTarget( ctx: Pick, skill: Doc<"skills">, args: { commit: string; contentHash: string }, ) { if ( skill.installKind === "github" && skill.githubSourceId && skill.githubPath && skill.githubCurrentStatus === "present" && skill.githubCurrentCommit === args.commit && skill.githubCurrentContentHash === args.contentHash ) { return { githubSourceId: skill.githubSourceId, githubPath: skill.githubPath, }; } if (!skill.githubPendingCandidateId) return null; const candidate = await ctx.db.get(skill.githubPendingCandidateId); if ( !candidate || candidate.skillId !== skill._id || candidate.githubCommit !== args.commit || candidate.githubContentHash !== args.contentHash ) { return null; } return { githubSourceId: candidate.githubSourceId, githubPath: candidate.githubPath, }; } 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()), presentation: v.optional( v.object({ displayName: v.string(), displayNameSource: v.optional( v.union( v.literal("publisher"), v.literal("openai"), v.literal("skill"), v.literal("slug"), ), ), summary: v.optional(v.string()), summarySource: v.optional( v.union( v.literal("publisher"), v.literal("openai"), v.literal("skill"), v.literal("generated"), ), ), icon: v.optional(v.string()), }), ), }), staticScan: staticScanResultValidator, }, handler: async (ctx, args) => { const skill = await ctx.db.get(args.skillId); const target = skill ? await resolveGitHubSkillScanTarget(ctx, skill, { commit: args.commit, contentHash: args.contentHash, }) : null; if (!skill || !target) { return { ok: true as const, skipped: "stale-or-missing" as const }; } if (!(await isGitHubSkillScanAllowed(ctx, target.githubSourceId))) { return { ok: true as const, skipped: "rollout-disabled" 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: target.githubSourceId, commit: args.commit, path: target.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 && !args.force && 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: target.githubSourceId, contentHash: args.contentHash, commit: args.commit, path: target.githubPath, status: "pending", staticScan: args.staticScan, createdAt: now, updatedAt: now, })); if (existing) { await ctx.db.patch(existing._id, { githubSourceId: target.githubSourceId, commit: args.commit, path: target.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"); } await assertGitHubSkillScanAllowed(ctx, scan.githubSourceId); 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"); } const scan = await ctx.db.get(request.githubSkillScanId); if (!scan) { throw new ConvexError("GitHub scan request is no longer current"); } await assertGitHubSkillScanAllowed(ctx, scan.githubSourceId); 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 skill = scan ? await ctx.db.get(scan.skillId) : null; const target = skill ? await resolveGitHubSkillScanTarget(ctx, skill, { commit: scan.commit, contentHash: scan.contentHash, }) : null; if ( !scan || scan.status !== "pending" || scan.skillScanRequestId !== request._id || !skill || !target || target.githubSourceId !== scan.githubSourceId || target.githubPath !== scan.path ) { 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"), files: v.array(scanRequestFileValidator), displayName: v.optional(v.string()), }, handler: async (ctx, args) => { const actor = await ctx.db.get(args.actorUserId); if (!actor) throw new ConvexError("Unauthorized"); if (args.files.length === 0) throw new ConvexError("files required"); if ( !args.files.some((file) => { const lower = file.path.trim().toLowerCase(); return lower === "skill.md"; }) ) { throw new ConvexError("SKILL.md required"); } const now = Date.now(); const scanId = await ctx.db.insert("skillScanRequests", { actorUserId: actor._id, sourceKind: "upload", update: false, writtenBack: false, status: "queued", displayName: args.displayName, version: "local", files: args.files, expiresAt: skillScanRequestExpiresAt(now), createdAt: now, updatedAt: now, }); const jobId = await enqueueSkillScanRequestJob(ctx, scanId); await ctx.db.insert("auditLogs", { actorUserId: actor._id, action: "skill.clawscan.scan_upload", targetType: "skillScanRequest", targetId: scanId, metadata: { jobId, fileCount: args.files.length, }, createdAt: now, }); return { ok: true as const, scanId, jobId, status: "queued" as const, sourceKind: "upload" as const, update: false, alreadyQueued: false, queue: await skillScanQueueState(ctx, await ctx.db.get(jobId)), }; }, }); export const createPublishedSkillScanRequestInternal = internalMutation({ args: { actorUserId: v.id("users"), slug: v.string(), version: v.optional(v.string()), update: v.optional(v.boolean()), }, handler: async (ctx, args) => { 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(); if (!skill || skill.softDeletedAt) throw new ConvexError("Skill not found"); await assertCanManageOwnedResource(ctx, { actor, ownerUserId: skill.ownerUserId, ownerPublisherId: skill.ownerPublisherId, allowPlatformModerator: true, }); const requestedVersion = args.version?.trim(); const version = requestedVersion ? await ctx.db .query("skillVersions") .withIndex("by_skill_version", (q) => q.eq("skillId", skill._id).eq("version", requestedVersion), ) .unique() : skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null; if (!version || version.softDeletedAt) throw new ConvexError("Skill version not found"); const fingerprintEntries = await ctx.db .query("skillVersionFingerprints") .withIndex("by_version", (q) => q.eq("versionId", version._id)) .collect(); const files = sourceSkillVersionFiles(version.files, { generatedBundleFingerprints: fingerprintEntries .filter((entry) => entry.kind === "generated-bundle") .map((entry) => entry.fingerprint), }); const now = Date.now(); const update = args.update === true; const scanId = await ctx.db.insert("skillScanRequests", { actorUserId: actor._id, sourceKind: "published", update, writtenBack: false, status: "queued", slug: skill.slug, displayName: skill.displayName, version: version.version, skillId: skill._id, skillVersionId: version._id, files, parsed: version.parsed, sha256hash: version.sha256hash, vtAnalysis: version.vtAnalysis, staticScan: version.staticScan, expiresAt: skillScanRequestExpiresAt(now), createdAt: now, updatedAt: now, }); const jobId = await enqueueSkillScanRequestJob(ctx, scanId); await ctx.db.insert("auditLogs", { actorUserId: actor._id, action: update ? "skill.clawscan.scan_published_update" : "skill.clawscan.scan_published", targetType: "skillVersion", targetId: version._id, metadata: { skillId: skill._id, slug: skill.slug, version: version.version, scanId, jobId, update, }, createdAt: now, }); return { ok: true as const, scanId, jobId, status: "queued" as const, sourceKind: "published" as const, update, alreadyQueued: false, queue: await skillScanQueueState(ctx, await ctx.db.get(jobId)), }; }, }); export const getSkillScanRequestForUserInternal = internalQuery({ args: { actorUserId: v.id("users"), scanId: v.id("skillScanRequests"), }, handler: async (ctx, args) => { const actor = await ctx.db.get(args.actorUserId); if (!actor) throw new ConvexError("Unauthorized"); const request = await ctx.db.get(args.scanId); if (!request) throw new ConvexError("Scan not found"); if (request.actorUserId !== actor._id && actor.role !== "admin" && actor.role !== "moderator") { throw new ConvexError("Forbidden"); } const job = request.securityScanJobId ? await ctx.db.get(request.securityScanJobId) : null; return await skillScanStatusResponse(ctx, request, job); }, }); export const getStoredScanReportForUserInternal = internalQuery({ args: { actorUserId: v.id("users"), kind: v.union(v.literal("skill"), v.literal("plugin")), name: v.string(), version: v.string(), }, handler: async (ctx, args) => { const actor = await ctx.db.get(args.actorUserId); if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized"); const name = args.name.trim(); const versionLabel = args.version.trim(); if (!name) throw new ConvexError("Name required"); if (!versionLabel) throw new ConvexError("Version required"); return args.kind === "plugin" ? await getStoredPackageScanReportForUser(ctx, { actor, kind: args.kind, name, version: versionLabel, }) : await getStoredSkillScanReportForUser(ctx, { actor, kind: args.kind, name, version: versionLabel, }); }, }); async function getStoredSkillScanReportForUser( ctx: QueryCtx, args: { actor: Doc<"users">; kind: StoredScanArtifactKind; name: string; version: string; }, ) { const slug = args.name.toLowerCase(); const skill = await ctx.db .query("skills") .withIndex("by_slug", (q) => q.eq("slug", slug)) .unique(); if (!skill) throw new ConvexError("Skill not found"); await assertCanManageOwnedResource(ctx, { actor: args.actor, ownerUserId: skill.ownerUserId, ownerPublisherId: skill.ownerPublisherId, allowedPublisherRoles: ["publisher"], allowPlatformModerator: true, }); const version = await ctx.db .query("skillVersions") .withIndex("by_skill_version", (q) => q.eq("skillId", skill._id).eq("version", args.version)) .unique(); if (!version) throw new ConvexError("Skill version not found"); if (!hasStoredScanReport(version)) throw new ConvexError("Scan results not found"); const completedAt = completedAtFromStoredScanReport(version); return { ok: true as const, scanId: `skill:${skill.slug}:${version.version}`, status: "succeeded" as const, sourceKind: "published" as const, update: false, writtenBack: true, artifact: { kind: args.kind, slug: skill.slug, displayName: skill.displayName, version: version.version, ...(version.sha256hash ? { sha256hash: version.sha256hash } : {}), fileCount: version.files.length, }, report: storedScanReportFromArtifact(version), createdAt: version.createdAt, updatedAt: Math.max(version.createdAt, completedAt ?? version.createdAt), completedAt, }; } async function getStoredPackageScanReportForUser( ctx: QueryCtx, args: { actor: Doc<"users">; kind: StoredScanArtifactKind; name: string; version: string; }, ) { const normalizedName = normalizePackageName(args.name); const pkg = await ctx.db .query("packages") .withIndex("by_name", (q) => q.eq("normalizedName", normalizedName)) .unique(); if (!pkg || pkg.family === "skill") throw new ConvexError("Plugin not found"); await assertCanManageOwnedResource(ctx, { actor: args.actor, ownerUserId: pkg.ownerUserId, ownerPublisherId: pkg.ownerPublisherId, allowedPublisherRoles: ["publisher"], allowPlatformModerator: true, }); const release = await ctx.db .query("packageReleases") .withIndex("by_package_version", (q) => q.eq("packageId", pkg._id).eq("version", args.version)) .unique(); if (!release) throw new ConvexError("Plugin version not found"); if (!hasStoredScanReport(release)) throw new ConvexError("Scan results not found"); const completedAt = completedAtFromStoredScanReport(release); return { ok: true as const, scanId: `plugin:${pkg.normalizedName}:${release.version}`, status: "succeeded" as const, sourceKind: "published" as const, update: false, writtenBack: true, artifact: { kind: args.kind, name: pkg.name, displayName: pkg.displayName, version: release.version, ...(release.integritySha256 ? { sha256hash: release.integritySha256 } : {}), fileCount: release.files.length, }, report: storedScanReportFromArtifact(release), createdAt: release.createdAt, updatedAt: Math.max(release.createdAt, completedAt ?? release.createdAt), completedAt, }; } export const recordSkillScanRequestSucceededInternal = internalMutation({ args: { scanId: v.id("skillScanRequests"), jobId: v.id("securityScanJobs"), runId: v.optional(v.string()), llmAnalysis: llmAnalysisValidator, skillSpectorAnalysis: v.optional(skillSpectorAnalysisValidator), writtenBack: v.optional(v.boolean()), }, handler: async (ctx, args) => { const request = await ctx.db.get(args.scanId); if (!request) throw new ConvexError("Scan request not found"); const now = Date.now(); await ctx.db.patch(request._id, { status: "succeeded", llmAnalysis: args.llmAnalysis, ...(args.skillSpectorAnalysis ? { skillSpectorAnalysis: capSkillSpectorAnalysisForStorage(args.skillSpectorAnalysis) } : {}), writtenBack: args.writtenBack === true || request.writtenBack, runId: args.runId, completedAt: now, updatedAt: now, }); return { ok: true as const }; }, }); export const completeCatalogSkillScanJobInternal = internalMutation({ args: { attemptId: v.id("skillsShCatalogScanAttempts"), scanId: v.id("skillScanRequests"), jobId: v.id("securityScanJobs"), leaseToken: v.string(), artifactContentHash: v.string(), verdict: catalogScanVerdictValidator, runId: v.optional(v.string()), llmAnalysis: llmAnalysisValidator, skillSpectorAnalysis: v.optional(skillSpectorAnalysisValidator), }, handler: async (ctx, args) => { const environment = getSkillsShFixtureEnvironmentPolicy(); if (!environment.allowed || environment.environment !== "test") { throw new ConvexError("catalog scan completion requires the permanent Test environment"); } const [job, request, attempt] = await Promise.all([ ctx.db.get(args.jobId), ctx.db.get(args.scanId), ctx.db.get(args.attemptId), ]); if ( !job || job.source !== "skills-sh-catalog-test" || job.targetKind !== "skillScanRequest" || job.skillScanRequestId !== args.scanId ) { throw new ConvexError("Catalog scan job linkage mismatch"); } if ( !request || request.sourceKind !== "skills-sh-catalog" || request.securityScanJobId !== args.jobId || request.skillsShCatalogAttemptId !== args.attemptId ) { throw new ConvexError("Catalog scan request linkage mismatch"); } if ( !attempt || attempt.dispatchKind !== "real" || attempt.skillScanRequestId !== args.scanId || attempt.securityScanJobId !== args.jobId ) { throw new ConvexError("Catalog scan attempt linkage mismatch"); } const artifactContentHash = args.artifactContentHash.toLowerCase(); if ( !attempt.artifactContentHash || attempt.artifactContentHash !== artifactContentHash || request.sha256hash !== artifactContentHash ) { throw new ConvexError("Catalog scan artifact hash mismatch"); } if ( attempt.status === "succeeded" || attempt.status === "failed" || attempt.status === "canceled" ) { const expectedStatus = args.verdict === "failed" ? "failed" : "succeeded"; if ( attempt.status === expectedStatus && attempt.verdict === args.verdict && request.status === expectedStatus && job.status === expectedStatus && (expectedStatus !== "failed" || (request.lastError === "Catalog scan analysis failed" && job.lastError === "Catalog scan analysis failed")) ) { const terminalEntry = await ctx.db.get(attempt.entryId); return { ok: true as const, applied: true as const, publicVisible: terminalEntry?.publicVisible === true, }; } if ( attempt.status === "canceled" && request.status === "failed" && job.status === "failed" && request.lastError === job.lastError ) { if (request.lastError === "Catalog run canceled before scan completion") { return { ok: true as const, applied: false as const, reason: "run-canceled" as const }; } if (request.lastError === "Catalog source changed before scan completion") { return { ok: true as const, applied: false as const, reason: "stale-attempt" as const }; } } throw new ConvexError("Catalog scan terminal result mismatch"); } if ( job.leaseToken !== args.leaseToken || job.status !== "running" || (attempt.status !== "queued" && attempt.status !== "running") ) { throw new ConvexError("Catalog scan job lease mismatch"); } const [run, entry, control] = await Promise.all([ ctx.db.get(attempt.runId), ctx.db.get(attempt.entryId), ctx.db .query("skillsShCatalogControls") .withIndex("by_key", (q) => q.eq("key", "global")) .unique(), ]); const now = Date.now(); const terminalizeWithoutResult = async (reason: "run-canceled" | "stale-attempt") => { const entryStillCurrent = entry?.sourceContentHash === attempt.sourceContentHash; await ctx.db.patch(attempt._id, { status: "canceled", completedAt: now, updatedAt: now, }); if (entryStillCurrent) { await ctx.db.patch(entry._id, { scanStatus: "canceled", publicVisible: false, updatedAt: now, }); } await ctx.db.patch(request._id, { status: "failed", lastError: reason === "run-canceled" ? "Catalog run canceled before scan completion" : "Catalog source changed before scan completion", completedAt: now, updatedAt: now, }); await ctx.db.patch(job._id, { status: "failed", lastError: reason === "run-canceled" ? "Catalog run canceled before scan completion" : "Catalog source changed before scan completion", completedAt: now, leaseToken: undefined, leaseExpiresAt: undefined, updatedAt: now, }); if (run) { const [queued, running] = await Promise.all([ ctx.db .query("skillsShCatalogScanAttempts") .withIndex("by_run_and_status", (q) => q.eq("runId", run._id).eq("status", "queued")) .first(), ctx.db .query("skillsShCatalogScanAttempts") .withIndex("by_run_and_status", (q) => q.eq("runId", run._id).eq("status", "running")) .first(), ]); await ctx.db.patch(run._id, { ...(reason === "run-canceled" ? { status: queued || running ? ("canceling" as const) : ("canceled" as const) } : {}), counts: { ...run.counts, scansCanceled: run.counts.scansCanceled + 1, }, operations: { functionCalls: run.operations.functionCalls + 1, dbReads: run.operations.dbReads + 7, dbWrites: run.operations.dbWrites + (entryStillCurrent ? 5 : 4), }, updatedAt: now, }); } return { ok: true as const, applied: false as const, reason }; }; if (run?.status === "canceling" || run?.status === "canceled") { return await terminalizeWithoutResult("run-canceled"); } const attemptIdentity = attempt.githubOwnerId !== undefined && attempt.owner !== undefined && attempt.repo !== undefined && attempt.slug !== undefined ? { externalId: attempt.externalId, githubOwnerId: attempt.githubOwnerId, owner: attempt.owner, repo: attempt.repo, slug: attempt.slug, githubPath: attempt.githubPath, githubCommit: attempt.githubCommit, githubContentHash: attempt.githubContentHash, sourceContentHash: attempt.sourceContentHash, dispatchKind: attempt.dispatchKind, source: attempt.source, } : null; if (!entry || !attemptIdentity || !isExactSkillsShCatalogAttempt(entry, attemptIdentity)) { return await terminalizeWithoutResult("stale-attempt"); } const scanFailed = args.verdict === "failed"; const publicVisible = attempt.publicationRolledBackAt === undefined && shouldPublishSkillsShCatalogEntry({ control, entry, attempt: attemptIdentity, verdict: args.verdict, }); await ctx.db.patch(attempt._id, { status: scanFailed ? "failed" : "succeeded", verdict: args.verdict, completedAt: now, updatedAt: now, }); await ctx.db.patch(entry._id, { scanStatus: args.verdict, publicVisible, publishedScanAttemptId: publicVisible ? attempt._id : undefined, updatedAt: now, }); await ctx.db.patch(request._id, { status: scanFailed ? "failed" : "succeeded", lastError: scanFailed ? "Catalog scan analysis failed" : undefined, llmAnalysis: args.llmAnalysis, ...(args.skillSpectorAnalysis ? { skillSpectorAnalysis: capSkillSpectorAnalysisForStorage(args.skillSpectorAnalysis) } : {}), writtenBack: request.writtenBack, runId: args.runId, completedAt: now, updatedAt: now, }); await ctx.db.patch(job._id, { status: scanFailed ? "failed" : "succeeded", lastError: scanFailed ? "Catalog scan analysis failed" : undefined, runId: args.runId, completedAt: now, leaseToken: undefined, leaseExpiresAt: undefined, updatedAt: now, }); if (run) { await ctx.db.patch(run._id, { counts: { ...run.counts, scansCompleted: run.counts.scansCompleted + 1, }, operations: { functionCalls: run.operations.functionCalls + 1, dbReads: run.operations.dbReads + 5, dbWrites: run.operations.dbWrites + 5, }, updatedAt: now, }); } return { ok: true as const, applied: true as const, publicVisible }; }, }); export const recordSkillScanRequestFailedInternal = internalMutation({ args: { scanId: v.id("skillScanRequests"), error: v.string(), llmAnalysis: v.optional(llmAnalysisValidator), }, handler: async (ctx, args) => { const request = await ctx.db.get(args.scanId); if (!request) throw new ConvexError("Scan request not found"); const now = Date.now(); const error = sanitizeWorkerErrorDetail(args.error, 2000); await ctx.db.patch(request._id, { status: "failed", lastError: error, ...(args.llmAnalysis ? { llmAnalysis: args.llmAnalysis } : {}), completedAt: now, updatedAt: now, }); return { ok: true as const }; }, }); 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(); const error = args.error ? sanitizeWorkerErrorDetail(args.error, 2000) : undefined; await ctx.db.patch(scan._id, { status: args.scanStatus, llmAnalysis: args.llmAnalysis, skillSpectorAnalysis: args.skillSpectorAnalysis, lastError: error, 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()), }, handler: async (ctx, args) => { const batchSize = Math.max( 1, Math.min( args.batchSize ?? DEFAULT_PRUNE_SKILL_SCAN_REQUEST_LIMIT, MAX_PRUNE_SKILL_SCAN_REQUEST_LIMIT, ), ); const now = Date.now(); const requests = await ctx.db .query("skillScanRequests") .withIndex("by_expires_at", (q) => q.lt("expiresAt", now)) .take(batchSize); let deletedJobs = 0; let deletedFiles = 0; let deletedRequests = 0; let deferredRequests = 0; for (const request of requests) { const job = request.securityScanJobId ? await ctx.db.get(request.securityScanJobId) : null; if ( request.sourceKind === "skills-sh-catalog" && job?.targetKind === "skillScanRequest" && (job.status === "queued" || job.status === "running") ) { deferredRequests += 1; continue; } if (request.sourceKind === "skills-sh-catalog" && request.skillsShCatalogAttemptId) { const attempt = await ctx.db.get(request.skillsShCatalogAttemptId); const run = attempt ? await ctx.db.get(attempt.runId) : null; await terminalizeBlockedCatalogRetry( ctx, { kind: "blocked", request, ...(attempt ? { attempt } : {}), ...(run ? { run } : {}), }, now, ); } if (job?.targetKind === "skillScanRequest") { await ctx.db.delete(job._id); deletedJobs += 1; } 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" || request.sourceKind === "skills-sh-catalog" ) { for (const file of [...request.files, ...fileChunks.flatMap((chunk) => chunk.files)]) { try { await ctx.storage.delete(file.storageId); deletedFiles += 1; } catch { // Missing storage objects should not block expiry of the request row. } } } 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, deferredRequests, deletedJobs, deletedFiles, done, }; }, }); async function requestPackageRescanForActor( ctx: MutationCtx, args: { actor: Doc<"users">; pkg: Doc<"packages">; version?: string; }, ) { await assertCanManageOwnedResource(ctx, { actor: args.actor, ownerUserId: args.pkg.ownerUserId, ownerPublisherId: args.pkg.ownerPublisherId, allowPlatformModerator: true, }); const requestedVersion = args.version?.trim(); const release = requestedVersion ? await ctx.db .query("packageReleases") .withIndex("by_package_version", (q) => q.eq("packageId", args.pkg._id).eq("version", requestedVersion), ) .unique() : args.pkg.latestReleaseId ? await ctx.db.get(args.pkg.latestReleaseId) : null; if (!release || release.softDeletedAt) throw new ConvexError("Package release not found"); const queued = await enqueuePackageReleaseScan(ctx, { releaseId: release._id, source: "manual", priority: 100, waitForVtMs: 0, }); if (!queued.jobId) throw new ConvexError("Package release not found"); await ctx.db.insert("auditLogs", { actorUserId: args.actor._id, action: "package.clawscan.rescan", targetType: "packageRelease", targetId: release._id, metadata: { packageId: args.pkg._id, name: args.pkg.name, version: release.version, jobId: queued.jobId, alreadyQueued: queued.alreadyQueued === true, }, createdAt: Date.now(), }); return { ok: true as const, name: args.pkg.name, version: release.version, packageId: args.pkg._id, packageReleaseId: release._id, jobId: queued.jobId, alreadyQueued: queued.alreadyQueued === true, }; } export const requestPackageRescanForUserInternal = internalMutation({ args: { actorUserId: v.id("users"), name: v.string(), version: v.optional(v.string()), }, handler: async (ctx, args) => { const actor = await ctx.db.get(args.actorUserId); if (!actor) throw new ConvexError("Unauthorized"); const normalizedName = normalizePackageName(args.name); if (!normalizedName) throw new ConvexError("Package name required"); const pkg = await ctx.db .query("packages") .withIndex("by_name", (q) => q.eq("normalizedName", normalizedName)) .unique(); if (!pkg || pkg.softDeletedAt || pkg.family === "skill") throw new ConvexError("Package not found"); return requestPackageRescanForActor(ctx, { actor, pkg, version: args.version }); }, }); export const requestPackageRescan = mutation({ args: { packageId: v.id("packages"), version: v.optional(v.string()), }, handler: async (ctx, args) => { const { user } = await requireUser(ctx); const pkg = await ctx.db.get(args.packageId); if (!pkg || pkg.softDeletedAt || pkg.family === "skill") throw new ConvexError("Package not found"); return requestPackageRescanForActor(ctx, { actor: user, pkg, version: args.version }); }, }); async function enqueueSkillVersionScan(ctx: MutationCtx, args: EnqueueSkillVersionScanArgs) { const version = await ctx.db.get(args.versionId); if (!version || version.softDeletedAt) return { ok: true as const, skipped: "missing" as const }; const now = Date.now(); const waitForVtUntil = now + Math.max(0, args.waitForVtMs ?? defaultVtWaitMs()); const nextRunAt = args.waitForVtMs === 0 || version.vtAnalysis ? now : waitForVtUntil; const hasMaliciousSignal = false; const existing = await ctx.db .query("securityScanJobs") .withIndex("by_skill_version", (q) => q.eq("skillVersionId", args.versionId)) .collect(); const active = existing.find((job) => job.status === "queued" || job.status === "running"); if (active) { if (args.preserveActiveJob) { return { ok: true as const, jobId: active._id, alreadyQueued: true as const }; } await ctx.db.patch(active._id, { source: higherPrioritySource(active.source, args.source), priority: Math.max(active.priority, args.priority ?? 0), hasMaliciousSignal, waitForVtUntil: Math.min(active.waitForVtUntil, waitForVtUntil), nextRunAt: Math.min(active.nextRunAt, nextRunAt), updatedAt: now, }); await requestSecurityScanDispatch(ctx); return { ok: true as const, jobId: active._id, alreadyQueued: true as const }; } const preservedExisting = args.preserveExistingJob ? existing .filter((job) => job.source === args.source) .sort((a, b) => b.updatedAt - a.updatedAt)[0] : undefined; if (preservedExisting) { return { ok: true as const, jobId: preservedExisting._id, alreadyQueued: true as const }; } const jobId = await ctx.db.insert("securityScanJobs", { targetKind: "skillVersion", skillVersionId: args.versionId, status: "queued", source: args.source, priority: args.priority ?? 0, hasMaliciousSignal, waitForVtUntil, nextRunAt, attempts: 0, createdAt: now, updatedAt: now, }); await requestSecurityScanDispatch(ctx); return { ok: true as const, jobId, alreadyQueued: false as const }; } export const enqueuePackageReleaseScanInternal = internalMutation({ args: { releaseId: v.id("packageReleases"), source: jobSourceValidator, priority: v.optional(v.number()), waitForVtMs: v.optional(v.number()), }, handler: async (ctx, args) => { return enqueuePackageReleaseScan(ctx, args); }, }); async function enqueuePackageReleaseScan(ctx: MutationCtx, args: EnqueuePackageReleaseScanArgs) { const release = await ctx.db.get(args.releaseId); if (!release || release.softDeletedAt) return { ok: true as const, skipped: "missing" as const }; const now = Date.now(); const waitForVtUntil = now + Math.max(0, args.waitForVtMs ?? DEFAULT_VT_WAIT_MS); const nextRunAt = args.waitForVtMs === 0 || release.vtAnalysis ? now : waitForVtUntil; const hasMaliciousSignal = false; const existing = await ctx.db .query("securityScanJobs") .withIndex("by_package_release", (q) => q.eq("packageReleaseId", args.releaseId)) .collect(); const active = existing.find((job) => job.status === "queued" || job.status === "running"); if (active) { await ctx.db.patch(active._id, { source: higherPrioritySource(active.source, args.source), priority: Math.max(active.priority, args.priority ?? 0), hasMaliciousSignal, waitForVtUntil: Math.min(active.waitForVtUntil, waitForVtUntil), nextRunAt: Math.min(active.nextRunAt, nextRunAt), updatedAt: now, }); await requestSecurityScanDispatch(ctx); return { ok: true as const, jobId: active._id, alreadyQueued: true as const }; } const jobId = await ctx.db.insert("securityScanJobs", { targetKind: "packageRelease", packageReleaseId: args.releaseId, status: "queued", source: args.source, priority: args.priority ?? 0, hasMaliciousSignal, waitForVtUntil, nextRunAt, attempts: 0, createdAt: now, updatedAt: now, }); await requestSecurityScanDispatch(ctx); return { ok: true as const, jobId, alreadyQueued: false as const }; } export const cancelQueuedVtUpdateJobsInternal = internalMutation({ args: { dryRun: v.boolean(), createdBefore: v.number(), scanLimit: v.optional(v.number()), deleteLimit: v.optional(v.number()), }, handler: async (ctx, args) => { const scanLimit = normalizeMaintenanceScanLimit(args.scanLimit); const deleteLimit = normalizeMaintenanceDeleteLimit(args.deleteLimit, scanLimit); const jobs = await ctx.db .query("securityScanJobs") .withIndex("by_status_source_created_at", (q) => q.eq("status", "queued").eq("source", "vt-update").lt("createdAt", args.createdBefore), ) .order("asc") .take(scanLimit); const skippedByReason: Partial> = {}; const sampleMatchedJobIds: string[] = []; const sampleDeletedJobIds: string[] = []; let matched = 0; let deleted = 0; for (const job of jobs) { if (job.status !== "queued") { incrementSkip( skippedByReason, job.source === "vt-update" ? "not-queued-vt-update" : "not-queued", ); continue; } if (job.source !== "vt-update") { incrementSkip(skippedByReason, "not-vt-update"); continue; } if (job.hasMaliciousSignal) { incrementSkip(skippedByReason, "malicious-signal"); continue; } const targetId = job.targetKind === "skillVersion" ? job.skillVersionId : job.packageReleaseId; if (!targetId) { incrementSkip(skippedByReason, "missing-target-id"); continue; } const target = await ctx.db.get(targetId); if (!target || target.softDeletedAt) { incrementSkip(skippedByReason, "missing-target"); continue; } const rawLlmStatus = target.llmAnalysis?.status?.trim(); if (!rawLlmStatus) { incrementSkip(skippedByReason, "missing-llm-analysis"); continue; } if (!finalLlmAnalysisStatuses.has(rawLlmStatus.toLowerCase())) { incrementSkip(skippedByReason, "non-final-llm-analysis"); continue; } // Emergency cleanup: source may have been overwritten by a VT update, but this // intentionally cancels old VT-origin work once ClawScan has a final result. matched += 1; if (sampleMatchedJobIds.length < CANCEL_SAMPLE_LIMIT) sampleMatchedJobIds.push(job._id); if (matched > deleteLimit) { incrementSkip(skippedByReason, "delete-limit-reached"); continue; } if (args.dryRun) continue; await ctx.db.delete(job._id); deleted += 1; if (sampleDeletedJobIds.length < CANCEL_SAMPLE_LIMIT) sampleDeletedJobIds.push(job._id); } const oldestScannedJob = jobs[0]; const newestScannedJob = jobs.at(-1); return { dryRun: args.dryRun, scanned: jobs.length, matched, wouldDelete: Math.min(matched, deleteLimit), deleted, skippedByReason, oldestScannedCreatedAt: oldestScannedJob?.createdAt ?? null, newestScannedCreatedAt: newestScannedJob?.createdAt ?? null, oldestScannedNextRunAt: oldestScannedJob?.nextRunAt ?? null, newestScannedNextRunAt: newestScannedJob?.nextRunAt ?? null, sampleMatchedJobIds, sampleDeletedJobIds, }; }, }); export const clearQueuedBackfillJobsForLocalDev = internalMutation({ args: { dryRun: v.optional(v.boolean()), limit: v.optional(v.number()), }, handler: async (ctx, args) => { const localDevEnabled = process.env.DEV_AUTH_ENABLED === "1" || process.env.SECURITY_SCAN_WORKER_TOKEN === "local-dev-worker-token"; if (!localDevEnabled) { throw new ConvexError("Refusing to clear backfill scan jobs outside local dev"); } const limit = Math.max(1, Math.min(args.limit ?? 1000, MAX_CANCEL_SCAN_LIMIT)); const jobs = await ctx.db .query("securityScanJobs") .withIndex("by_status_source_created_at", (q) => q.eq("status", "queued").eq("source", "backfill"), ) .order("asc") .take(limit); const sampleDeletedJobIds: string[] = []; if (!args.dryRun) { for (const job of jobs) { await ctx.db.delete(job._id); if (sampleDeletedJobIds.length < CANCEL_SAMPLE_LIMIT) sampleDeletedJobIds.push(job._id); } } return { dryRun: args.dryRun === true, matched: jobs.length, deleted: args.dryRun ? 0 : jobs.length, sampleDeletedJobIds, }; }, }); type ReadySourceJobsForClaimPage = { page: Doc<"securityScanJobs">[]; isDone: boolean; continueCursor: string; }; export async function listReadySourceJobsForClaimHandler( ctx: QueryCtx, args: { source: SecurityScanJobSource; now: number; cursor: string | null; numItems: number; excludeGitHubSkillSync: boolean; }, ): Promise { const query = ctx.db .query("securityScanJobs") .withIndex("by_status_source_next_run_at", (q) => q.eq("status", "queued").eq("source", args.source).lte("nextRunAt", args.now), ); const eligibleQuery = args.excludeGitHubSkillSync ? query.filter((q) => q.neq(q.field("rolloutGate"), "github-skill-sync")) : query; return await eligibleQuery.order("asc").paginate({ cursor: args.cursor, numItems: args.numItems, }); } export const listReadySourceJobsForClaimInternal = internalQuery({ args: { source: jobSourceValidator, now: v.number(), cursor: v.union(v.string(), v.null()), numItems: v.number(), excludeGitHubSkillSync: v.boolean(), }, handler: listReadySourceJobsForClaimHandler, }); export const claimQueuedJobsInternal = internalMutation({ args: { workerId: v.string(), lane: v.optional(codexScanWorkerLaneValidator), limit: v.number(), leaseMs: v.optional(v.number()), }, handler: async (ctx, args) => { const now = Date.now(); const limit = normalizeLimit(args.limit); const leaseMs = Math.max(60_000, Math.min(args.leaseMs ?? DEFAULT_LEASE_MS, 60 * 60 * 1000)); const capacity = limit; const ready: Doc<"securityScanJobs">[] = []; const claimedIds = new Set>(); const remainingCapacity = () => capacity - ready.length; let catalogGate: | { control: Doc<"skillsShCatalogControls">; health: Awaited>; } | null | undefined; const loadCatalogGate = async () => { if (catalogGate !== undefined) return catalogGate; const environment = getSkillsShFixtureEnvironmentPolicy(); if (!environment.allowed || environment.environment !== "test") { catalogGate = null; return catalogGate; } const control = await ctx.db .query("skillsShCatalogControls") .withIndex("by_key", (q) => q.eq("key", "global")) .unique(); catalogGate = control ? { control, health: await readCatalogClaimHealth(ctx, control), } : null; return catalogGate; }; const addReadyJobs = (jobs: Doc<"securityScanJobs">[], stopAtCapacity = true) => { for (const job of jobs) { if (stopAtCapacity && remainingCapacity() === 0) break; if (claimedIds.has(job._id) || job.nextRunAt > now) continue; claimedIds.add(job._id); ready.push(job); } }; const githubSkillSyncEnabled = getRuntimeRolloutCapabilities().githubSkillSync.runtimeEnabled; const isJobRolloutClaimable = async (job: Doc<"securityScanJobs">) => { if ( githubSkillSyncEnabled || job.targetKind !== "skillScanRequest" || !job.skillScanRequestId ) { return true; } const request = await ctx.db.get(job.skillScanRequestId); if (request?.sourceKind !== "github" || !request.githubSkillScanId) return true; const scan = await ctx.db.get(request.githubSkillScanId); return scan ? await isGitHubSkillScanAllowed(ctx, scan.githubSourceId) : false; }; const takeReadySourceJobs = async (source: SecurityScanJobSource) => { if (remainingCapacity() === 0) return []; let takeLimit = remainingCapacity(); if (source === "skills-sh-catalog-test") { const gate = await loadCatalogGate(); if ( !gate || gate.control.mode !== "staging-live" || gate.control.paused || !gate.control.scanAdmissionEnabled || !gate.health.claimable ) { return []; } // Scan a bounded window independent of the current admission cap so paused // or canceled jobs cannot hide later runnable backlog after the cap is lowered. takeLimit = MAX_CODEX_SCAN_CLAIM_LIMIT; } const eligible: Doc<"securityScanJobs">[] = []; let cursor: string | null = null; do { const page: ReadySourceJobsForClaimPage = await runQueryRef( ctx, internalRefs.securityScan.listReadySourceJobsForClaimInternal, { source, now, cursor, numItems: githubSkillSyncEnabled ? Math.min(takeLimit, MAX_CODEX_SCAN_CLAIM_LIMIT) : MAX_CODEX_SCAN_CLAIM_LIMIT, excludeGitHubSkillSync: !githubSkillSyncEnabled, }, ); for (const job of page.page) { if (await isJobRolloutClaimable(job)) eligible.push(job); if (eligible.length >= takeLimit) return eligible; } cursor = page.isDone ? null : page.continueCursor; } while (cursor); return eligible; }; if (args.lane === "catalog") { addReadyJobs(await takeReadySourceJobs("skills-sh-catalog-test"), false); } else { addReadyJobs(await takeReadySourceJobs("manual")); if (remainingCapacity() > 0) { addReadyJobs( await ctx.db .query("securityScanJobs") .withIndex("by_status_malicious_signal_next_run_at", (q) => q.eq("status", "queued").eq("hasMaliciousSignal", true).lte("nextRunAt", now), ) .order("asc") .take(remainingCapacity()), ); } // Shared workers remain work-conserving and may help priority work. The dedicated // priority lane never claims bulk sources, which guarantees reserved fast-path capacity. for (const source of CLAIM_SOURCE_ORDER) { addReadyJobs(await takeReadySourceJobs(source), source !== "skills-sh-catalog-test"); if (remainingCapacity() === 0) break; if (args.lane === "priority" && source === "publish") break; } } const claimed = []; let catalogClaims = 0; for (const selectedJob of ready) { if (claimed.length >= capacity) break; const job = await ctx.db.get(selectedJob._id); if ( !job || job.status !== "queued" || job.source !== selectedJob.source || job.nextRunAt > now ) { continue; } if (!(await isJobRolloutClaimable(job))) continue; let catalogAttemptId: Id<"skillsShCatalogScanAttempts"> | null = null; if (job.source === "skills-sh-catalog-test") { if (!job.skillScanRequestId) { continue; } const request = await ctx.db.get(job.skillScanRequestId); const attempt = request?.skillsShCatalogAttemptId ? await ctx.db.get(request.skillsShCatalogAttemptId) : null; const run = attempt ? await ctx.db.get(attempt.runId) : null; await loadCatalogGate(); const control = catalogGate?.control ?? null; const health = catalogGate?.health ?? null; if ( !request || request.sourceKind !== "skills-sh-catalog" || !attempt || attempt.skillScanRequestId !== request._id || attempt.securityScanJobId !== job._id || attempt.status !== "queued" || !run || run.status === "paused" || run.status === "canceling" || run.status === "canceled" || run.status === "failed" || !control || control.mode !== "staging-live" || control.paused || !control.scanAdmissionEnabled || !health?.claimable || health.catalogInFlight + catalogClaims >= control.maxCatalogInFlight ) { continue; } catalogAttemptId = attempt._id; } const leaseToken = crypto.randomUUID(); await ctx.db.patch(job._id, { status: "running", attempts: job.attempts + 1, leaseToken, leaseExpiresAt: now + leaseMs, workerId: args.workerId, lastError: undefined, updatedAt: now, }); if (job.targetKind === "skillScanRequest" && job.skillScanRequestId) { await ctx.db.patch(job.skillScanRequestId, { status: "running", lastError: undefined, updatedAt: now, }); } if (catalogAttemptId) { await ctx.db.patch(catalogAttemptId, { status: "running", updatedAt: now, }); catalogClaims += 1; } claimed.push({ ...job, status: "running" as const, attempts: job.attempts + 1, leaseToken, leaseExpiresAt: now + leaseMs, workerId: args.workerId, }); } return claimed; }, }); async function readCatalogClaimHealth(ctx: MutationCtx, control: Doc<"skillsShCatalogControls">) { const nativeSources = ["publish", "vt-update", "backfill", "bulk-rescan", "manual"] as const; const [nativeQueuedBySource, nativeRunningBySource, catalogQueued, catalogRunning] = await Promise.all([ Promise.all( nativeSources.map(async (source) => ctx.db .query("securityScanJobs") .withIndex("by_status_source_created_at", (q) => q.eq("status", "queued").eq("source", source), ) .take(control.maxNativeQueued + 1), ), ), Promise.all( nativeSources.map(async (source) => ctx.db .query("securityScanJobs") .withIndex("by_status_source_created_at", (q) => q.eq("status", "running").eq("source", source), ) .take(control.maxNativeInFlight + 1), ), ), ctx.db .query("skillsShCatalogScanAttempts") .withIndex("by_dispatch_kind_and_status_and_created_at", (q) => q.eq("dispatchKind", "real").eq("status", "queued"), ) .take(control.maxCatalogQueued + 1), ctx.db .query("skillsShCatalogScanAttempts") .withIndex("by_dispatch_kind_and_status_and_created_at", (q) => q.eq("dispatchKind", "real").eq("status", "running"), ) .take(control.maxCatalogInFlight + 1), ]); const nativeQueued = Math.min( control.maxNativeQueued + 1, nativeQueuedBySource.reduce((count, jobs) => count + jobs.length, 0), ); const nativeInFlight = Math.min( control.maxNativeInFlight + 1, nativeRunningBySource.reduce((count, jobs) => count + jobs.length, 0), ); return { nativeQueued, nativeInFlight, catalogQueued: catalogQueued.length, catalogInFlight: catalogRunning.length, // Queued depth is an admission limit, not a drain limit. Already admitted work // must remain claimable after an operator lowers maxCatalogQueued. claimable: nativeQueued <= control.maxNativeQueued && nativeInFlight <= control.maxNativeInFlight && catalogRunning.length <= control.maxCatalogInFlight, }; } type CatalogRetryDecision = | { kind: "not-catalog" } | { kind: "allowed"; attemptId: Id<"skillsShCatalogScanAttempts">; requestId: Id<"skillScanRequests">; } | { kind: "blocked"; request?: Doc<"skillScanRequests">; attempt?: Doc<"skillsShCatalogScanAttempts">; run?: Doc<"skillsShCatalogRuns">; }; async function prepareCatalogRetry( ctx: MutationCtx, job: Doc<"securityScanJobs">, now: number, retryAllowed = true, ): Promise { if (job.source !== "skills-sh-catalog-test") return { kind: "not-catalog" }; if (job.targetKind !== "skillScanRequest" || !job.skillScanRequestId) { return { kind: "blocked" }; } const request = await ctx.db.get(job.skillScanRequestId); if (!request || request.sourceKind !== "skills-sh-catalog" || !request.skillsShCatalogAttemptId) { return { kind: "blocked", ...(request ? { request } : {}) }; } const attempt = await ctx.db.get(request.skillsShCatalogAttemptId); if ( !attempt || attempt.skillScanRequestId !== request._id || attempt.securityScanJobId !== job._id ) { return { kind: "blocked", request }; } const run = await ctx.db.get(attempt.runId); if ( !retryAllowed || (attempt.status !== "queued" && attempt.status !== "running") || !run || run.status === "canceling" || run.status === "canceled" || run.status === "failed" ) { return { kind: "blocked", request, attempt, ...(run ? { run } : {}) }; } await ctx.db.patch(attempt._id, { status: "queued", updatedAt: now, }); return { kind: "allowed", attemptId: attempt._id, requestId: request._id, }; } async function terminalizeBlockedCatalogRetry( ctx: MutationCtx, decision: Extract, now: number, ) { const attempt = decision.attempt; if (!attempt || (attempt.status !== "queued" && attempt.status !== "running")) return; const canceled = decision.run?.status === "canceling" || decision.run?.status === "canceled"; await ctx.db.patch(attempt._id, { status: canceled ? "canceled" : "failed", verdict: canceled ? undefined : "failed", completedAt: now, updatedAt: now, }); const entry = await ctx.db.get(attempt.entryId); const entryShouldBeTerminalized = entry?.sourceContentHash === attempt.sourceContentHash && (entry.scanStatus === "planned" || entry.scanStatus === "queued"); if (entryShouldBeTerminalized) { await ctx.db.patch(entry._id, { scanStatus: canceled ? "canceled" : "failed", publicVisible: false, updatedAt: now, }); } if (decision.run) { await ctx.db.patch(decision.run._id, { counts: { ...decision.run.counts, scansCompleted: decision.run.counts.scansCompleted + (canceled ? 0 : 1), scansCanceled: decision.run.counts.scansCanceled + (canceled ? 1 : 0), }, operations: { ...decision.run.operations, functionCalls: decision.run.operations.functionCalls + 1, dbReads: decision.run.operations.dbReads + 4, dbWrites: decision.run.operations.dbWrites + (entryShouldBeTerminalized ? 3 : 2), }, updatedAt: now, }); } } export const requeueExpiredCodexScanJobsInternal = internalMutation({ args: { limit: v.optional(v.number()), }, handler: async (ctx, args) => { const now = Date.now(); const jobs = await ctx.db .query("securityScanJobs") .withIndex("by_status_and_lease_expires_at", (q) => q.eq("status", "running").lte("leaseExpiresAt", now), ) .take( Math.max( 1, Math.min(args.limit ?? MAX_EXPIRED_CODEX_SCAN_LEASE_REQUEUES, MAX_CODEX_SCAN_CLAIM_LIMIT), ), ); let requeued = 0; for (const job of jobs) { const catalogRetry = await prepareCatalogRetry(ctx, job, now); if (catalogRetry.kind === "blocked") { await terminalizeBlockedCatalogRetry(ctx, catalogRetry, now); await ctx.db.patch(job._id, { status: "failed", lastError: "Catalog scan retry blocked by inactive or invalid linkage", completedAt: now, leaseToken: undefined, leaseExpiresAt: undefined, workerId: undefined, updatedAt: now, }); if (catalogRetry.request) { await ctx.db.patch(catalogRetry.request._id, { status: "failed", lastError: "Catalog scan retry blocked by inactive or invalid linkage", completedAt: now, updatedAt: now, }); } continue; } await ctx.db.patch(job._id, { status: "queued", leaseToken: undefined, leaseExpiresAt: undefined, workerId: undefined, nextRunAt: now, updatedAt: now, }); if (catalogRetry.kind === "allowed") { await ctx.db.patch(catalogRetry.requestId, { status: "queued", lastError: undefined, completedAt: undefined, updatedAt: now, }); } requeued += 1; } if (requeued > 0) await requestSecurityScanDispatch(ctx); return { requeued }; }, }); export const requeueFailedSecurityScanJobsInternal = internalMutation({ args: { failedAfter: v.number(), failedBefore: v.number(), dryRun: v.boolean(), limit: v.optional(v.number()), }, handler: async (ctx, args) => { if (args.failedBefore <= args.failedAfter) { throw new ConvexError("failedBefore must be greater than failedAfter"); } const limit = Math.max( 1, Math.min( Math.floor(args.limit ?? DEFAULT_FAILED_SCAN_RECOVERY_LIMIT), MAX_FAILED_SCAN_RECOVERY_LIMIT, ), ); const jobs = await ctx.db .query("securityScanJobs") .withIndex("by_status_and_updated_at", (q) => q .eq("status", "failed") .gte("updatedAt", args.failedAfter) .lt("updatedAt", args.failedBefore), ) .filter((q) => q.neq(q.field("source"), "skills-sh-catalog-test")) .order("asc") .take(limit + 1); const matched = jobs.slice(0, limit); const bySource: Partial> = {}; const byTargetKind: Partial["targetKind"], number>> = {}; let requeued = 0; for (const job of matched) { bySource[job.source] = (bySource[job.source] ?? 0) + 1; byTargetKind[job.targetKind] = (byTargetKind[job.targetKind] ?? 0) + 1; if (args.dryRun) continue; const now = Date.now(); await ctx.db.patch(job._id, { status: "queued", attempts: 0, lastError: undefined, runId: undefined, completedAt: undefined, leaseToken: undefined, leaseExpiresAt: undefined, workerId: undefined, nextRunAt: now, updatedAt: now, }); if (job.targetKind === "skillScanRequest" && job.skillScanRequestId) { const request = await ctx.db.get(job.skillScanRequestId); await ctx.db.patch(job.skillScanRequestId, { status: "queued", lastError: undefined, completedAt: undefined, updatedAt: now, }); if (request?.githubSkillScanId) { const scan = await ctx.db.get(request.githubSkillScanId); if (scan) { await ctx.db.patch(scan._id, { status: "pending", skillSpectorAnalysis: undefined, llmAnalysis: undefined, lastError: undefined, runId: undefined, completedAt: undefined, updatedAt: now, }); await applyGitHubSkillVerificationResultHandler(ctx, { skillId: scan.skillId, contentHash: scan.contentHash, scanStatus: "pending", now, }); } } } requeued += 1; } if (!args.dryRun && requeued > 0) await requestSecurityScanDispatch(ctx); return { dryRun: args.dryRun, matched: matched.length, requeued: args.dryRun ? 0 : requeued, hasMore: jobs.length > limit, bySource, byTargetKind, sampleJobIds: matched.slice(0, FAILED_SCAN_RECOVERY_SAMPLE_LIMIT).map((job) => job._id), }; }, }); export const getJobTargetInternal = internalQuery({ args: { jobId: v.id("securityScanJobs"), }, handler: async (ctx, args) => { const job = await ctx.db.get(args.jobId); if (!job) return null; if (job.targetKind === "skillVersion" && job.skillVersionId) { const version = await ctx.db.get(job.skillVersionId); if (!version || version.softDeletedAt) return { job, missing: true as const }; const skill = await ctx.db.get(version.skillId); return { job, skill, version }; } if (job.targetKind === "packageRelease" && job.packageReleaseId) { const release = await ctx.db.get(job.packageReleaseId); if (!release || release.softDeletedAt) return { job, missing: true as const }; const pkg = await ctx.db.get(release.packageId); const ownerPublisher = pkg?.ownerPublisherId ? await ctx.db.get(pkg.ownerPublisherId) : null; return { job, package: pkg, release, trustedOpenClawPlugin: isOpenClawPluginPackage(pkg, ownerPublisher), }; } if (job.targetKind === "skillScanRequest" && job.skillScanRequestId) { const scanRequest = await ctx.db.get(job.skillScanRequestId); if (!scanRequest) return { job, missing: true as const }; const version = scanRequest.skillVersionId ? await ctx.db.get(scanRequest.skillVersionId) : null; const skill = scanRequest.skillId ? await ctx.db.get(scanRequest.skillId) : null; 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 }; }, }); export const succeedJobInternal = internalMutation({ args: { jobId: v.id("securityScanJobs"), leaseToken: v.string(), runId: v.optional(v.string()), }, handler: async (ctx, args) => { const job = await ctx.db.get(args.jobId); if (!job || job.leaseToken !== args.leaseToken) throw new ConvexError("Lease mismatch"); const now = Date.now(); await ctx.db.patch(args.jobId, { status: "succeeded", runId: args.runId, completedAt: now, leaseToken: undefined, leaseExpiresAt: undefined, updatedAt: now, }); return { ok: true as const }; }, }); export const failJobInternal = internalMutation({ args: { jobId: v.id("securityScanJobs"), leaseToken: v.string(), error: v.string(), }, handler: async (ctx, args) => { const job = await ctx.db.get(args.jobId); if (!job || job.leaseToken !== args.leaseToken) throw new ConvexError("Lease mismatch"); const now = Date.now(); const catalogRetry = await prepareCatalogRetry(ctx, job, now, job.attempts < MAX_ATTEMPTS); const retry = job.attempts < MAX_ATTEMPTS && catalogRetry.kind !== "blocked"; const error = sanitizeWorkerErrorDetail(args.error, 2000); if (catalogRetry.kind === "blocked") { await terminalizeBlockedCatalogRetry(ctx, catalogRetry, now); } await ctx.db.patch(args.jobId, { status: retry ? "queued" : "failed", lastError: error, nextRunAt: retry ? now + Math.min(30 * 60 * 1000, 2 ** job.attempts * 60_000) : job.nextRunAt, leaseToken: undefined, leaseExpiresAt: undefined, workerId: undefined, updatedAt: now, }); if (job.targetKind === "skillScanRequest" && job.skillScanRequestId) { await ctx.db.patch(job.skillScanRequestId, { status: retry ? "queued" : "failed", lastError: error, ...(retry ? {} : { completedAt: now }), updatedAt: now, }); } return { ok: true as const, retry }; }, }); export const requeueJobLeaseInternal = internalMutation({ args: { jobId: v.id("securityScanJobs"), leaseToken: v.string(), workerId: v.string(), }, handler: async (ctx, args) => { const job = await ctx.db.get(args.jobId); if ( !job || job.status !== "running" || job.leaseToken !== args.leaseToken || job.workerId !== args.workerId ) { throw new ConvexError("Lease mismatch"); } const now = Date.now(); const catalogRetry = await prepareCatalogRetry(ctx, job, now); const retry = catalogRetry.kind !== "blocked"; if (catalogRetry.kind === "blocked") { await terminalizeBlockedCatalogRetry(ctx, catalogRetry, now); } await ctx.db.patch(job._id, { status: retry ? "queued" : "failed", attempts: retry ? Math.max(0, job.attempts - 1) : job.attempts, ...(retry ? {} : { lastError: "Catalog scan retry blocked by inactive or invalid linkage", completedAt: now, }), leaseToken: undefined, leaseExpiresAt: undefined, workerId: undefined, nextRunAt: retry ? now + 60_000 : job.nextRunAt, updatedAt: now, }); if (job.targetKind === "skillScanRequest" && job.skillScanRequestId) { await ctx.db.patch(job.skillScanRequestId, { status: retry ? "queued" : "failed", ...(retry ? {} : { lastError: "Catalog scan retry blocked by inactive or invalid linkage", completedAt: now, }), updatedAt: now, }); } if (retry) await requestSecurityScanDispatch(ctx); return { ok: true as const, nextRunAt: now + 60_000 }; }, }); type CodexScanHydrationCtx = { runMutation: (ref: never, args: never) => Promise; runQuery: (ref: never, args: never) => Promise; storage: { getUrl: (storageId: Id<"_storage">) => Promise; }; }; async function hydrateClaimedCodexScanJob( ctx: CodexScanHydrationCtx, job: Doc<"securityScanJobs"> & { leaseToken: string }, target: Record | null, ) { if (!target || target.missing) { await runMutationRef(ctx, internalRefs.securityScan.failJobInternal, { jobId: job._id, leaseToken: job.leaseToken, error: "Target artifact missing", }); return null; } const scanRequest = target.scanRequest as Doc<"skillScanRequests"> | undefined; const version = target.version as Doc<"skillVersions"> | undefined; const release = target.release as Doc<"packageReleases"> | undefined; let files: Array<{ path: string; size: number; sha256: string; storageId: Id<"_storage">; contentType?: string; }> = []; if (scanRequest) { files = (target.scanRequestFiles as Doc<"skillScanRequests">["files"] | undefined) ?? scanRequest.files; } else if (version) { const fingerprintEntries = await runQueryRef< Array<{ fingerprint: string; kind?: "source" | "generated-bundle" }> >(ctx, internalRefs.skills.listVersionFingerprintsInternal, { skillVersionId: version._id, }); files = sourceSkillVersionFiles(version.files, { generatedBundleFingerprints: fingerprintEntries .filter((entry) => entry.kind === "generated-bundle") .map((entry) => entry.fingerprint), }); } else if (release) { files = release.files; } const fileUrls = []; for (const file of files) { const url = await ctx.storage.getUrl(file.storageId); if (!url) { await runMutationRef(ctx, internalRefs.securityScan.failJobInternal, { jobId: job._id, leaseToken: job.leaseToken, error: `Artifact file unavailable: ${file.path}`, }); return null; } fileUrls.push({ path: file.path, size: file.size, sha256: file.sha256, contentType: file.contentType, url, }); } const clawpackUrl = release?.clawpackStorageId ? await ctx.storage.getUrl(release.clawpackStorageId) : null; if (release?.clawpackStorageId && !clawpackUrl) { await runMutationRef(ctx, internalRefs.securityScan.failJobInternal, { jobId: job._id, leaseToken: job.leaseToken, error: "ClawPack artifact unavailable", }); return null; } return { job, target: { ...target, files: fileUrls, clawpackUrl, }, }; } export const claimCodexScanJobs = action({ args: { token: v.string(), workerId: v.string(), limit: v.optional(v.number()), leaseMs: v.optional(v.number()), }, handler: async (ctx, args) => { assertWorkerToken(args.token); const jobs = await runMutationRef & { leaseToken: string }>>( ctx, internalRefs.securityScan.claimQueuedJobsInternal, { workerId: args.workerId, // 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, }, ); const hydrated = []; for (const job of jobs) { const target = await runQueryRef | null>( ctx, internalRefs.securityScan.getJobTargetInternal, { jobId: job._id }, ); const claimedJob = await hydrateClaimedCodexScanJob(ctx, job, target); if (claimedJob) hydrated.push(claimedJob); } return hydrated; }, }); export const claimCodexScanJobLeases = action({ args: { token: v.string(), workerId: v.string(), lane: v.optional(codexScanWorkerLaneValidator), limit: v.optional(v.number()), leaseMs: v.optional(v.number()), }, handler: async (ctx, args) => { assertWorkerToken(args.token); return await runMutationRef & { leaseToken: string }>>( ctx, internalRefs.securityScan.claimQueuedJobsInternal, { workerId: args.workerId, lane: args.lane ?? "shared", limit: normalizeLimit(args.limit), leaseMs: args.leaseMs, }, ); }, }); export const hydrateCodexScanJob = action({ args: { token: v.string(), workerId: v.string(), jobId: v.id("securityScanJobs"), leaseToken: v.string(), }, handler: async (ctx, args) => { assertWorkerToken(args.token); const target = await runQueryRef | null>( ctx, internalRefs.securityScan.getJobTargetInternal, { jobId: args.jobId }, ); const job = target?.job as Doc<"securityScanJobs"> | undefined; if ( !job || job.status !== "running" || job.leaseToken !== args.leaseToken || job.workerId !== args.workerId ) { throw new ConvexError("Lease mismatch"); } return hydrateClaimedCodexScanJob( ctx, job as Doc<"securityScanJobs"> & { leaseToken: string }, target, ); }, }); export const requeueCodexScanJobLease = action({ args: { token: v.string(), workerId: v.string(), jobId: v.id("securityScanJobs"), leaseToken: v.string(), }, handler: async (ctx, args) => { assertWorkerToken(args.token); return await runMutationRef<{ ok: true; nextRunAt: number }>( ctx, internalRefs.securityScan.requeueJobLeaseInternal, { workerId: args.workerId, jobId: args.jobId, leaseToken: args.leaseToken, }, ); }, }); export const completeCodexScanJob = action({ args: { token: v.string(), jobId: v.id("securityScanJobs"), leaseToken: v.string(), llmAnalysis: llmAnalysisValidator, skillSpectorAnalysis: v.optional(skillSpectorAnalysisValidator), runId: v.optional(v.string()), }, handler: async (ctx, args) => { assertWorkerToken(args.token); const target = await runQueryRef( ctx, internalRefs.securityScan.getJobTargetInternal, { jobId: args.jobId, }, ); if (!target) throw new ConvexError("Job not found"); const isCatalogScanRequest = target.job.targetKind === "skillScanRequest" && target.scanRequest?.sourceKind === "skills-sh-catalog" && Boolean(target.scanRequest.skillsShCatalogAttemptId); if (!isCatalogScanRequest && target.job.leaseToken !== args.leaseToken) { throw new ConvexError("Lease mismatch"); } if (target.job.targetKind === "skillVersion" && target.version) { if (args.skillSpectorAnalysis) { await runMutationRef(ctx, internalRefs.skills.updateVersionSkillSpectorAnalysisInternal, { versionId: target.version._id, skillSpectorAnalysis: capSkillSpectorAnalysisForStorage(args.skillSpectorAnalysis), }); } await runMutationRef(ctx, internalRefs.skills.updateVersionLlmAnalysisInternal, { versionId: target.version._id, llmAnalysis: args.llmAnalysis, }); } else if (target.job.targetKind === "packageRelease" && target.release) { await runMutationRef(ctx, internalRefs.packages.updateReleaseSkillSpectorAnalysisInternal, { releaseId: target.release._id, ...(args.skillSpectorAnalysis ? { skillSpectorAnalysis: capSkillSpectorAnalysisForStorage(args.skillSpectorAnalysis) } : {}), }); await runMutationRef(ctx, internalRefs.packages.updateReleaseLlmAnalysisInternal, { releaseId: target.release._id, llmAnalysis: args.llmAnalysis, }); } else if (target.job.targetKind === "skillScanRequest" && target.scanRequest) { let writtenBack = false; if ( target.scanRequest.sourceKind === "published" && target.scanRequest.update && target.version ) { if (args.skillSpectorAnalysis) { await runMutationRef(ctx, internalRefs.skills.updateVersionSkillSpectorAnalysisInternal, { versionId: target.version._id, skillSpectorAnalysis: capSkillSpectorAnalysisForStorage(args.skillSpectorAnalysis), }); } await runMutationRef(ctx, internalRefs.skills.updateVersionLlmAnalysisInternal, { versionId: target.version._id, llmAnalysis: args.llmAnalysis, }); 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; } if ( target.scanRequest.sourceKind === "skills-sh-catalog" && target.scanRequest.skillsShCatalogAttemptId ) { const result = await runMutationRef<{ ok: true }>( ctx, internalRefs.securityScan.completeCatalogSkillScanJobInternal, { attemptId: target.scanRequest.skillsShCatalogAttemptId, scanId: target.scanRequest._id, jobId: args.jobId, leaseToken: args.leaseToken, artifactContentHash: target.scanRequest.sha256hash ?? "", verdict: githubSkillScanStatusFromLlmAnalysis(args.llmAnalysis), runId: args.runId, llmAnalysis: args.llmAnalysis, skillSpectorAnalysis, }, ); try { await runMutationRef( ctx, internalRefs.securityScanDispatch.requestSecurityScanDispatchInternal, {}, ); } catch { console.warn("security scan dispatch request failed after catalog completion"); } return result; } await runMutationRef(ctx, internalRefs.securityScan.recordSkillScanRequestSucceededInternal, { scanId: target.scanRequest._id, jobId: args.jobId, runId: args.runId, llmAnalysis: args.llmAnalysis, skillSpectorAnalysis, writtenBack, }); } else { throw new ConvexError("Unsupported security scan target"); } const result = await runMutationRef<{ ok: true }>( ctx, internalRefs.securityScan.succeedJobInternal, { jobId: args.jobId, leaseToken: args.leaseToken, runId: args.runId, }, ); await runMutationRef( ctx, internalRefs.securityScanDispatch.requestSecurityScanDispatchInternal, {}, ); return result; }, }); export const failCodexScanJob = action({ args: { token: v.string(), jobId: v.id("securityScanJobs"), leaseToken: v.string(), error: v.string(), }, handler: async (ctx, args) => { assertWorkerToken(args.token); const error = sanitizeWorkerErrorDetail(args.error, 2000); const result = await runMutationRef<{ ok: true; retry: boolean }>( ctx, internalRefs.securityScan.failJobInternal, { jobId: args.jobId, leaseToken: args.leaseToken, error, }, ); if (!result.retry) { const target = await runQueryRef( ctx, internalRefs.securityScan.getJobTargetInternal, { jobId: args.jobId, }, ); if (target && !target.missing) { const llmAnalysis = buildWorkerFailureLlmAnalysis(error); if (target.job.targetKind === "skillVersion" && target.version) { if (!hasArtifactBackedLlmAnalysis(target.version.llmAnalysis)) { await runMutationRef(ctx, internalRefs.skills.updateVersionLlmAnalysisInternal, { versionId: target.version._id, moderationMode: "preserve", llmAnalysis, }); } } else if (target.job.targetKind === "packageRelease" && target.release) { if (!hasArtifactBackedLlmAnalysis(target.release.llmAnalysis)) { await runMutationRef(ctx, internalRefs.packages.updateReleaseLlmAnalysisInternal, { releaseId: target.release._id, llmAnalysis, }); } } 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, llmAnalysis, }, ); } await runMutationRef( ctx, internalRefs.securityScan.recordSkillScanRequestFailedInternal, { scanId: target.scanRequest._id, error, llmAnalysis, }, ); } } } return result; }, });