mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
4013 lines
128 KiB
TypeScript
4013 lines
128 KiB
TypeScript
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_TARGETED_TEST_GITHUB_SYNC_JOBS = 32;
|
|
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<MutationCtx, "db">,
|
|
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<MutationCtx, "db">,
|
|
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: Pick<QueryCtx | MutationCtx, "db">,
|
|
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<SecurityScanJobSource, number> = {
|
|
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<T>(
|
|
ctx: { runQuery: (ref: never, args: never) => Promise<unknown> },
|
|
ref: unknown,
|
|
args: unknown,
|
|
): Promise<T> {
|
|
return (await ctx.runQuery(ref as never, args as never)) as T;
|
|
}
|
|
|
|
async function runMutationRef<T>(
|
|
ctx: { runMutation: (ref: never, args: never) => Promise<unknown> },
|
|
ref: unknown,
|
|
args: unknown,
|
|
): Promise<T> {
|
|
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<NonNullable<Doc<"skillVersions">["llmAnalysis"]>, "status" | "verdict">,
|
|
) {
|
|
const status = normalizePackageScanStatus(analysis.verdict ?? analysis.status);
|
|
if (status === "clean" || status === "suspicious" || status === "malicious") return status;
|
|
return "failed" as const;
|
|
}
|
|
|
|
function 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<Record<CancelSkipReason, number>>,
|
|
reason: CancelSkipReason,
|
|
) {
|
|
skippedByReason[reason] = (skippedByReason[reason] ?? 0) + 1;
|
|
}
|
|
|
|
function isOpenClawPluginPackage(
|
|
pkg: Doc<"packages"> | null | undefined,
|
|
ownerPublisher: Pick<Doc<"publishers">, "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<CodexScanQueueHealth> => {
|
|
const snapshot = await runQueryRef<CodexScanQueueHealth>(
|
|
ctx,
|
|
internalRefs.securityScan.getCodexScanQueueHealthInternal,
|
|
{},
|
|
);
|
|
logEvent(Events.SecurityScanQueueSnapshot, snapshot);
|
|
return snapshot;
|
|
},
|
|
});
|
|
|
|
export const getCodexScanQueueHealth = action({
|
|
args: {
|
|
token: v.string(),
|
|
},
|
|
handler: async (ctx, args): Promise<CodexScanQueueHealth> => {
|
|
assertWorkerToken(args.token);
|
|
return await runQueryRef<CodexScanQueueHealth>(
|
|
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<MutationCtx, "db">,
|
|
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(),
|
|
ownerHandle: v.optional(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 skill = await resolveSkillForRescan(ctx, args.slug, args.ownerHandle);
|
|
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(),
|
|
ownerHandle: v.optional(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,
|
|
ownerHandle: args.ownerHandle,
|
|
version: versionLabel,
|
|
});
|
|
},
|
|
});
|
|
|
|
async function getStoredSkillScanReportForUser(
|
|
ctx: QueryCtx,
|
|
args: {
|
|
actor: Doc<"users">;
|
|
kind: StoredScanArtifactKind;
|
|
name: string;
|
|
ownerHandle?: string;
|
|
version: string;
|
|
},
|
|
) {
|
|
const skill = await resolveSkillForRescan(ctx, args.name, args.ownerHandle);
|
|
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,
|
|
githubSkillScanId: scan._id,
|
|
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<Record<CancelSkipReason, number>> = {};
|
|
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<ReadySourceJobsForClaimPage> {
|
|
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()),
|
|
targetedJobIds: v.optional(v.array(v.id("securityScanJobs"))),
|
|
},
|
|
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<Id<"securityScanJobs">>();
|
|
const remainingCapacity = () => capacity - ready.length;
|
|
let catalogGate:
|
|
| {
|
|
control: Doc<"skillsShCatalogControls">;
|
|
health: Awaited<ReturnType<typeof readCatalogClaimHealth>>;
|
|
}
|
|
| 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<ReadySourceJobsForClaimPage>(
|
|
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;
|
|
};
|
|
|
|
const targetedJobIds = args.targetedJobIds;
|
|
if (targetedJobIds !== undefined) {
|
|
const rollout = getRuntimeRolloutCapabilities();
|
|
if (
|
|
rollout.environment !== "test" ||
|
|
rollout.githubSkillSync.mode !== "test" ||
|
|
!rollout.githubSkillSync.runtimeEnabled
|
|
) {
|
|
throw new ConvexError("Exact GitHub Skill Sync job claims are Test-only");
|
|
}
|
|
if (targetedJobIds.length > MAX_TARGETED_TEST_GITHUB_SYNC_JOBS) {
|
|
throw new ConvexError("Too many exact GitHub Skill Sync jobs requested");
|
|
}
|
|
const targetedJobs: Doc<"securityScanJobs">[] = [];
|
|
for (const jobId of new Set(targetedJobIds)) {
|
|
const job = await ctx.db.get(jobId);
|
|
if (
|
|
job?.status === "queued" &&
|
|
job.rolloutGate === "github-skill-sync" &&
|
|
job.nextRunAt <= now
|
|
) {
|
|
targetedJobs.push(job);
|
|
}
|
|
}
|
|
addReadyJobs(targetedJobs);
|
|
} else 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<CatalogRetryDecision> {
|
|
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<CatalogRetryDecision, { kind: "blocked" }>,
|
|
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<Record<SecurityScanJobSource, number>> = {};
|
|
const byTargetKind: Partial<Record<Doc<"securityScanJobs">["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,
|
|
githubSkillScanId: scan._id,
|
|
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<unknown>;
|
|
runQuery: (ref: never, args: never) => Promise<unknown>;
|
|
storage: {
|
|
getUrl: (storageId: Id<"_storage">) => Promise<string | null>;
|
|
};
|
|
};
|
|
|
|
async function hydrateClaimedCodexScanJob(
|
|
ctx: CodexScanHydrationCtx,
|
|
job: Doc<"securityScanJobs"> & { leaseToken: string },
|
|
target: Record<string, unknown> | 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<Array<Doc<"securityScanJobs"> & { 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<Record<string, unknown> | 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()),
|
|
targetedJobIds: v.optional(v.array(v.id("securityScanJobs"))),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
assertWorkerToken(args.token);
|
|
return await runMutationRef<Array<Doc<"securityScanJobs"> & { leaseToken: string }>>(
|
|
ctx,
|
|
internalRefs.securityScan.claimQueuedJobsInternal,
|
|
{
|
|
workerId: args.workerId,
|
|
lane: args.lane ?? "shared",
|
|
limit: normalizeLimit(args.limit),
|
|
leaseMs: args.leaseMs,
|
|
targetedJobIds: args.targetedJobIds,
|
|
},
|
|
);
|
|
},
|
|
});
|
|
|
|
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<Record<string, unknown> | 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<JobTarget | null>(
|
|
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<JobTarget | null>(
|
|
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;
|
|
},
|
|
});
|