diff --git a/CHANGELOG.md b/CHANGELOG.md index b1f6dd2654..3dc8e1788a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ - Users: sync handle on ensure when GitHub login changes (#293) (thanks @christianhpoe). - Users/Auth: throttle GitHub profile sync on login; also sync avatar when it changes (#312) (thanks @ianalloway). - Upload gate: fetch GitHub account age by immutable account ID (prevents username swaps) (#116) (thanks @mkrokosz). +- VT fallback: activate only VT-pending hidden skills when scans are unavailable/stale; keep quality/scanner-blocked skills hidden (#300) (thanks @superlowburn). - API: return proper status codes for delete/undelete errors (#35) (thanks @sergical). - API: for owners, return clearer status/messages for hidden/soft-deleted skills instead of a generic 404. - Web: allow copying OpenClaw scan summary text (thanks @borisolver, #322). diff --git a/convex/lib/public.test.ts b/convex/lib/public.test.ts index 7c5fe9050c..52b519cdb7 100644 --- a/convex/lib/public.test.ts +++ b/convex/lib/public.test.ts @@ -65,4 +65,32 @@ describe('public skill mapping', () => { comments: 0, }) }) + + it('returns skill when moderationStatus is active', () => { + const skill = makeSkill({ moderationStatus: 'active' }) + expect(toPublicSkill(skill)).not.toBeNull() + }) + + it('filters out skill when moderationStatus is hidden', () => { + const skill = makeSkill({ moderationStatus: 'hidden' }) + expect(toPublicSkill(skill)).toBeNull() + }) + + it('returns skill when moderationStatus is undefined (legacy)', () => { + const skill = makeSkill({ moderationStatus: undefined as unknown as string }) + expect(toPublicSkill(skill)).not.toBeNull() + }) + + it('filters out soft-deleted skills', () => { + const skill = makeSkill({ softDeletedAt: Date.now() }) + expect(toPublicSkill(skill)).toBeNull() + }) + + it('filters out skills with blocked.malware flag', () => { + const skill = makeSkill({ + moderationStatus: 'active', + moderationFlags: ['blocked.malware'], + }) + expect(toPublicSkill(skill)).toBeNull() + }) }) diff --git a/convex/vt.test.ts b/convex/vt.test.ts new file mode 100644 index 0000000000..83944981c4 --- /dev/null +++ b/convex/vt.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { __test } from './vt' + +describe('vt activation fallback', () => { + it('activates only VT-pending hidden skills', () => { + expect( + __test.shouldActivateWhenVtUnavailable({ + moderationStatus: 'hidden', + moderationReason: 'pending.scan', + }), + ).toBe(true) + + expect( + __test.shouldActivateWhenVtUnavailable({ + moderationStatus: 'hidden', + moderationReason: 'scanner.vt.pending', + }), + ).toBe(true) + + expect( + __test.shouldActivateWhenVtUnavailable({ + moderationStatus: 'hidden', + moderationReason: 'pending.scan.stale', + }), + ).toBe(true) + }) + + it('does not activate quality or scanner-hidden skills', () => { + expect( + __test.shouldActivateWhenVtUnavailable({ + moderationStatus: 'hidden', + moderationReason: 'quality.low', + }), + ).toBe(false) + + expect( + __test.shouldActivateWhenVtUnavailable({ + moderationStatus: 'hidden', + moderationReason: 'scanner.llm.malicious', + }), + ).toBe(false) + }) + + it('does not activate blocked or already-active skills', () => { + expect( + __test.shouldActivateWhenVtUnavailable({ + moderationStatus: 'hidden', + moderationReason: 'pending.scan', + moderationFlags: ['blocked.malware'], + }), + ).toBe(false) + + expect( + __test.shouldActivateWhenVtUnavailable({ + moderationStatus: 'active', + moderationReason: 'pending.scan', + }), + ).toBe(false) + }) +}) diff --git a/convex/vt.ts b/convex/vt.ts index 7f236534d4..bc3dab73f5 100644 --- a/convex/vt.ts +++ b/convex/vt.ts @@ -1,6 +1,7 @@ import { v } from 'convex/values' import { internal } from './_generated/api' import type { Id } from './_generated/dataModel' +import type { ActionCtx } from './_generated/server' import { action, internalAction, internalMutation } from './_generated/server' import { buildDeterministicZip } from './lib/skillZip' @@ -136,6 +137,13 @@ type PendingScanSkill = { checkCount: number } +type SkillActivationCandidate = { + moderationStatus?: string + moderationReason?: string + moderationFlags?: string[] + softDeletedAt?: number +} + type PollPendingScansResult = { processed: number updated: number @@ -228,6 +236,23 @@ type SyncModerationReasonsResult = { done: boolean } +const VT_PENDING_REASONS = new Set(['pending.scan', 'scanner.vt.pending', 'pending.scan.stale']) + +function shouldActivateWhenVtUnavailable(skill: SkillActivationCandidate | null | undefined) { + if (!skill || skill.softDeletedAt) return false + if (skill.moderationFlags?.includes('blocked.malware')) return false + if (skill.moderationStatus === 'active') return false + const reason = skill.moderationReason + return typeof reason === 'string' && VT_PENDING_REASONS.has(reason) +} + +async function activateSkillWhenVtUnavailable(ctx: ActionCtx, skillId: Id<'skills'>) { + const skill = await ctx.runQuery(internal.skills.getSkillByIdInternal, { skillId }) + if (!shouldActivateWhenVtUnavailable(skill)) return + + await ctx.runMutation(internal.skills.setSkillModerationStatusActiveInternal, { skillId }) +} + export const fetchResults = action({ args: { sha256hash: v.optional(v.string()), @@ -305,7 +330,13 @@ export const scanWithVirusTotal = internalAction({ handler: async (ctx, args) => { const apiKey = process.env.VT_API_KEY if (!apiKey) { - console.log('VT_API_KEY not configured, skipping scan') + console.log('VT_API_KEY not configured, skipping scan — activating skill') + const version = await ctx.runQuery(internal.skills.getVersionByIdInternal, { + versionId: args.versionId, + }) + if (version) { + await activateSkillWhenVtUnavailable(ctx, version.skillId) + } return } @@ -524,6 +555,7 @@ export const pollPendingScans = internalAction({ versionId, vtAnalysis: { status: 'stale', checkedAt: Date.now() }, }) + await activateSkillWhenVtUnavailable(ctx, skillId) staled++ } continue @@ -549,6 +581,7 @@ export const pollPendingScans = internalAction({ versionId, vtAnalysis: { status: 'stale', checkedAt: Date.now() }, }) + await activateSkillWhenVtUnavailable(ctx, skillId) staled++ } continue @@ -650,6 +683,10 @@ async function requestRescan(apiKey: string, sha256hash: string): Promise