diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 8a1874c4..b96f7f8e 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -140,6 +140,7 @@ import type * as packagePublishTokens from "../packagePublishTokens.js"; import type * as packages from "../packages.js"; import type * as promotions from "../promotions.js"; import type * as promotionsFeed from "../promotionsFeed.js"; +import type * as publishAttempts from "../publishAttempts.js"; import type * as publisherAbuse from "../publisherAbuse.js"; import type * as publisherAbuseDevSeed from "../publisherAbuseDevSeed.js"; import type * as publishers from "../publishers.js"; @@ -301,6 +302,7 @@ declare const fullApi: ApiFromModules<{ packages: typeof packages; promotions: typeof promotions; promotionsFeed: typeof promotionsFeed; + publishAttempts: typeof publishAttempts; publisherAbuse: typeof publisherAbuse; publisherAbuseDevSeed: typeof publisherAbuseDevSeed; publishers: typeof publishers; diff --git a/convex/emailsNode.ts b/convex/emailsNode.ts index 142683f9..58c4d7a1 100644 --- a/convex/emailsNode.ts +++ b/convex/emailsNode.ts @@ -11,6 +11,7 @@ import { buildMaliciousArtifactEmail, buildPublisherAbuseWarningEmail, buildRestoredAccountEmail, + buildSecretBlockedPublishEmail, type NotificationArtifact, } from "./lib/emails"; @@ -181,6 +182,31 @@ export const sendMaliciousArtifactNotificationInternal = internalAction({ }, }); +export const sendSecretPublishBlockedNotificationInternal = internalAction({ + args: { + attemptId: v.id("publishAttempts"), + userId: v.id("users"), + to: v.string(), + handle: v.optional(v.string()), + artifact: notificationArtifactValidator, + version: v.optional(v.string()), + }, + handler: async (_ctx, args) => { + const email = await buildSecretBlockedPublishEmail({ + handle: args.handle, + artifact: args.artifact as NotificationArtifact, + version: args.version, + }); + return await sendTransactionalEmail({ + idempotencyKey: `secret-publish-block:${args.attemptId}:${args.userId}`, + to: args.to, + subject: email.subject, + text: email.text, + html: email.html, + }); + }, +}); + export const sendPublisherAbuseWarningInternal = internalAction({ args: { nominationId: v.id("publisherAbuseReviewNominations"), diff --git a/convex/lib/emailRendering.tsx b/convex/lib/emailRendering.tsx index 4492d1c1..d67b8ddd 100644 --- a/convex/lib/emailRendering.tsx +++ b/convex/lib/emailRendering.tsx @@ -11,6 +11,9 @@ import BlockedVersionEmail, { type BlockedVersionEmailProps } from "../../emails import PluginInspectorFindingsEmail, { type PluginInspectorFindingsEmailProps, } from "../../emails/plugin-inspector-findings"; +import SecretBlockedPublishEmail, { + type SecretBlockedPublishEmailProps, +} from "../../emails/secret-blocked-publish"; export async function renderAccountSuspendedEmail(props: AccountSuspendedEmailProps) { return await renderEmail(); @@ -28,6 +31,10 @@ export async function renderPluginInspectorFindingsEmail(props: PluginInspectorF return await renderEmail(); } +export async function renderSecretBlockedPublishEmail(props: SecretBlockedPublishEmailProps) { + return await renderEmail(); +} + export async function renderAdminOneOffEmail(props: AdminOneOffEmailProps) { return await renderEmail(); } diff --git a/convex/lib/emails.test.ts b/convex/lib/emails.test.ts index f63d77a6..0e45bb8a 100644 --- a/convex/lib/emails.test.ts +++ b/convex/lib/emails.test.ts @@ -8,6 +8,7 @@ import { buildPackageInspectorFindingsEmail, buildPublisherAbuseWarningEmail, buildRestoredAccountEmail, + buildSecretBlockedPublishEmail, } from "./emails"; function expectFooterLinksUnderlined(html: string) { @@ -254,6 +255,27 @@ describe("moderation notification email copy", () => { expect(email.text).toContain("Increment the version number before uploading the fixed plugin."); }); + it("builds secret-blocked publish copy without raw findings", async () => { + const email = await buildSecretBlockedPublishEmail({ + handle: "publisher", + artifact: { kind: "skill", name: "secret-skill" }, + version: "1.0.0", + }); + + expect(email.subject).toBe("ClawHub blocked a skill publish"); + expect(email.text).toContain("Hi publisher,"); + expect(email.text).toContain("TruffleHog found a secret-looking value"); + expect(email.text).toContain("Skill: secret-skill"); + expect(email.text).toContain("Version: 1.0.0"); + expect(email.text).toContain("Rotate the secret if it was real."); + expect(email.text).toContain("Uploaded files for this attempt were deleted"); + expect(email.text).not.toContain("sk-local-e2e-redacted-secret-not-real"); + expect(email.html).toContain("ClawHub blocked a skill publish"); + expect(email.html).toContain("TruffleHog"); + expect(email.html).not.toContain("Repeated malicious rejections"); + expect(email.html).not.toContain("appeal this decision"); + }); + it("builds plugin inspector warning copy with local validation guidance", async () => { const email = await buildPackageInspectorFindingsEmail({ handle: "octocat", diff --git a/convex/lib/emails.ts b/convex/lib/emails.ts index 504992f0..a89ea759 100644 --- a/convex/lib/emails.ts +++ b/convex/lib/emails.ts @@ -88,6 +88,12 @@ export type MaliciousArtifactEmailArgs = { findingSummary?: string; }; +export type SecretBlockedPublishEmailArgs = { + handle?: string; + artifact: NotificationArtifact; + version?: string; +}; + export type PackageInspectorEmailFinding = { findingKind: "warning" | "error"; code: string; @@ -307,6 +313,17 @@ function buildScanDownloadCommand(args: MaliciousArtifactEmailArgs) { return `clawhub scan download ${args.artifact.name} --version ${version}${kindFlag}`; } +async function renderSecretBlockedPublishTemplate(args: { + artifactKind: "skill" | "plugin"; + artifactName: string; + version: string; + preheader: string; +}) { + const { renderSecretBlockedPublishEmail } = await import("./emailRendering"); + const rendered = await renderSecretBlockedPublishEmail(args); + return rendered.html; +} + function buildPluginValidateCommand() { return "clawhub package validate "; } @@ -478,6 +495,44 @@ export async function buildMaliciousArtifactEmail(args: MaliciousArtifactEmailAr }; } +export async function buildSecretBlockedPublishEmail(args: SecretBlockedPublishEmailArgs) { + const artifactKind = args.artifact.kind === "skill" ? "skill" : "plugin"; + const artifactLabelText = artifactLabel(args.artifact); + const version = args.version?.trim() || ""; + const subject = `ClawHub blocked a ${artifactKind} publish`; + const lines = [ + greeting(args.handle), + "", + `ClawHub blocked a ${artifactKind} publish because TruffleHog found a secret-looking value in the uploaded files.`, + artifactLabelText, + `Version: ${version}`, + "", + "What changed:", + "- This version was not made public.", + "- Uploaded files for this attempt were deleted from ClawHub storage.", + "- Your account can still sign in.", + "", + "What to do next:", + "- Rotate the secret if it was real.", + `- Remove it from the ${artifactKind}.`, + "- Upload a new version.", + "", + "ClawHub Security", + ]; + const html = await renderSecretBlockedPublishTemplate({ + artifactKind, + artifactName: args.artifact.name, + version, + preheader: `${args.artifact.name}@${version} was blocked before public listing because a secret was found.`, + }); + + return { + subject, + text: lines.join("\n"), + html, + }; +} + export async function buildPackageInspectorFindingsEmail(args: PackageInspectorFindingsEmailArgs) { const targetOpenClawVersion = args.findings.find( (finding) => finding.targetOpenClawVersion, diff --git a/convex/lib/retentionPolicy.ts b/convex/lib/retentionPolicy.ts index 6a7e4095..f3c9cc5f 100644 --- a/convex/lib/retentionPolicy.ts +++ b/convex/lib/retentionPolicy.ts @@ -109,6 +109,16 @@ export const RETENTION_POLICIES = { skillSlugAliases: permanent("Historical slug routing aliases."), packages: permanent("Canonical package records."), packageReleases: permanent("Canonical package release records."), + publishAttempts: ephemeral( + "Private staged publish workflow state expires unless later retained by moderation policy.", + { + expirationField: "expiresAt", + expirationIndex: "by_expires_at", + prune: "future publishAttempts cleanup in CLAW-467 staged-publish follow-up", + retention: + "Pending/finalized attempt TTL; later secret and moderation slices refine blocked retention.", + }, + ), catalogClassificationResults: derived( "Catalog classification output can be recomputed from package and skill metadata.", "skills/packages", diff --git a/convex/lib/skillPublish.test.ts b/convex/lib/skillPublish.test.ts index 582eeb10..ccb97176 100644 --- a/convex/lib/skillPublish.test.ts +++ b/convex/lib/skillPublish.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it, vi } from "vitest"; import { MAX_PUBLISH_FILE_BYTES } from "./publishLimits"; -import { publishVersionForUser, __test } from "./skillPublish"; +import { + finalizeSkillPublishAttempt, + publishVersionForUser, + stageSkillPublishAttemptForUser, + __test, +} from "./skillPublish"; vi.mock("./embeddings", () => ({ generateEmbedding: vi.fn(async () => [0, 1, 2]), @@ -50,7 +55,7 @@ description: Automation workflow for recurring reports. }, }; - await publishVersionForUser( + const result = await publishVersionForUser( ctx as never, "users:1" as never, { @@ -82,6 +87,11 @@ description: Automation workflow for recurring reports. }, ); + expect(result).toEqual({ + skillId: "skills:demo", + versionId: "skillVersions:demo", + embeddingId: "skillEmbeddings:demo", + }); expect(runMutation).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ @@ -182,11 +192,12 @@ description: Org helper. }, ); - await vi.waitFor(() => { - expect(ctx.runQuery).toHaveBeenCalledWith(expect.anything(), { - slug: "org-helper", - ownerHandle: "org-demo", - }); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + expect(ctx.runQuery).toHaveBeenCalledWith(expect.anything(), { + slug: "org-helper", + ownerHandle: "org-demo", }); } finally { if (previousWebhookUrl === undefined) { @@ -352,7 +363,7 @@ description: Research helper for literature reviews. ); }); - it("schedules security scan enqueue after publish instead of awaiting it inline", async () => { + it("stages publish attempts without creating a public version inline", async () => { const storedFiles = new Map([ [ "_storage:skill", @@ -364,14 +375,13 @@ description: Security scanner smoke fixture. ], ]); const runMutation = vi.fn(async (_ref: unknown, args: Record) => { - if ("version" in args && "embedding" in args) { + if ("skillInsertArgs" in args) { return { - skillId: "skills:demo", - versionId: "skillVersions:demo", - embeddingId: "skillEmbeddings:demo", + attemptId: "publishAttempts:security-scanner-smoke", + status: "pending_checks", }; } - throw new Error("publish should not await follow-up scan enqueue mutations"); + throw new Error("publish should not create a public version before checks pass"); }); const scheduler = { runAfter: vi.fn() }; const ctx = { @@ -389,7 +399,7 @@ description: Security scanner smoke fixture. }, }; - await publishVersionForUser( + const result = await stageSkillPublishAttemptForUser( ctx as never, "users:1" as never, { @@ -414,25 +424,267 @@ description: Security scanner smoke fixture. }, ); + expect(result).toEqual({ + status: "pending", + attemptId: "publishAttempts:security-scanner-smoke", + slug: "security-scanner-smoke", + version: "1.0.0", + }); expect(runMutation).toHaveBeenCalledTimes(1); - expect(scheduler.runAfter).toHaveBeenCalledWith( - 0, + expect(runMutation).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ - versionId: "skillVersions:demo", - source: "publish", + skillInsertArgs: expect.objectContaining({ + slug: "security-scanner-smoke", + version: "1.0.0", + }), }), ); - expect(scheduler.runAfter).toHaveBeenCalledWith( - 15_000, + expect(scheduler.runAfter).not.toHaveBeenCalled(); + }); + + it("rejects duplicate staged skill versions before creating a publish attempt", async () => { + const runMutation = vi.fn(async () => { + throw new Error("duplicate publish should not create an attempt"); + }); + const ctx = { + runQuery: vi + .fn() + .mockResolvedValueOnce({ + _id: "skills:demo", + slug: "security-scanner-smoke", + softDeletedAt: undefined, + }) + .mockResolvedValueOnce({ + _id: "skillVersions:demo", + skillId: "skills:demo", + version: "1.0.0", + }), + runMutation, + scheduler: { runAfter: vi.fn() }, + storage: { + get: vi.fn(), + }, + }; + + await expect( + stageSkillPublishAttemptForUser( + ctx as never, + "users:1" as never, + { + slug: "security-scanner-smoke", + displayName: "Security Scanner Smoke", + version: "1.0.0", + changelog: "Duplicate release", + files: [ + { + path: "SKILL.md", + size: 90, + storageId: "_storage:skill" as never, + sha256: "a".repeat(64), + contentType: "text/markdown", + }, + ], + }, + { + bypassGitHubAccountAge: true, + bypassQualityGate: true, + skipWebhook: true, + }, + ), + ).rejects.toThrow("Version 1.0.0 already exists. Increment the version number and try again."); + + expect(runMutation).not.toHaveBeenCalled(); + expect(ctx.storage.get).not.toHaveBeenCalled(); + }); + + it("finalizes a clean staged publish through insertVersion and then enqueues scans", async () => { + const insertArgs = { + userId: "users:1", + slug: "security-scanner-smoke", + displayName: "Security Scanner Smoke", + version: "1.0.0", + embedding: [0, 1, 2], + }; + const runMutation = vi.fn(async (_ref: unknown, args: Record) => { + if ("claimId" in args && !("result" in args)) { + return { + status: "claimed", + attemptId: "publishAttempts:security-scanner-smoke", + skillInsertArgs: insertArgs, + followup: { + skipWebhook: true, + slug: "security-scanner-smoke", + version: "1.0.0", + displayName: "Security Scanner Smoke", + }, + }; + } + if ("version" in args && "embedding" in args) { + return { + skillId: "skills:demo", + versionId: "skillVersions:demo", + embeddingId: "skillEmbeddings:demo", + }; + } + if ("result" in args) { + return { + attemptId: "publishAttempts:security-scanner-smoke", + status: "finalized", + result: args.result, + }; + } + return { + attemptId: "publishAttempts:security-scanner-smoke", + status: "ready_to_finalize", + }; + }); + const scheduler = { runAfter: vi.fn() }; + const ctx = { + runMutation, + scheduler, + }; + + const result = await finalizeSkillPublishAttempt( + ctx as never, + "publishAttempts:security-scanner-smoke" as never, + ); + + expect(result).toEqual({ + skillId: "skills:demo", + versionId: "skillVersions:demo", + embeddingId: "skillEmbeddings:demo", + }); + expect(runMutation).toHaveBeenCalledWith(expect.anything(), insertArgs); + expect(scheduler.runAfter).toHaveBeenCalledWith(0, expect.anything(), { + versionId: "skillVersions:demo", + }); + expect(scheduler.runAfter).toHaveBeenCalledWith(0, expect.anything(), { + versionId: "skillVersions:demo", + source: "publish", + }); + expect(scheduler.runAfter).toHaveBeenCalledWith(15_000, expect.anything(), { + versionId: "skillVersions:demo", + source: "publish", + preserveActiveJob: true, + preserveExistingJob: true, + }); + }); + + it("releases the staged publish finalization claim when insertion fails", async () => { + const insertArgs = { + userId: "users:1", + slug: "security-scanner-smoke", + displayName: "Security Scanner Smoke", + version: "1.0.0", + embedding: [0, 1, 2], + }; + const runMutation = vi.fn(async (_ref: unknown, args: Record) => { + if ("claimId" in args && !("error" in args) && !("result" in args)) { + return { + status: "claimed", + attemptId: "publishAttempts:security-scanner-smoke", + skillInsertArgs: insertArgs, + followup: { + skipWebhook: true, + slug: "security-scanner-smoke", + version: "1.0.0", + displayName: "Security Scanner Smoke", + }, + }; + } + if ("version" in args && "embedding" in args) { + throw new Error("transient insert failure"); + } + if ("error" in args) { + return { + attemptId: "publishAttempts:security-scanner-smoke", + status: "ready_to_finalize", + }; + } + throw new Error("unexpected mutation"); + }); + const ctx = { + runMutation, + runQuery: vi.fn(async () => null), + scheduler: { runAfter: vi.fn() }, + }; + + await expect( + finalizeSkillPublishAttempt(ctx as never, "publishAttempts:security-scanner-smoke" as never), + ).rejects.toThrow("transient insert failure"); + + expect(runMutation).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ - versionId: "skillVersions:demo", - source: "publish", - preserveActiveJob: true, - preserveExistingJob: true, + attemptId: "publishAttempts:security-scanner-smoke", + error: "transient insert failure", }), ); + expect(ctx.scheduler.runAfter).not.toHaveBeenCalled(); + }); + + it("recovers an already-created public version when retrying finalization", async () => { + const insertArgs = { + userId: "users:1", + slug: "security-scanner-smoke", + displayName: "Security Scanner Smoke", + version: "1.0.0", + embedding: [0, 1, 2], + }; + const recoveredResult = { + skillId: "skills:demo", + versionId: "skillVersions:demo", + embeddingId: "skillEmbeddings:demo", + }; + const runMutation = vi.fn(async (_ref: unknown, args: Record) => { + if ("claimId" in args && !("result" in args)) { + return { + status: "claimed", + attemptId: "publishAttempts:security-scanner-smoke", + skillInsertArgs: insertArgs, + followup: { + skipWebhook: true, + slug: "security-scanner-smoke", + version: "1.0.0", + displayName: "Security Scanner Smoke", + }, + }; + } + if ("version" in args && "embedding" in args) { + throw new Error( + "Version 1.0.0 already exists. Increment the version number and try again.", + ); + } + if ("result" in args) { + return { + attemptId: "publishAttempts:security-scanner-smoke", + status: "finalized", + result: args.result, + }; + } + throw new Error("unexpected mutation"); + }); + const scheduler = { runAfter: vi.fn() }; + const ctx = { + runMutation, + runQuery: vi.fn(async () => recoveredResult), + scheduler, + }; + + const result = await finalizeSkillPublishAttempt( + ctx as never, + "publishAttempts:security-scanner-smoke" as never, + ); + + expect(result).toEqual(recoveredResult); + expect(runMutation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ result: recoveredResult }), + ); + expect(scheduler.runAfter).toHaveBeenCalledWith(0, expect.anything(), { + versionId: "skillVersions:demo", + }); }); it("merges github source into metadata", () => { diff --git a/convex/lib/skillPublish.ts b/convex/lib/skillPublish.ts index 80f8c6ba..a862bcee 100644 --- a/convex/lib/skillPublish.ts +++ b/convex/lib/skillPublish.ts @@ -54,6 +54,17 @@ const MAX_PUBLISH_SUMMARY_LENGTH = 300; type FingerprintFile = { path: string; sha256: string }; type SafePublishFile = PublishVersionArgs["files"][number] & { path: string }; type PublishFileBlob = { file: SafePublishFile; blob: Blob }; +type DeferredAiEnrichment = { + summary: { + mode: "generate" | "literal"; + literal?: string; + currentSummary?: string; + }; + changelog: { + source: "auto" | "user"; + supplied: string; + }; +}; function normalizeStoredSkillCategoryOverride(categories: readonly string[] | undefined) { if (categories === undefined) return undefined; @@ -70,6 +81,23 @@ export type PublishResult = { embeddingId: Id<"skillEmbeddings">; }; +export type PendingPublishResult = { + status: "pending"; + attemptId: Id<"publishAttempts">; + slug: string; + version: string; +}; + +type SkillPublishFollowup = { + skipWebhook?: boolean; + ownerHandle?: string; + slug: string; + version: string; + displayName: string; +}; + +export type SkillPublishResult = PublishResult | PendingPublishResult; + export type PublishVersionArgs = { slug: string; displayName: string; @@ -112,14 +140,46 @@ export type PublishOptions = { // publishes (including older CLIs that never pass this flag) can never // accidentally transfer ownership. migrateOwner?: boolean; + stagePrePublicationChecks?: boolean; }; +type InternalPublishOptions = PublishOptions; + export async function publishVersionForUser( ctx: ActionCtx, userId: Id<"users">, args: PublishVersionArgs, options: PublishOptions = {}, -): Promise { +): Promise { + return await publishVersionForUserInternal(ctx, userId, args, { + ...options, + stagePrePublicationChecks: + options.stagePrePublicationChecks ?? stagedPrePublicationPublishesEnabled(), + }); +} + +export async function stageSkillPublishAttemptForUser( + ctx: ActionCtx, + userId: Id<"users">, + args: PublishVersionArgs, + options: PublishOptions & { stagePrePublicationChecks?: boolean } = {}, +): Promise { + return await publishVersionForUserInternal(ctx, userId, args, { + ...options, + stagePrePublicationChecks: options.stagePrePublicationChecks ?? true, + }); +} + +function stagedPrePublicationPublishesEnabled() { + return process.env.CLAWHUB_STAGED_PREPUBLICATION_PUBLISHES === "1"; +} + +async function publishVersionForUserInternal( + ctx: ActionCtx, + userId: Id<"users">, + args: PublishVersionArgs, + options: InternalPublishOptions, +): Promise { const version = args.version.trim(); // Normalize first so we can look up the existing skill before deciding // how strictly to validate. The reserved-word blocklist and length floor @@ -145,6 +205,20 @@ export async function publishVersionForUser( sourceOwnerPublisherId: options.sourceOwnerPublisherId, migrateOwner: options.migrateOwner, })) as Doc<"skills"> | null; + if (options.stagePrePublicationChecks && existingSkill && !existingSkill.softDeletedAt) { + const existingVersion = (await ctx.runQuery( + internal.skills.getVersionBySkillAndVersionInternal, + { + skillId: existingSkill._id, + version, + }, + )) as Doc<"skillVersions"> | null; + if (existingVersion) { + throw new ConvexError( + `Version ${version} already exists. Increment the version number and try again.`, + ); + } + } const isNewSkill = !existingSkill; // For new skills, enforce the full write-path rules (length, pattern, @@ -208,14 +282,17 @@ export async function publishVersionForUser( if (explicitSummary && explicitSummary.length > MAX_PUBLISH_SUMMARY_LENGTH) { throw new ConvexError(`Summary must be ${MAX_PUBLISH_SUMMARY_LENGTH} characters or less`); } + const shouldDeferAiEnrichment = options.stagePrePublicationChecks === true; const summary = explicitSummary || - (await generateSkillSummary({ - slug, - displayName, - readmeText, - currentSummary: summaryFromFrontmatter ?? existingSkill?.summary ?? undefined, - })); + (shouldDeferAiEnrichment + ? (summaryFromFrontmatter ?? existingSkill?.summary ?? "") + : await generateSkillSummary({ + slug, + displayName, + readmeText, + currentSummary: summaryFromFrontmatter ?? existingSkill?.summary ?? undefined, + })); let qualityAssessment: QualityAssessment | null = null; if (isNewSkill && !options.bypassQualityGate) { @@ -310,7 +387,7 @@ export async function publishVersionForUser( ); const changelogPromise = - changelogSource === "user" + changelogSource === "user" || shouldDeferAiEnrichment ? Promise.resolve(suppliedChangelog) : generateChangelogForPublish(ctx, { slug, @@ -319,7 +396,9 @@ export async function publishVersionForUser( files: publishFiles.map((file) => ({ path: file.path, sha256: file.sha256 })), }); - const embeddingPromise = generateEmbedding(embeddingText); + const embeddingPromise = shouldDeferAiEnrichment + ? Promise.resolve([] as number[]) + : generateEmbedding(embeddingText); const [fingerprint, changelogText, embedding] = await Promise.all([ fingerprintPromise, @@ -329,7 +408,7 @@ export async function publishVersionForUser( }), ]); - const publishResult = (await ctx.runMutation(internal.skills.insertVersion, { + const skillInsertArgs = { userId, ownerPublisherId: options.ownerPublisherId, sourceOwnerPublisherId: options.sourceOwnerPublisherId, @@ -365,6 +444,20 @@ export async function publishVersionForUser( summary, staticScan, embedding, + deferredAiEnrichment: shouldDeferAiEnrichment + ? ({ + summary: explicitSummary + ? { mode: "literal", literal: explicitSummary } + : { + mode: "generate", + currentSummary: summaryFromFrontmatter ?? existingSkill?.summary ?? undefined, + }, + changelog: { + source: changelogSource, + supplied: suppliedChangelog, + }, + } satisfies DeferredAiEnrichment) + : undefined, qualityAssessment: qualityAssessment ? { decision: qualityAssessment.decision, @@ -375,8 +468,245 @@ export async function publishVersionForUser( signals: qualityAssessment.signals, } : undefined, - })) as PublishResult; + }; + let ownerHandle = options.ownerHandle; + if (!ownerHandle && options.ownerPublisherId !== undefined) { + const targetPublisher = (await ctx.runQuery(internal.publishers.getByIdInternal, { + publisherId: options.ownerPublisherId, + })) as Doc<"publishers"> | null; + ownerHandle = targetPublisher?.handle; + } + ownerHandle ??= owner?.handle ?? owner?.displayName ?? owner?.name; + + const followup = { + skipWebhook: options.skipWebhook || undefined, + ownerHandle, + slug, + version, + displayName, + }; + + if (!options.stagePrePublicationChecks) { + const publishResult = (await ctx.runMutation( + internal.skills.insertVersion, + skillInsertArgs, + )) as PublishResult; + await scheduleSkillPublishFollowups(ctx, publishResult, followup); + return publishResult; + } + + const staged = (await ctx.runMutation( + internal.publishAttempts.createSkillPublishAttemptInternal, + { + userId, + ownerPublisherId: options.ownerPublisherId, + sourceOwnerPublisherId: options.sourceOwnerPublisherId, + slug, + displayName, + version, + idempotencyKey: buildSkillPublishAttemptIdempotencyKey({ + userId, + ownerPublisherId: options.ownerPublisherId, + slug, + version, + fingerprint, + }), + artifactFingerprint: fingerprint, + files: publishFiles.map((file) => ({ + ...file, + path: file.path, + })), + skillInsertArgs: stripUndefinedForStoredAttempt(skillInsertArgs), + followup: { + skipWebhook: followup.skipWebhook, + ownerHandle, + }, + }, + )) as { + attemptId: Id<"publishAttempts">; + status: string; + result?: PublishResult; + }; + + if (staged.status === "finalized" && staged.result) { + return staged.result; + } + + return { status: "pending", attemptId: staged.attemptId, slug, version }; +} + +export async function finalizeSkillPublishAttempt( + ctx: ActionCtx, + attemptId: Id<"publishAttempts">, +): Promise { + const claimId = buildFinalizationClaimId(); + const claim = (await ctx.runMutation( + internal.publishAttempts.claimSkillPublishAttemptForFinalizationInternal, + { attemptId, claimId }, + )) as + | { + status: "claimed"; + attemptId: Id<"publishAttempts">; + skillInsertArgs: unknown; + followup: SkillPublishFollowup; + } + | { + status: "finalized"; + attemptId: Id<"publishAttempts">; + result: PublishResult; + followup: SkillPublishFollowup; + }; + + if (claim.status === "finalized") { + return claim.result; + } + + let publishResult: PublishResult; + try { + const skillInsertArgs = await prepareSkillInsertArgsForFinalization(ctx, claim.skillInsertArgs); + publishResult = (await ctx.runMutation( + internal.skills.insertVersion, + skillInsertArgs as never, + )) as PublishResult; + } catch (error) { + const existingResult = (await ctx.runQuery( + internal.publishAttempts.findSkillPublishAttemptPublicResultInternal, + { attemptId: claim.attemptId }, + )) as PublishResult | null; + if (!existingResult) { + await releaseSkillPublishAttemptFinalizationClaim(ctx, claim.attemptId, claimId, error); + throw error; + } + publishResult = existingResult; + } + + try { + await scheduleSkillPublishFollowups(ctx, publishResult, claim.followup); + + await ctx.runMutation(internal.publishAttempts.recordSkillPublishAttemptFinalizedInternal, { + attemptId: claim.attemptId, + claimId, + result: publishResult, + }); + } catch (error) { + await releaseSkillPublishAttemptFinalizationClaim(ctx, claim.attemptId, claimId, error); + throw error; + } + + return publishResult; +} + +async function prepareSkillInsertArgsForFinalization( + ctx: ActionCtx, + rawInsertArgs: unknown, +): Promise { + if (!rawInsertArgs || typeof rawInsertArgs !== "object" || Array.isArray(rawInsertArgs)) { + return rawInsertArgs; + } + const insertArgs = rawInsertArgs as Record; + const deferred = insertArgs.deferredAiEnrichment as DeferredAiEnrichment | undefined; + if (!deferred) return rawInsertArgs; + + const { deferredAiEnrichment: _deferredAiEnrichment, ...prepared } = insertArgs; + const files = Array.isArray(prepared.files) + ? (prepared.files as Array<{ + path?: unknown; + storageId?: unknown; + contentType?: unknown; + sha256?: unknown; + }>) + : []; + const readmeFile = files.find((file) => { + const path = typeof file.path === "string" ? file.path.toLowerCase() : ""; + return path === "skill.md" || path === "skills.md"; + }); + if (!readmeFile?.storageId || typeof readmeFile.storageId !== "string") { + throw new ConvexError("SKILL.md is required"); + } + + const readmeText = await fetchText(ctx, readmeFile.storageId as Id<"_storage">); + const frontmatter = parseFrontmatter(readmeText); + const otherFiles: Array<{ path: string; content: string }> = []; + for (const file of files) { + if (file === readmeFile || typeof file.path !== "string") continue; + if ( + !isTextFile(file.path, typeof file.contentType === "string" ? file.contentType : undefined) + ) { + continue; + } + if (!file.storageId || typeof file.storageId !== "string") continue; + const content = await fetchText(ctx, file.storageId as Id<"_storage">); + otherFiles.push({ path: file.path, content }); + if (otherFiles.length >= MAX_FILES_FOR_EMBEDDING) break; + } + + const summary = + deferred.summary.mode === "literal" + ? (deferred.summary.literal ?? "") + : await generateSkillSummary({ + slug: stringField(prepared, "slug"), + displayName: stringField(prepared, "displayName"), + readmeText, + currentSummary: deferred.summary.currentSummary, + }); + const changelog = + deferred.changelog.source === "user" + ? deferred.changelog.supplied + : await generateChangelogForPublish(ctx, { + slug: stringField(prepared, "slug"), + version: stringField(prepared, "version"), + readmeText, + files: files + .filter( + (file): file is { path: string; sha256: string } => + typeof file.path === "string" && typeof file.sha256 === "string", + ) + .map((file) => ({ path: file.path, sha256: file.sha256 })), + }); + const embeddingText = buildEmbeddingText({ + frontmatter, + readme: readmeText, + otherFiles, + }); + const embedding = await generateEmbedding(embeddingText).catch((error) => { + throw new ConvexError(formatEmbeddingError(error)); + }); + + return { + ...prepared, + summary, + changelog, + embedding, + }; +} + +function stringField(record: Record, field: string) { + const value = record[field]; + return typeof value === "string" ? value : ""; +} + +async function releaseSkillPublishAttemptFinalizationClaim( + ctx: ActionCtx, + attemptId: Id<"publishAttempts">, + claimId: string, + error: unknown, +) { + await ctx.runMutation( + internal.publishAttempts.releaseSkillPublishAttemptFinalizationClaimInternal, + { + attemptId, + claimId, + error: formatPublishAttemptFinalizationError(error), + }, + ); +} + +async function scheduleSkillPublishFollowups( + ctx: ActionCtx, + publishResult: PublishResult, + followup: SkillPublishFollowup, +) { await ctx.scheduler.runAfter(0, internal.vt.scanWithVirusTotal, { versionId: publishResult.versionId, }); @@ -402,24 +732,14 @@ export async function publishVersionForUser( }, ); - if (!options.skipWebhook && getWebhookConfig().url) { - let ownerHandle = options.ownerHandle; - if (!ownerHandle && options.ownerPublisherId !== undefined) { - const targetPublisher = (await ctx.runQuery(internal.publishers.getByIdInternal, { - publisherId: options.ownerPublisherId, - })) as Doc<"publishers"> | null; - ownerHandle = targetPublisher?.handle; - } - ownerHandle ??= owner?.handle ?? owner?.displayName ?? owner?.name; + if (!followup.skipWebhook && getWebhookConfig().url) { void schedulePublishWebhook(ctx, { - slug, - version, - displayName, - ownerHandle, + slug: followup.slug, + version: followup.version, + displayName: followup.displayName, + ownerHandle: followup.ownerHandle, }); } - - return publishResult; } function mergeSourceIntoMetadata( @@ -459,6 +779,36 @@ function mergeSourceIntoMetadata( return Object.keys(base).length ? base : undefined; } +function buildSkillPublishAttemptIdempotencyKey(args: { + userId: Id<"users">; + ownerPublisherId?: Id<"publishers">; + slug: string; + version: string; + fingerprint: string; +}) { + const ownerScope = args.ownerPublisherId + ? `publisher:${args.ownerPublisherId}` + : `user:${args.userId}`; + return ["skill", ownerScope, args.slug, args.version, args.fingerprint].join(":"); +} + +function stripUndefinedForStoredAttempt(value: unknown): unknown { + if (Array.isArray(value)) return value.map(stripUndefinedForStoredAttempt); + if (!value || typeof value !== "object") return value; + + const result: Record = {}; + for (const [key, nested] of Object.entries(value)) { + if (nested !== undefined) result[key] = stripUndefinedForStoredAttempt(nested); + } + return result; +} + +function buildFinalizationClaimId() { + return typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : `${Date.now()}:${Math.random().toString(16).slice(2)}`; +} + async function buildPublishSourceFingerprint(files: FingerprintFile[]) { return await hashSkillFiles(files.filter((file) => !isSkillCardPath(file.path))); } @@ -470,6 +820,7 @@ export const __test = { evaluateQuality, toStructuralFingerprint, derivePublishFilesFromStorage, + buildSkillPublishAttemptIdempotencyKey, }; export async function queueHighlightedWebhook(ctx: MutationCtx, skillId: Id<"skills">) { @@ -589,6 +940,11 @@ function formatEmbeddingError(error: unknown) { return "Embedding failed. Please try again."; } +function formatPublishAttemptFinalizationError(error: unknown) { + if (error instanceof Error) return error.message.slice(0, 500); + return String(error).slice(0, 500); +} + async function schedulePublishWebhook( ctx: ActionCtx, params: { slug: string; version: string; displayName: string; ownerHandle?: string }, diff --git a/convex/packages.public.test.ts b/convex/packages.public.test.ts index 77e92032..de04fd1f 100644 --- a/convex/packages.public.test.ts +++ b/convex/packages.public.test.ts @@ -40,6 +40,7 @@ import { getVersionByName, getVersionSecurityByNameForViewerInternal, insertReleaseInternal, + findPackagePublishResultInternal, listPackageModerationQueueInternal, listPluginExportPageInternal, reservePackageNameInternal, @@ -259,6 +260,18 @@ const insertReleaseInternalHandler = ( unknown > )._handler; +const findPackagePublishResultInternalHandler = ( + findPackagePublishResultInternal as unknown as WrappedHandler< + { + name: string; + version: string; + integritySha256: string; + ownerUserId: string; + ownerPublisherId?: string; + }, + { ok: true; packageId: string; releaseId: string } | null + > +)._handler; const reservePackageNameInternalHandler = ( reservePackageNameInternal as unknown as WrappedHandler< { @@ -6883,6 +6896,85 @@ describe("packages public queries", () => { ); }); + it("recovers idempotent package publish results for the same owner", async () => { + const release = makeReleaseDoc({ integritySha256: "abc123" }); + const ctx = { + db: { + query: vi.fn((table: string) => { + if (table === "packages") { + return { + withIndex: vi.fn(() => ({ + unique: vi.fn().mockResolvedValue( + makePackageDoc({ + ownerUserId: "users:owner", + ownerPublisherId: "publishers:owner", + }), + ), + })), + }; + } + if (table === "packageReleases") { + return { + withIndex: vi.fn(() => ({ + unique: vi.fn().mockResolvedValue(release), + })), + }; + } + throw new Error(`Unexpected table ${table}`); + }), + }, + }; + + await expect( + findPackagePublishResultInternalHandler(ctx as never, { + name: "demo-plugin", + version: "1.0.0", + integritySha256: "abc123", + ownerUserId: "users:owner", + ownerPublisherId: "publishers:owner", + }), + ).resolves.toEqual({ + ok: true, + packageId: "packages:demo", + releaseId: "packageReleases:demo-1", + }); + }); + + it("does not recover idempotent package publish results for another owner", async () => { + const ctx = { + db: { + query: vi.fn((table: string) => { + if (table === "packages") { + return { + withIndex: vi.fn(() => ({ + unique: vi.fn().mockResolvedValue( + makePackageDoc({ + ownerUserId: "users:other", + ownerPublisherId: "publishers:other", + }), + ), + })), + }; + } + if (table === "packageReleases") { + throw new Error("release query should be skipped for owner mismatch"); + } + throw new Error(`Unexpected table ${table}`); + }), + }, + }; + + await expect( + findPackagePublishResultInternalHandler(ctx as never, { + name: "demo-plugin", + version: "1.0.0", + integritySha256: "abc123", + ownerUserId: "users:owner", + ownerPublisherId: "publishers:owner", + }), + ).resolves.toBeNull(); + }); + it("clears inferred catalog state when a publisher promotes a latest package release", async () => { const ctx = makeInsertReleaseCtx( makePackageDoc({ @@ -8325,6 +8417,179 @@ describe("packages public queries", () => { }); }); + it("revokes trusted publish tokens when a staged publish is accepted for checks", async () => { + const previousFlag = process.env.CLAWHUB_STAGED_PREPUBLICATION_PUBLISHES; + process.env.CLAWHUB_STAGED_PREPUBLICATION_PUBLISHES = "1"; + const runMutation = vi.fn(async (_ref: unknown, args: unknown) => { + if ( + typeof args === "object" && + args !== null && + "packageInsertArgs" in args && + "packageFollowup" in args + ) { + return { + attemptId: "publishAttempts:demo", + status: "pending_checks", + }; + } + return null; + }); + const trustedPublisher = { + _id: "packageTrustedPublishers:1", + packageId: "packages:demo", + provider: "github-actions", + repository: "openclaw/openclaw", + repositoryId: "1", + repositoryOwner: "openclaw", + repositoryOwnerId: "2", + workflowFilename: "plugin-clawhub-release.yml", + environment: "clawhub-release", + }; + const ctx = { + runQuery: vi + .fn() + .mockResolvedValueOnce({ + _id: "packagePublishTokens:1", + packageId: "packages:demo", + provider: "github-actions", + repository: "openclaw/openclaw", + repositoryId: "1", + repositoryOwner: "openclaw", + repositoryOwnerId: "2", + workflowFilename: "plugin-clawhub-release.yml", + environment: "clawhub-release", + version: "1.0.0", + sha: "abc123", + ref: "refs/heads/main", + runId: "100", + runAttempt: "1", + expiresAt: Date.now() + 60_000, + }) + .mockResolvedValueOnce(trustedPublisher) + .mockResolvedValueOnce(makePackageDoc({ family: "bundle-plugin" })) + .mockResolvedValueOnce(trustedPublisher) + .mockResolvedValueOnce(null), + runMutation, + runAction: vi.fn(async () => makeCleanPackageInspectorResult()), + scheduler: { + runAfter: vi.fn(), + }, + storage: makePackageManifestStorage(), + }; + + try { + await expect( + publishPackageForTrustedPublisherInternalHandler(ctx as never, { + publishTokenId: "packagePublishTokens:1", + payload: { + name: "demo-plugin", + family: "bundle-plugin", + version: "1.0.0", + changelog: "init", + bundle: { hostTargets: ["desktop"] }, + files: [packageManifestFile], + }, + }), + ).resolves.toMatchObject({ + ok: true, + status: "pending", + attemptId: "publishAttempts:demo", + packageName: "demo-plugin", + version: "1.0.0", + }); + } finally { + if (previousFlag === undefined) { + delete process.env.CLAWHUB_STAGED_PREPUBLICATION_PUBLISHES; + } else { + process.env.CLAWHUB_STAGED_PREPUBLICATION_PUBLISHES = previousFlag; + } + } + + expect(runMutation).toHaveBeenCalledWith(expect.anything(), { + tokenId: "packagePublishTokens:1", + }); + }); + + it("rejects duplicate staged package releases before creating a publish attempt", async () => { + const previousFlag = process.env.CLAWHUB_STAGED_PREPUBLICATION_PUBLISHES; + process.env.CLAWHUB_STAGED_PREPUBLICATION_PUBLISHES = "1"; + const runMutation = vi.fn(async () => { + throw new Error("duplicate publish should not create an attempt"); + }); + const trustedPublisher = { + _id: "packageTrustedPublishers:1", + packageId: "packages:demo", + provider: "github-actions", + repository: "openclaw/openclaw", + repositoryId: "1", + repositoryOwner: "openclaw", + repositoryOwnerId: "2", + workflowFilename: "plugin-clawhub-release.yml", + environment: "clawhub-release", + }; + const ctx = { + runQuery: vi + .fn() + .mockResolvedValueOnce({ + _id: "packagePublishTokens:1", + packageId: "packages:demo", + provider: "github-actions", + repository: "openclaw/openclaw", + repositoryId: "1", + repositoryOwner: "openclaw", + repositoryOwnerId: "2", + workflowFilename: "plugin-clawhub-release.yml", + environment: "clawhub-release", + version: "1.0.0", + sha: "abc123", + ref: "refs/heads/main", + runId: "100", + runAttempt: "1", + expiresAt: Date.now() + 60_000, + }) + .mockResolvedValueOnce(trustedPublisher) + .mockResolvedValueOnce(makePackageDoc({ family: "bundle-plugin" })) + .mockResolvedValueOnce(trustedPublisher) + .mockResolvedValueOnce( + makeReleaseDoc({ + integritySha256: "different-existing-artifact", + }), + ), + runMutation, + runAction: vi.fn(async () => makeCleanPackageInspectorResult()), + scheduler: { + runAfter: vi.fn(), + }, + storage: makePackageManifestStorage(), + }; + + try { + await expect( + publishPackageForTrustedPublisherInternalHandler(ctx as never, { + publishTokenId: "packagePublishTokens:1", + payload: { + name: "demo-plugin", + family: "bundle-plugin", + version: "1.0.0", + changelog: "duplicate", + bundle: { hostTargets: ["desktop"] }, + files: [packageManifestFile], + }, + }), + ).rejects.toThrow( + "Version 1.0.0 already exists. Increment the version number and try again.", + ); + } finally { + if (previousFlag === undefined) { + delete process.env.CLAWHUB_STAGED_PREPUBLICATION_PUBLISHES; + } else { + process.env.CLAWHUB_STAGED_PREPUBLICATION_PUBLISHES = previousFlag; + } + } + + expect(runMutation).not.toHaveBeenCalled(); + }); + it("accepts trusted publish tokens when no environment is pinned", async () => { const runMutation = vi.fn(async (_ref: unknown, args: unknown) => { if ( diff --git a/convex/packages.ts b/convex/packages.ts index 0dd97106..d26043c6 100644 --- a/convex/packages.ts +++ b/convex/packages.ts @@ -469,10 +469,12 @@ const internalRefs = internal as unknown as { scanPackageReleaseStaticallyInternal: unknown; insertReleaseInternal: unknown; getPackageByNameInternal: unknown; + findPackagePublishResultInternal: unknown; getTrustedPublisherByPackageIdInternal: unknown; getByNameForViewerInternal: unknown; getPackageByIdInternal: unknown; getReleaseByIdInternal: unknown; + getReleaseByPackageAndVersionInternal: unknown; getPackageReleaseScanBackfillBatchInternal: unknown; listVersionsForViewerInternal: unknown; getVersionByNameForViewerInternal: unknown; @@ -507,6 +509,12 @@ const internalRefs = internal as unknown as { getByIdInternal: unknown; resolvePublishTargetForUserInternal: unknown; }; + publishAttempts: { + createPackagePublishAttemptInternal: unknown; + claimPackagePublishAttemptForFinalizationInternal: unknown; + releasePackagePublishAttemptFinalizationClaimInternal: unknown; + recordPackagePublishAttemptFinalizedInternal: unknown; + }; securityScan: { enqueuePackageReleaseScanInternal: unknown; }; @@ -630,6 +638,9 @@ type PackagePublishAuthContext = publishToken: Doc<"packagePublishTokens">; }; type PackageTrustedPublisherDoc = Doc<"packageTrustedPublishers">; +type PackagePublishOptions = { + stagePrePublicationChecks?: boolean; +}; type PackageDoc = Doc<"packages">; type PublicPackageListItem = { name: string; @@ -4301,6 +4312,31 @@ export const getPackageByNameInternal = internalQuery({ }, }); +export const findPackagePublishResultInternal = internalQuery({ + args: { + name: v.string(), + version: v.string(), + integritySha256: v.string(), + ownerUserId: v.id("users"), + ownerPublisherId: v.optional(v.id("publishers")), + }, + handler: async (ctx, args) => { + const pkg = await getPackageByNormalizedName(ctx, normalizePackageName(args.name)); + if (!pkg) return null; + if (getPackageOwnerKey(pkg) !== getRequestedPackageOwnerKey(args)) return null; + const release = await ctx.db + .query("packageReleases") + .withIndex("by_package_version", (q) => + q.eq("packageId", pkg._id).eq("version", args.version), + ) + .unique(); + if (!release || release.softDeletedAt || release.integritySha256 !== args.integritySha256) { + return null; + } + return { ok: true as const, packageId: pkg._id, releaseId: release._id }; + }, +}); + async function buildPackageActivityTrend(ctx: DbReaderCtx, pkg: Doc<"packages">, endDay: number) { const safeEndDay = clampActivityTrendEndDay(endDay, Date.now()); const { startDay, endDay: normalizedEndDay } = getActivityTrendRangeForEndDay(safeEndDay); @@ -7331,6 +7367,7 @@ async function publishPackageImpl( Pick, auth: PackagePublishAuthContext, rawPayload: unknown, + options: PackagePublishOptions = {}, ) { const payload = parseArk( ServerPackagePublishRequestSchema, @@ -7633,11 +7670,7 @@ async function publishPackageImpl( files: await withSkillMarkdownTextsForManifestSummary(ctx, files), }); - const publishResult = await runMutationRef<{ - ok: true; - packageId: Id<"packages">; - releaseId: Id<"packageReleases">; - }>(ctx, internalRefs.packages.insertReleaseInternal, { + const packageInsertArgs = { actorUserId, ownerUserId, ownerPublisherId, @@ -7679,12 +7712,116 @@ async function publishPackageImpl( normalizedBundleManifest: family === "bundle-plugin" ? storedBundleManifest : undefined, pluginManifestSummary, source: effectiveSource, - }); + }; const inspectorFindings = inspectorResult?.warnings.map((finding) => toPackageInspectorPublishResponseFinding(finding, inspectorResult.metadata), ) ?? []; + + if (options.stagePrePublicationChecks) { + if (existingPackage) { + const existingRelease = await runQueryRef | null>( + ctx, + internalRefs.packages.getReleaseByPackageAndVersionInternal, + { packageId: existingPackage._id, version }, + ); + const canReuseExistingRelease = + packageInsertArgs.allowExistingRelease && + existingRelease && + !existingRelease.softDeletedAt && + existingRelease.integritySha256 === integritySha256; + if (existingRelease && !canReuseExistingRelease) { + throw new ConvexError( + `Version ${version} already exists. Increment the version number and try again.`, + ); + } + } + + const staged = await runMutationRef<{ + attemptId: Id<"publishAttempts">; + status: string; + result?: { ok: true; packageId: Id<"packages">; releaseId: Id<"packageReleases"> }; + }>(ctx, internalRefs.publishAttempts.createPackagePublishAttemptInternal, { + userId: actorUserId, + ownerUserId, + ownerPublisherId, + name, + displayName, + version, + idempotencyKey: buildPackagePublishAttemptIdempotencyKey({ + actorUserId, + ownerPublisherId, + ownerUserId, + name, + version, + integritySha256, + }), + artifactFingerprint: integritySha256, + files, + packageInsertArgs: stripUndefinedForStoredAttempt(packageInsertArgs), + packageFollowup: stripUndefinedForStoredAttempt({ + ownerUserId, + ownerPublisherId, + packageName: name, + version, + inspectorWarnings: inspectorResult?.warnings ?? [], + inspectorMetadata: inspectorResult?.metadata, + trustedPublishTokenId: auth.kind === "github-actions" ? auth.publishToken._id : undefined, + manualOverrideAudit: + auth.kind === "user" && existingTrustedPublisher && manualOverrideReason + ? { + actorUserId, + version, + reason: manualOverrideReason, + trustedPublisher: { + provider: existingTrustedPublisher.provider, + repository: existingTrustedPublisher.repository, + workflowFilename: existingTrustedPublisher.workflowFilename, + environment: existingTrustedPublisher.environment, + }, + } + : undefined, + githubActionsAudit: + auth.kind === "github-actions" + ? { + actorUserId, + version, + repository: auth.publishToken.repository, + workflowFilename: auth.publishToken.workflowFilename, + environment: auth.publishToken.environment, + runId: auth.publishToken.runId, + runAttempt: auth.publishToken.runAttempt, + sha: auth.publishToken.sha, + } + : undefined, + }), + }); + if (auth.kind === "github-actions") { + await runMutationRef(ctx, internalRefs.packagePublishTokens.revokeInternal, { + tokenId: auth.publishToken._id, + }); + } + + if (staged.status === "finalized" && staged.result) { + return inspectorFindings.length > 0 ? { ...staged.result, inspectorFindings } : staged.result; + } + + return { + ok: true as const, + status: "pending" as const, + attemptId: staged.attemptId, + packageName: name, + version, + ...(inspectorFindings.length > 0 ? { inspectorFindings } : {}), + }; + } + + const publishResult = await runMutationRef<{ + ok: true; + packageId: Id<"packages">; + releaseId: Id<"packageReleases">; + }>(ctx, internalRefs.packages.insertReleaseInternal, packageInsertArgs); if (inspectorResult?.warnings.length) { const insertFindingsResult = await runMutationRef<{ ok: true; @@ -7802,6 +7939,7 @@ export const publishPackageForUserInternal = internalAction({ ctx, { kind: "user", actorUserId: args.actorUserId }, args.payload, + { stagePrePublicationChecks: stagedPrePublicationPublishesEnabled() }, ); }, }); @@ -7812,7 +7950,98 @@ export const publishRelease: ReturnType = action({ }, handler: async (ctx, args) => { const { userId } = await requireUserFromAction(ctx); - return await publishPackageImpl(ctx, { kind: "user", actorUserId: userId }, args.payload); + const stagePrePublicationChecks = stagedPrePublicationPublishesEnabled(); + return await publishPackageImpl(ctx, { kind: "user", actorUserId: userId }, args.payload, { + stagePrePublicationChecks, + }); + }, +}); + +function stagedPrePublicationPublishesEnabled() { + return process.env.CLAWHUB_STAGED_PREPUBLICATION_PUBLISHES === "1"; +} + +export const finalizePackagePublishAttemptInternal = internalAction({ + args: { + attemptId: v.id("publishAttempts"), + }, + handler: async (ctx, args) => { + const claimId = buildPackageFinalizationClaimId(); + const claim = await runMutationRef< + | { + status: "claimed"; + attemptId: Id<"publishAttempts">; + packageInsertArgs: unknown; + packageFollowup: unknown; + } + | { + status: "finalized"; + attemptId: Id<"publishAttempts">; + result: { ok: true; packageId: Id<"packages">; releaseId: Id<"packageReleases"> }; + packageFollowup: unknown; + } + >(ctx, internalRefs.publishAttempts.claimPackagePublishAttemptForFinalizationInternal, { + attemptId: args.attemptId, + claimId, + }); + if (claim.status === "finalized") return claim.result; + + let publishResult: { ok: true; packageId: Id<"packages">; releaseId: Id<"packageReleases"> }; + try { + publishResult = await runMutationRef( + ctx, + internalRefs.packages.insertReleaseInternal, + claim.packageInsertArgs, + ); + } catch (error) { + const insertArgs = claim.packageInsertArgs as { + name?: string; + version?: string; + integritySha256?: string; + ownerUserId?: Id<"users">; + ownerPublisherId?: Id<"publishers">; + }; + const existingResult = + insertArgs.name && + insertArgs.version && + insertArgs.integritySha256 && + insertArgs.ownerUserId + ? await runQueryRef<{ + ok: true; + packageId: Id<"packages">; + releaseId: Id<"packageReleases">; + } | null>(ctx, internalRefs.packages.findPackagePublishResultInternal, { + name: insertArgs.name, + version: insertArgs.version, + integritySha256: insertArgs.integritySha256, + ownerUserId: insertArgs.ownerUserId, + ownerPublisherId: insertArgs.ownerPublisherId, + }) + : null; + if (!existingResult) { + await releasePackagePublishAttemptFinalizationClaim(ctx, claim.attemptId, claimId, error); + throw error; + } + publishResult = existingResult; + } + + try { + await runPackagePublishPostFinalizeFollowups(ctx, publishResult, claim.packageFollowup); + await runMutationRef( + ctx, + internalRefs.publishAttempts.recordPackagePublishAttemptFinalizedInternal, + { + attemptId: claim.attemptId, + claimId, + result: publishResult, + }, + ); + } catch (error) { + await releasePackagePublishAttemptFinalizationClaim(ctx, claim.attemptId, claimId, error); + throw error; + } + + return publishResult; }, }); @@ -7840,10 +8069,186 @@ export const publishPackageForTrustedPublisherInternal = internalAction({ "Trusted publish token no longer matches the current package trusted publisher", ); } - return await publishPackageImpl(ctx, { kind: "github-actions", publishToken }, args.payload); + return await publishPackageImpl(ctx, { kind: "github-actions", publishToken }, args.payload, { + stagePrePublicationChecks: stagedPrePublicationPublishesEnabled(), + }); }, }); +function buildPackagePublishAttemptIdempotencyKey(args: { + actorUserId: Id<"users">; + ownerUserId: Id<"users">; + ownerPublisherId?: Id<"publishers">; + name: string; + version: string; + integritySha256: string; +}) { + return [ + "package", + args.actorUserId, + args.ownerPublisherId ?? args.ownerUserId, + args.name, + args.version, + args.integritySha256, + ].join(":"); +} + +function stripUndefinedForStoredAttempt(value: unknown): unknown { + if (Array.isArray(value)) return value.map(stripUndefinedForStoredAttempt); + if (!value || typeof value !== "object") return value; + + const result: Record = {}; + for (const [key, nested] of Object.entries(value)) { + if (nested !== undefined) result[key] = stripUndefinedForStoredAttempt(nested); + } + return result; +} + +function buildPackageFinalizationClaimId() { + return typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : `${Date.now()}:${Math.random().toString(36).slice(2)}`; +} + +async function releasePackagePublishAttemptFinalizationClaim( + ctx: ActionCtx, + attemptId: Id<"publishAttempts">, + claimId: string, + error: unknown, +) { + await runMutationRef( + ctx, + internalRefs.publishAttempts.releasePackagePublishAttemptFinalizationClaimInternal, + { + attemptId, + claimId, + error: error instanceof Error ? error.message : String(error), + }, + ); +} + +async function runPackagePublishPostFinalizeFollowups( + ctx: ActionCtx, + publishResult: { packageId: Id<"packages">; releaseId: Id<"packageReleases"> }, + rawFollowup: unknown, +) { + const followup = rawFollowup as { + ownerUserId?: Id<"users">; + ownerPublisherId?: Id<"publishers">; + packageName?: string; + version?: string; + inspectorWarnings?: PackageInspectorFinding[]; + inspectorMetadata?: PackageInspectorPublishResult["metadata"]; + trustedPublishTokenId?: Id<"packagePublishTokens">; + manualOverrideAudit?: { + actorUserId: Id<"users">; + version: string; + reason: string; + trustedPublisher: { + provider: string; + repository: string; + workflowFilename: string; + environment?: string; + }; + }; + githubActionsAudit?: { + actorUserId: Id<"users">; + version: string; + repository: string; + workflowFilename: string; + environment?: string; + runId?: string; + runAttempt?: string; + sha?: string; + }; + }; + + if ( + followup.ownerUserId && + followup.packageName && + followup.version && + followup.inspectorWarnings?.length + ) { + const insertFindingsResult = await runMutationRef<{ + ok: true; + inserted: number; + shouldEmailOwner: boolean; + }>(ctx, internalRefs.packages.insertPackageInspectorWarningsInternal, { + packageId: publishResult.packageId, + releaseId: publishResult.releaseId, + ownerUserId: followup.ownerUserId, + ownerPublisherId: followup.ownerPublisherId, + packageName: followup.packageName, + version: followup.version, + scanSource: "publish", + inspectorVersion: followup.inspectorMetadata?.inspectorVersion, + targetOpenClawVersion: followup.inspectorMetadata?.targetOpenClawVersion, + findings: followup.inspectorWarnings, + }); + if (insertFindingsResult.shouldEmailOwner) { + try { + await runActionRef(ctx, internalRefs.packages.sendPackageInspectorFindingsEmailInternal, { + packageId: publishResult.packageId, + releaseId: publishResult.releaseId, + }); + } catch (error) { + console.error("Package Inspector findings email failed", error); + } + } + } + + if (followup.trustedPublishTokenId) { + await runMutationRef(ctx, internalRefs.packagePublishTokens.revokeInternal, { + tokenId: followup.trustedPublishTokenId, + }); + } + + if (followup.manualOverrideAudit) { + await runMutationRef(ctx, internalRefs.packages.insertAuditLogInternal, { + actorUserId: followup.manualOverrideAudit.actorUserId, + action: "package.publish.manual_override", + targetType: "package", + targetId: String(publishResult.packageId), + metadata: { + version: followup.manualOverrideAudit.version, + reason: followup.manualOverrideAudit.reason, + trustedPublisher: followup.manualOverrideAudit.trustedPublisher, + }, + }); + } + + if (followup.githubActionsAudit) { + await runMutationRef(ctx, internalRefs.packages.insertAuditLogInternal, { + actorUserId: followup.githubActionsAudit.actorUserId, + action: "package.publish.github_actions", + targetType: "package", + targetId: String(publishResult.packageId), + metadata: { + version: followup.githubActionsAudit.version, + repository: followup.githubActionsAudit.repository, + workflowFilename: followup.githubActionsAudit.workflowFilename, + environment: followup.githubActionsAudit.environment, + runId: followup.githubActionsAudit.runId, + runAttempt: followup.githubActionsAudit.runAttempt, + sha: followup.githubActionsAudit.sha, + }, + }); + } + + await runAfterRef( + ctx, + INITIAL_PACKAGE_VT_SCAN_DELAY_MS, + internalRefs.vt.scanPackageReleaseWithVirusTotal, + { + releaseId: publishResult.releaseId, + }, + ); + await runMutationRef(ctx, internalRefs.securityScan.enqueuePackageReleaseScanInternal, { + releaseId: publishResult.releaseId, + source: "publish", + }); +} + export const reservePackageNameInternal = internalMutation({ args: { actorUserId: v.id("users"), diff --git a/convex/publishAttempts.test.ts b/convex/publishAttempts.test.ts new file mode 100644 index 00000000..8b72c29b --- /dev/null +++ b/convex/publishAttempts.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, it, vi } from "vitest"; +import { completePendingPublishAttemptChecksInternal } from "./publishAttempts"; + +const completePendingChecksHandler = ( + completePendingPublishAttemptChecksInternal as unknown as { + _handler: (ctx: unknown, args: unknown) => Promise; + } +)._handler; + +describe("publishAttempts", () => { + it("lets worker completion retries reclaim expired finalization leases", async () => { + const now = Date.now(); + const ctx = { + db: { + get: vi.fn(async () => ({ + _id: "publishAttempts:demo", + kind: "skill", + status: "finalizing", + artifactFingerprint: "fingerprint", + finalizationClaimExpiresAt: now - 1, + })), + patch: vi.fn(), + insert: vi.fn(), + replace: vi.fn(), + delete: vi.fn(), + query: vi.fn(), + normalizeId: vi.fn(), + system: {}, + }, + storage: { + delete: vi.fn(), + }, + }; + + await expect( + completePendingChecksHandler(ctx, { + attemptId: "publishAttempts:demo", + claimId: "checks:claim", + artifactFingerprint: "fingerprint", + trufflehog: { status: "clean" }, + clawscan: { status: "clean" }, + }), + ).resolves.toEqual({ + attemptId: "publishAttempts:demo", + kind: "skill", + status: "ready_to_finalize", + }); + + expect(ctx.db.patch).not.toHaveBeenCalled(); + }); + + it("emails the publisher when TruffleHog blocks a staged publish", async () => { + const ctx = { + db: { + get: vi + .fn() + .mockResolvedValueOnce({ + _id: "publishAttempts:demo", + kind: "skill", + status: "pending_checks", + userId: "users:publisher", + slug: "secret-skill", + version: "1.0.0", + artifactFingerprint: "fingerprint", + checkClaimId: "checks:claim", + checkClaimExpiresAt: Date.now() + 60_000, + files: [{ storageId: "_storage:secret-skill" }], + }) + .mockResolvedValueOnce({ + _id: "users:publisher", + handle: "publisher", + email: "publisher@example.com", + }), + patch: vi.fn(), + insert: vi.fn(), + replace: vi.fn(), + delete: vi.fn(), + query: vi.fn(), + normalizeId: vi.fn(), + system: {}, + }, + scheduler: { + runAfter: vi.fn(), + }, + storage: { + delete: vi.fn(), + }, + }; + + await expect( + completePendingChecksHandler(ctx, { + attemptId: "publishAttempts:demo", + claimId: "checks:claim", + artifactFingerprint: "fingerprint", + trufflehog: { + status: "blocked", + summary: "redacted TruffleHog finding", + redactedFindings: ["redacted-secret"], + }, + clawscan: { status: "clean" }, + }), + ).resolves.toMatchObject({ + attemptId: "publishAttempts:demo", + kind: "skill", + status: "blocked", + }); + + expect(ctx.storage.delete).toHaveBeenCalledWith("_storage:secret-skill"); + expect(ctx.db.patch).toHaveBeenCalledWith( + "publishAttempts:demo", + expect.objectContaining({ + status: "blocked", + files: [], + skillInsertArgs: undefined, + packageInsertArgs: undefined, + followup: undefined, + packageFollowup: undefined, + }), + ); + expect(ctx.scheduler.runAfter).toHaveBeenCalledWith(0, expect.anything(), { + attemptId: "publishAttempts:demo", + userId: "users:publisher", + to: "publisher@example.com", + handle: "publisher", + artifact: { kind: "skill", name: "secret-skill" }, + version: "1.0.0", + }); + }); + + it("keeps TruffleHog-positive attempts pending when secret storage deletion fails", async () => { + const ctx = { + db: { + get: vi.fn(async () => ({ + _id: "publishAttempts:demo", + kind: "skill", + status: "pending_checks", + userId: "users:publisher", + slug: "secret-skill", + version: "1.0.0", + artifactFingerprint: "fingerprint", + checkClaimId: "checks:claim", + checkClaimExpiresAt: Date.now() + 60_000, + files: [{ storageId: "_storage:secret-skill" }], + })), + patch: vi.fn(), + insert: vi.fn(), + replace: vi.fn(), + delete: vi.fn(), + query: vi.fn(), + normalizeId: vi.fn(), + system: {}, + }, + scheduler: { + runAfter: vi.fn(), + }, + storage: { + delete: vi.fn(async () => { + throw new Error("storage unavailable"); + }), + }, + }; + + await expect( + completePendingChecksHandler(ctx, { + attemptId: "publishAttempts:demo", + claimId: "checks:claim", + artifactFingerprint: "fingerprint", + trufflehog: { + status: "blocked", + summary: "redacted TruffleHog finding", + redactedFindings: ["redacted-secret"], + }, + clawscan: { status: "clean" }, + }), + ).rejects.toThrow("storage unavailable"); + + expect(ctx.db.patch).not.toHaveBeenCalled(); + expect(ctx.scheduler.runAfter).not.toHaveBeenCalled(); + }); + + it("deletes package artifacts when TruffleHog blocks a staged package publish", async () => { + const ctx = { + db: { + get: vi + .fn() + .mockResolvedValueOnce({ + _id: "publishAttempts:demo-package", + kind: "package", + status: "pending_checks", + userId: "users:publisher", + slug: "@demo/plugin", + version: "1.0.0", + artifactFingerprint: "fingerprint", + checkClaimId: "checks:claim", + checkClaimExpiresAt: Date.now() + 60_000, + files: [{ storageId: "_storage:manifest" }, { storageId: "_storage:artifact" }], + packageInsertArgs: { clawpackStorageId: "_storage:artifact" }, + }) + .mockResolvedValueOnce({ + _id: "users:publisher", + handle: "publisher", + email: "publisher@example.com", + }), + patch: vi.fn(), + insert: vi.fn(), + replace: vi.fn(), + delete: vi.fn(), + query: vi.fn(), + normalizeId: vi.fn(), + system: {}, + }, + scheduler: { + runAfter: vi.fn(), + }, + storage: { + delete: vi.fn(), + }, + }; + + await expect( + completePendingChecksHandler(ctx, { + attemptId: "publishAttempts:demo-package", + claimId: "checks:claim", + artifactFingerprint: "fingerprint", + trufflehog: { + status: "blocked", + summary: "redacted TruffleHog finding", + redactedFindings: ["redacted-secret"], + }, + clawscan: { status: "clean" }, + }), + ).resolves.toMatchObject({ + attemptId: "publishAttempts:demo-package", + kind: "package", + status: "blocked", + }); + + expect(ctx.storage.delete).toHaveBeenCalledTimes(2); + expect(ctx.storage.delete).toHaveBeenCalledWith("_storage:manifest"); + expect(ctx.storage.delete).toHaveBeenCalledWith("_storage:artifact"); + expect(ctx.db.patch).toHaveBeenCalledWith( + "publishAttempts:demo-package", + expect.objectContaining({ + status: "blocked", + files: [], + skillInsertArgs: undefined, + packageInsertArgs: undefined, + followup: undefined, + packageFollowup: undefined, + }), + ); + expect(ctx.scheduler.runAfter).toHaveBeenCalledWith(0, expect.anything(), { + attemptId: "publishAttempts:demo-package", + userId: "users:publisher", + to: "publisher@example.com", + handle: "publisher", + artifact: { kind: "plugin", name: "@demo/plugin" }, + version: "1.0.0", + }); + }); +}); diff --git a/convex/publishAttempts.ts b/convex/publishAttempts.ts new file mode 100644 index 00000000..9dfa617f --- /dev/null +++ b/convex/publishAttempts.ts @@ -0,0 +1,894 @@ +import { ConvexError, v } from "convex/values"; +import { internal } from "./_generated/api"; +import type { Id } from "./_generated/dataModel"; +import type { MutationCtx } from "./_generated/server"; +import { action, internalAction, internalMutation, internalQuery } from "./functions"; +import { finalizeSkillPublishAttempt } from "./lib/skillPublish"; + +const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; +const CHECK_CLAIM_LEASE_MS = 10 * 60 * 1000; +const FINALIZATION_CLAIM_LEASE_MS = 10 * 60 * 1000; + +const publishResultValidator = v.object({ + skillId: v.id("skills"), + versionId: v.id("skillVersions"), + embeddingId: v.id("skillEmbeddings"), +}); + +const packagePublishResultValidator = v.object({ + ok: v.boolean(), + packageId: v.id("packages"), + releaseId: v.id("packageReleases"), +}); + +const workerCheckResultValidator = v.object({ + status: v.union(v.literal("clean"), v.literal("blocked"), v.literal("failed")), + summary: v.optional(v.string()), + redactedFindings: v.optional(v.array(v.string())), +}); + +export const createSkillPublishAttemptInternal = internalMutation({ + args: { + userId: v.id("users"), + ownerPublisherId: v.optional(v.id("publishers")), + sourceOwnerPublisherId: v.optional(v.id("publishers")), + slug: v.string(), + displayName: v.string(), + version: v.string(), + idempotencyKey: v.string(), + artifactFingerprint: v.string(), + files: v.array( + v.object({ + path: v.string(), + size: v.number(), + storageId: v.id("_storage"), + sha256: v.string(), + contentType: v.optional(v.string()), + }), + ), + skillInsertArgs: v.any(), + followup: v.object({ + skipWebhook: v.optional(v.boolean()), + ownerHandle: v.optional(v.string()), + }), + }, + handler: async (ctx, args) => { + const existing = await findReusablePublishAttemptByIdempotencyKey(ctx, args.idempotencyKey); + if (existing) { + return { + attemptId: existing._id, + status: existing.status, + result: existing.result, + }; + } + + const now = Date.now(); + const attemptId = await ctx.db.insert("publishAttempts", { + kind: "skill", + status: "pending_checks", + userId: args.userId, + ownerPublisherId: args.ownerPublisherId, + sourceOwnerPublisherId: args.sourceOwnerPublisherId, + slug: args.slug, + displayName: args.displayName, + version: args.version, + idempotencyKey: args.idempotencyKey, + artifactFingerprint: args.artifactFingerprint, + files: args.files, + checks: { + trufflehog: { status: "pending" }, + clawscan: { status: "pending" }, + }, + skillInsertArgs: args.skillInsertArgs, + followup: args.followup, + createdAt: now, + updatedAt: now, + expiresAt: now + THIRTY_DAYS_MS, + }); + + return { attemptId, status: "pending_checks" as const, result: undefined }; + }, +}); + +async function findReusablePublishAttemptByIdempotencyKey( + ctx: MutationCtx, + idempotencyKey: string, +) { + const attempts = await ctx.db + .query("publishAttempts") + .withIndex("by_idempotency_key", (q) => q.eq("idempotencyKey", idempotencyKey)) + .order("desc") + .take(10); + return attempts.find((attempt) => !isTerminalRetriableAttemptStatus(attempt.status)) ?? null; +} + +function isTerminalRetriableAttemptStatus(status: string) { + return status === "blocked" || status === "failed" || status === "expired"; +} + +export const createPackagePublishAttemptInternal = internalMutation({ + args: { + userId: v.id("users"), + ownerUserId: v.id("users"), + ownerPublisherId: v.optional(v.id("publishers")), + name: v.string(), + displayName: v.string(), + version: v.string(), + idempotencyKey: v.string(), + artifactFingerprint: v.string(), + files: v.array( + v.object({ + path: v.string(), + size: v.number(), + storageId: v.id("_storage"), + sha256: v.string(), + contentType: v.optional(v.string()), + }), + ), + packageInsertArgs: v.any(), + packageFollowup: v.any(), + }, + handler: async (ctx, args) => { + const existing = await findReusablePublishAttemptByIdempotencyKey(ctx, args.idempotencyKey); + if (existing) { + return { + attemptId: existing._id, + status: existing.status, + result: existing.result, + }; + } + + const now = Date.now(); + const attemptId = await ctx.db.insert("publishAttempts", { + kind: "package", + status: "pending_checks", + userId: args.userId, + ownerUserId: args.ownerUserId, + ownerPublisherId: args.ownerPublisherId, + slug: args.name, + displayName: args.displayName, + version: args.version, + idempotencyKey: args.idempotencyKey, + artifactFingerprint: args.artifactFingerprint, + files: args.files, + checks: { + trufflehog: { status: "pending" }, + clawscan: { status: "pending" }, + }, + packageInsertArgs: args.packageInsertArgs, + packageFollowup: args.packageFollowup, + createdAt: now, + updatedAt: now, + expiresAt: now + THIRTY_DAYS_MS, + }); + + return { attemptId, status: "pending_checks" as const, result: undefined }; + }, +}); + +function getSecretBlockedStorageIds(attempt: { + files: Array<{ storageId: Id<"_storage"> }>; + packageInsertArgs?: unknown; +}) { + const storageIds = new Set>(attempt.files.map((file) => file.storageId)); + const packageInsertArgs = attempt.packageInsertArgs; + if (packageInsertArgs && typeof packageInsertArgs === "object") { + const clawpackStorageId = (packageInsertArgs as { clawpackStorageId?: unknown }) + .clawpackStorageId; + if (typeof clawpackStorageId === "string") { + storageIds.add(clawpackStorageId as Id<"_storage">); + } + } + return [...storageIds]; +} + +export const recordSkillPublishAttemptChecksPassedInternal = internalMutation({ + args: { + attemptId: v.id("publishAttempts"), + trufflehogSummary: v.optional(v.string()), + clawscanSummary: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const attempt = await requireSkillPublishAttempt(ctx, args.attemptId); + if (attempt.status === "finalized") { + return { attemptId: attempt._id, status: attempt.status, result: attempt.result }; + } + if (attempt.status !== "pending_checks" && attempt.status !== "ready_to_finalize") { + throw new ConvexError(`Publish attempt is ${attempt.status}, not pending checks.`); + } + + const now = Date.now(); + await ctx.db.patch(attempt._id, { + status: "ready_to_finalize", + checks: { + trufflehog: { + status: "clean", + checkedAt: now, + summary: args.trufflehogSummary, + }, + clawscan: { + status: "clean", + checkedAt: now, + summary: args.clawscanSummary, + }, + }, + updatedAt: now, + }); + + return { attemptId: attempt._id, status: "ready_to_finalize" as const, result: undefined }; + }, +}); + +export const completePendingPublishAttemptChecksInternal = internalMutation({ + args: { + attemptId: v.id("publishAttempts"), + claimId: v.string(), + artifactFingerprint: v.string(), + trufflehog: workerCheckResultValidator, + clawscan: workerCheckResultValidator, + }, + handler: async (ctx, args) => { + const attempt = await ctx.db.get(args.attemptId); + if (!attempt) throw new ConvexError("Publish attempt not found."); + if (attempt.artifactFingerprint !== args.artifactFingerprint) { + throw new ConvexError("Publish attempt artifact fingerprint does not match scanned input."); + } + if ( + attempt.status === "finalizing" && + (attempt.finalizationClaimExpiresAt ?? 0) <= Date.now() + ) { + return { attemptId: attempt._id, kind: attempt.kind, status: "ready_to_finalize" as const }; + } + if (attempt.status !== "pending_checks") { + return { attemptId: attempt._id, kind: attempt.kind, status: attempt.status }; + } + if (attempt.checkClaimId !== args.claimId || (attempt.checkClaimExpiresAt ?? 0) <= Date.now()) { + throw new ConvexError("Publish attempt check claim is not active."); + } + + const now = Date.now(); + const checks = { + trufflehog: { + status: args.trufflehog.status, + checkedAt: now, + summary: args.trufflehog.summary, + redactedFindings: args.trufflehog.redactedFindings, + }, + clawscan: { + status: args.clawscan.status, + checkedAt: now, + summary: args.clawscan.summary, + redactedFindings: args.clawscan.redactedFindings, + }, + }; + + if (args.trufflehog.status === "blocked") { + await Promise.all( + getSecretBlockedStorageIds(attempt).map((storageId) => ctx.storage.delete(storageId)), + ); + await ctx.db.patch(attempt._id, { + status: "blocked", + checks, + files: [], + skillInsertArgs: undefined, + packageInsertArgs: undefined, + followup: undefined, + packageFollowup: undefined, + checkClaimId: undefined, + checkClaimedAt: undefined, + checkClaimExpiresAt: undefined, + checkClaimLastError: undefined, + blockedAt: now, + updatedAt: now, + }); + await scheduleSecretPublishBlockedEmail(ctx, attempt); + return { attemptId: attempt._id, kind: attempt.kind, status: "blocked" as const }; + } + + if (args.clawscan.status === "blocked") { + await ctx.db.patch(attempt._id, { + status: "blocked", + checks, + checkClaimId: undefined, + checkClaimedAt: undefined, + checkClaimExpiresAt: undefined, + checkClaimLastError: undefined, + blockedAt: now, + updatedAt: now, + }); + return { attemptId: attempt._id, kind: attempt.kind, status: "blocked" as const }; + } + + if (args.trufflehog.status === "failed" || args.clawscan.status === "failed") { + await ctx.db.patch(attempt._id, { + status: "failed", + checks, + checkClaimId: undefined, + checkClaimedAt: undefined, + checkClaimExpiresAt: undefined, + checkClaimLastError: undefined, + failedAt: now, + updatedAt: now, + }); + return { attemptId: attempt._id, kind: attempt.kind, status: "failed" as const }; + } + + await ctx.db.patch(attempt._id, { + status: "ready_to_finalize", + checks, + checkClaimId: undefined, + checkClaimedAt: undefined, + checkClaimExpiresAt: undefined, + checkClaimLastError: undefined, + updatedAt: now, + }); + return { attemptId: attempt._id, kind: attempt.kind, status: "ready_to_finalize" as const }; + }, +}); + +export const claimPendingPublishAttemptChecksInternal = internalMutation({ + args: { + claimId: v.string(), + attemptId: v.optional(v.id("publishAttempts")), + kind: v.optional(v.union(v.literal("skill"), v.literal("package"))), + slug: v.optional(v.string()), + version: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const now = Date.now(); + const attempt = args.attemptId + ? await ctx.db.get(args.attemptId) + : ( + await ctx.db + .query("publishAttempts") + .withIndex("by_status_and_created", (q) => q.eq("status", "pending_checks")) + .order("asc") + .take(25) + ).find((candidate) => { + if ((candidate.checkClaimExpiresAt ?? 0) > now) return false; + if (args.kind && candidate.kind !== args.kind) return false; + if (args.slug && candidate.slug !== args.slug) return false; + if (args.version && candidate.version !== args.version) return false; + return true; + }); + + if (!attempt) return null; + if (attempt.status !== "pending_checks") { + throw new ConvexError(`Publish attempt is ${attempt.status}, not pending checks.`); + } + if (args.kind && attempt.kind !== args.kind) { + throw new ConvexError("Publish attempt kind does not match worker claim."); + } + if (args.slug && attempt.slug !== args.slug) { + throw new ConvexError("Publish attempt slug does not match worker claim."); + } + if (args.version && attempt.version !== args.version) { + throw new ConvexError("Publish attempt version does not match worker claim."); + } + if ((attempt.checkClaimExpiresAt ?? 0) > now && attempt.checkClaimId !== args.claimId) { + throw new ConvexError("Publish attempt checks are already claimed."); + } + + const checkClaimExpiresAt = now + CHECK_CLAIM_LEASE_MS; + await ctx.db.patch(attempt._id, { + checkClaimId: args.claimId, + checkClaimedAt: now, + checkClaimExpiresAt, + checkClaimLastError: undefined, + updatedAt: now, + }); + + return { + attemptId: attempt._id, + claimId: args.claimId, + kind: attempt.kind, + userId: attempt.userId, + ownerUserId: attempt.ownerUserId, + ownerPublisherId: attempt.ownerPublisherId, + sourceOwnerPublisherId: attempt.sourceOwnerPublisherId, + slug: attempt.slug, + displayName: attempt.displayName, + version: attempt.version, + artifactFingerprint: attempt.artifactFingerprint, + files: attempt.files, + checkClaimExpiresAt, + createdAt: attempt.createdAt, + }; + }, +}); + +export const claimSkillPublishAttemptForFinalizationInternal = internalMutation({ + args: { + attemptId: v.id("publishAttempts"), + claimId: v.string(), + }, + handler: async (ctx, args) => { + const attempt = await requireSkillPublishAttempt(ctx, args.attemptId); + const now = Date.now(); + if (attempt.status === "finalized" && attempt.result) { + return { + status: "finalized" as const, + attemptId: attempt._id, + result: attempt.result, + followup: buildSkillPublishFollowup(attempt), + }; + } + if (attempt.status === "finalizing" && (attempt.finalizationClaimExpiresAt ?? 0) > now) { + throw new ConvexError("Publish attempt is already finalizing."); + } + if (attempt.status !== "ready_to_finalize" && attempt.status !== "finalizing") { + throw new ConvexError(`Publish attempt is ${attempt.status}, not ready to finalize.`); + } + + await ctx.db.patch(attempt._id, { + status: "finalizing", + finalizationClaimId: args.claimId, + finalizationClaimedAt: now, + finalizationClaimExpiresAt: now + FINALIZATION_CLAIM_LEASE_MS, + finalizationLastError: undefined, + updatedAt: now, + }); + + return { + status: "claimed" as const, + attemptId: attempt._id, + skillInsertArgs: attempt.skillInsertArgs, + followup: buildSkillPublishFollowup(attempt), + }; + }, +}); + +export const claimPackagePublishAttemptForFinalizationInternal = internalMutation({ + args: { + attemptId: v.id("publishAttempts"), + claimId: v.string(), + }, + handler: async (ctx, args) => { + const attempt = await requirePackagePublishAttempt(ctx, args.attemptId); + const now = Date.now(); + if (attempt.status === "finalized" && attempt.result) { + return { + status: "finalized" as const, + attemptId: attempt._id, + result: attempt.result, + packageFollowup: attempt.packageFollowup, + }; + } + if (attempt.status === "finalizing" && (attempt.finalizationClaimExpiresAt ?? 0) > now) { + throw new ConvexError("Publish attempt is already finalizing."); + } + if (attempt.status !== "ready_to_finalize" && attempt.status !== "finalizing") { + throw new ConvexError(`Publish attempt is ${attempt.status}, not ready to finalize.`); + } + + await ctx.db.patch(attempt._id, { + status: "finalizing", + finalizationClaimId: args.claimId, + finalizationClaimedAt: now, + finalizationClaimExpiresAt: now + FINALIZATION_CLAIM_LEASE_MS, + finalizationLastError: undefined, + updatedAt: now, + }); + + return { + status: "claimed" as const, + attemptId: attempt._id, + packageInsertArgs: attempt.packageInsertArgs, + packageFollowup: attempt.packageFollowup, + }; + }, +}); + +export const releaseSkillPublishAttemptFinalizationClaimInternal = internalMutation({ + args: { + attemptId: v.id("publishAttempts"), + claimId: v.string(), + error: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const attempt = await requireSkillPublishAttempt(ctx, args.attemptId); + if (attempt.status !== "finalizing" || attempt.finalizationClaimId !== args.claimId) { + return { attemptId: attempt._id, status: attempt.status }; + } + + await ctx.db.patch(attempt._id, { + status: "ready_to_finalize", + finalizationClaimId: undefined, + finalizationClaimedAt: undefined, + finalizationClaimExpiresAt: undefined, + finalizationLastError: args.error, + updatedAt: Date.now(), + }); + return { attemptId: attempt._id, status: "ready_to_finalize" as const }; + }, +}); + +export const releasePackagePublishAttemptFinalizationClaimInternal = internalMutation({ + args: { + attemptId: v.id("publishAttempts"), + claimId: v.string(), + error: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const attempt = await requirePackagePublishAttempt(ctx, args.attemptId); + if (attempt.status !== "finalizing" || attempt.finalizationClaimId !== args.claimId) { + return { attemptId: attempt._id, status: attempt.status }; + } + + await ctx.db.patch(attempt._id, { + status: "ready_to_finalize", + finalizationClaimId: undefined, + finalizationClaimedAt: undefined, + finalizationClaimExpiresAt: undefined, + finalizationLastError: args.error, + updatedAt: Date.now(), + }); + return { attemptId: attempt._id, status: "ready_to_finalize" as const }; + }, +}); + +export const recordSkillPublishAttemptFinalizedInternal = internalMutation({ + args: { + attemptId: v.id("publishAttempts"), + claimId: v.string(), + result: publishResultValidator, + }, + handler: async (ctx, args) => { + const attempt = await requireSkillPublishAttempt(ctx, args.attemptId); + if (attempt.status === "finalized" && attempt.result) { + return { attemptId: attempt._id, status: attempt.status, result: attempt.result }; + } + const now = Date.now(); + if ( + attempt.status !== "finalizing" || + attempt.finalizationClaimId !== args.claimId || + (attempt.finalizationClaimExpiresAt ?? 0) <= now + ) { + throw new ConvexError("Publish attempt finalization claim is not active."); + } + + await ctx.db.patch(attempt._id, { + status: "finalized", + finalizationClaimId: undefined, + finalizationClaimedAt: undefined, + finalizationClaimExpiresAt: undefined, + finalizationLastError: undefined, + result: args.result, + finalizedAt: now, + updatedAt: now, + }); + + return { attemptId: attempt._id, status: "finalized" as const, result: args.result }; + }, +}); + +export const recordPackagePublishAttemptFinalizedInternal = internalMutation({ + args: { + attemptId: v.id("publishAttempts"), + claimId: v.string(), + result: packagePublishResultValidator, + }, + handler: async (ctx, args) => { + const attempt = await requirePackagePublishAttempt(ctx, args.attemptId); + if (attempt.status === "finalized" && attempt.result) { + return { attemptId: attempt._id, status: attempt.status, result: attempt.result }; + } + const now = Date.now(); + if ( + attempt.status !== "finalizing" || + attempt.finalizationClaimId !== args.claimId || + (attempt.finalizationClaimExpiresAt ?? 0) <= now + ) { + throw new ConvexError("Publish attempt finalization claim is not active."); + } + + await ctx.db.patch(attempt._id, { + status: "finalized", + finalizationClaimId: undefined, + finalizationClaimedAt: undefined, + finalizationClaimExpiresAt: undefined, + finalizationLastError: undefined, + result: args.result, + finalizedAt: now, + updatedAt: now, + }); + + return { attemptId: attempt._id, status: "finalized" as const, result: args.result }; + }, +}); + +export const findSkillPublishAttemptPublicResultInternal = internalQuery({ + args: { + attemptId: v.id("publishAttempts"), + }, + handler: async (ctx, args) => { + const attempt = await requireSkillPublishAttempt(ctx, args.attemptId); + let ownerPublisherId = attempt.ownerPublisherId; + if (!ownerPublisherId) { + const personalPublishers = await ctx.db + .query("publishers") + .withIndex("by_linked_user", (q) => q.eq("linkedUserId", attempt.userId)) + .take(5); + ownerPublisherId = personalPublishers.find( + (publisher) => + publisher.kind === "user" && !publisher.deletedAt && !publisher.deactivatedAt, + )?._id; + } + + const skill = ownerPublisherId + ? await ctx.db + .query("skills") + .withIndex("by_owner_publisher_slug", (q) => + q.eq("ownerPublisherId", ownerPublisherId).eq("slug", attempt.slug), + ) + .unique() + : await ctx.db + .query("skills") + .withIndex("by_owner_slug", (q) => + q.eq("ownerUserId", attempt.userId).eq("slug", attempt.slug), + ) + .unique(); + if (!skill) return null; + + const version = await ctx.db + .query("skillVersions") + .withIndex("by_skill_version", (q) => + q.eq("skillId", skill._id).eq("version", attempt.version), + ) + .unique(); + if (!version || version.softDeletedAt || version.fingerprint !== attempt.artifactFingerprint) { + return null; + } + + const embedding = await ctx.db + .query("skillEmbeddings") + .withIndex("by_version", (q) => q.eq("versionId", version._id)) + .unique(); + if (!embedding) return null; + + return { + skillId: skill._id, + versionId: version._id, + embeddingId: embedding._id, + }; + }, +}); + +export const finalizeSkillPublishAttemptInternal = internalAction({ + args: { + attemptId: v.id("publishAttempts"), + }, + handler: async (ctx, args) => { + return await finalizeSkillPublishAttempt(ctx, args.attemptId); + }, +}); + +export const claimPrePublicationChecks: ReturnType = action({ + args: { + token: v.string(), + attemptId: v.optional(v.id("publishAttempts")), + kind: v.optional(v.union(v.literal("skill"), v.literal("package"))), + slug: v.optional(v.string()), + version: v.optional(v.string()), + }, + handler: async (ctx, args): Promise => { + assertWorkerToken(args.token); + const claimId = buildCheckClaimId(); + const claimed = (await ctx.runMutation( + internal.publishAttempts.claimPendingPublishAttemptChecksInternal, + { + claimId, + attemptId: args.attemptId, + kind: args.kind, + slug: args.slug, + version: args.version, + }, + )) as null | { + attemptId: Id<"publishAttempts">; + claimId: string; + kind: "skill" | "package"; + userId: Id<"users">; + ownerUserId?: Id<"users">; + ownerPublisherId?: Id<"publishers">; + sourceOwnerPublisherId?: Id<"publishers">; + slug: string; + displayName: string; + version: string; + artifactFingerprint: string; + files: Array<{ + path: string; + size: number; + storageId: Id<"_storage">; + sha256: string; + contentType?: string; + }>; + checkClaimExpiresAt: number; + createdAt: number; + }; + if (!claimed) return null; + + const files = await Promise.all( + claimed.files.map(async (file) => ({ + ...file, + url: await ctx.storage.getUrl(file.storageId), + })), + ); + return { ...claimed, files }; + }, +}); + +export const completePrePublicationChecks: ReturnType = action({ + args: { + token: v.string(), + attemptId: v.id("publishAttempts"), + claimId: v.string(), + artifactFingerprint: v.string(), + trufflehog: workerCheckResultValidator, + clawscan: workerCheckResultValidator, + }, + handler: async (ctx, args): Promise => { + assertWorkerToken(args.token); + const completed = (await ctx.runMutation( + internal.publishAttempts.completePendingPublishAttemptChecksInternal, + { + attemptId: args.attemptId, + claimId: args.claimId, + artifactFingerprint: args.artifactFingerprint, + trufflehog: args.trufflehog, + clawscan: args.clawscan, + }, + )) as { + attemptId: Id<"publishAttempts">; + kind: "skill" | "package"; + status: "blocked" | "failed" | "ready_to_finalize"; + }; + + if (completed.status !== "ready_to_finalize") return completed; + if (completed.kind === "skill") { + const result = await finalizeSkillPublishAttempt(ctx, completed.attemptId); + return { ...completed, status: "finalized" as const, result }; + } + + const result: unknown = await ctx.runAction( + internal.packages.finalizePackagePublishAttemptInternal, + { + attemptId: completed.attemptId, + }, + ); + return { ...completed, status: "finalized" as const, result }; + }, +}); + +function assertWorkerToken(token: string) { + const expected = process.env.SECURITY_SCAN_WORKER_TOKEN; + if (!expected || token !== expected) throw new ConvexError("Unauthorized"); +} + +function buildCheckClaimId() { + return typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : `${Date.now()}:${Math.random().toString(16).slice(2)}`; +} + +async function scheduleSecretPublishBlockedEmail( + ctx: MutationCtx, + attempt: { + _id: Id<"publishAttempts">; + userId: Id<"users">; + kind: "skill" | "package"; + slug: string; + version: string; + }, +) { + const user = await ctx.db.get(attempt.userId); + if (!user?.email) return; + await ctx.scheduler.runAfter( + 0, + internal.emailsNode.sendSecretPublishBlockedNotificationInternal, + { + attemptId: attempt._id, + userId: attempt.userId, + to: user.email, + handle: user.handle, + artifact: { + kind: attempt.kind === "skill" ? "skill" : "plugin", + name: attempt.slug, + }, + version: attempt.version, + }, + ); +} + +async function requireSkillPublishAttempt( + ctx: { db: { get: (id: Id<"publishAttempts">) => Promise } }, + attemptId: Id<"publishAttempts">, +) { + const attempt = await ctx.db.get(attemptId); + if (!attempt || typeof attempt !== "object") { + throw new ConvexError("Publish attempt not found."); + } + const typed = attempt as { + _id: Id<"publishAttempts">; + kind: "skill" | "package"; + status: + | "pending_checks" + | "ready_to_finalize" + | "finalizing" + | "finalized" + | "blocked" + | "failed" + | "expired"; + skillInsertArgs: unknown; + followup: { skipWebhook?: boolean; ownerHandle?: string }; + userId: Id<"users">; + ownerPublisherId?: Id<"publishers">; + slug: string; + version: string; + displayName: string; + artifactFingerprint: string; + finalizationClaimId?: string; + finalizationClaimExpiresAt?: number; + result?: { + skillId: Id<"skills">; + versionId: Id<"skillVersions">; + embeddingId: Id<"skillEmbeddings">; + }; + }; + if (typed.kind !== "skill" || !typed.skillInsertArgs || !typed.followup) { + throw new ConvexError("Skill publish attempt not found."); + } + return typed as typeof typed & { + kind: "skill"; + skillInsertArgs: unknown; + followup: { skipWebhook?: boolean; ownerHandle?: string }; + }; +} + +async function requirePackagePublishAttempt( + ctx: { db: { get: (id: Id<"publishAttempts">) => Promise } }, + attemptId: Id<"publishAttempts">, +) { + const attempt = await ctx.db.get(attemptId); + if (!attempt || typeof attempt !== "object") { + throw new ConvexError("Publish attempt not found."); + } + const typed = attempt as { + _id: Id<"publishAttempts">; + kind: "skill" | "package"; + status: + | "pending_checks" + | "ready_to_finalize" + | "finalizing" + | "finalized" + | "blocked" + | "failed" + | "expired"; + packageInsertArgs?: unknown; + packageFollowup?: unknown; + finalizationClaimId?: string; + finalizationClaimExpiresAt?: number; + result?: { + ok: true; + packageId: Id<"packages">; + releaseId: Id<"packageReleases">; + }; + }; + if (typed.kind !== "package" || !typed.packageInsertArgs) { + throw new ConvexError("Package publish attempt not found."); + } + return typed; +} + +function buildSkillPublishFollowup(attempt: { + followup: { skipWebhook?: boolean; ownerHandle?: string }; + slug: string; + version: string; + displayName: string; +}) { + return { + ...attempt.followup, + slug: attempt.slug, + version: attempt.version, + displayName: attempt.displayName, + }; +} diff --git a/convex/schema.ts b/convex/schema.ts index 8bbacf4c..71dce73f 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -1079,6 +1079,77 @@ const skillVersions = defineTable({ .index("by_sha256hash", ["sha256hash"]) .index("by_dep_registry_scan_status_and_created", ["depRegistryScanStatus", "createdAt"]); +const publishAttemptStatusValidator = v.union( + v.literal("pending_checks"), + v.literal("ready_to_finalize"), + v.literal("finalizing"), + v.literal("finalized"), + v.literal("blocked"), + v.literal("failed"), + v.literal("expired"), +); + +const publishAttemptCheckStateValidator = v.object({ + status: v.union( + v.literal("pending"), + v.literal("clean"), + v.literal("blocked"), + v.literal("failed"), + ), + checkedAt: v.optional(v.number()), + summary: v.optional(v.string()), + redactedFindings: v.optional(v.array(v.string())), +}); + +const publishAttempts = defineTable({ + kind: v.union(v.literal("skill"), v.literal("package")), + status: publishAttemptStatusValidator, + userId: v.id("users"), + ownerUserId: v.optional(v.id("users")), + ownerPublisherId: v.optional(v.id("publishers")), + sourceOwnerPublisherId: v.optional(v.id("publishers")), + slug: v.string(), + displayName: v.string(), + version: v.string(), + idempotencyKey: v.string(), + artifactFingerprint: v.string(), + files: packageFilesValidator, + checks: v.object({ + trufflehog: publishAttemptCheckStateValidator, + clawscan: publishAttemptCheckStateValidator, + }), + skillInsertArgs: v.optional(v.any()), + packageInsertArgs: v.optional(v.any()), + followup: v.optional( + v.object({ + skipWebhook: v.optional(v.boolean()), + ownerHandle: v.optional(v.string()), + }), + ), + packageFollowup: v.optional(v.any()), + checkClaimId: v.optional(v.string()), + checkClaimedAt: v.optional(v.number()), + checkClaimExpiresAt: v.optional(v.number()), + checkClaimLastError: v.optional(v.string()), + finalizationClaimId: v.optional(v.string()), + finalizationClaimedAt: v.optional(v.number()), + finalizationClaimExpiresAt: v.optional(v.number()), + finalizationLastError: v.optional(v.string()), + result: v.optional(v.any()), + createdAt: v.number(), + updatedAt: v.number(), + expiresAt: v.number(), + finalizedAt: v.optional(v.number()), + blockedAt: v.optional(v.number()), + failedAt: v.optional(v.number()), +}) + .index("by_idempotency_key", ["idempotencyKey"]) + .index("by_status_and_created", ["status", "createdAt"]) + .index("by_expires_at", ["expiresAt"]) + .index("by_kind_status_slug_version_created", ["kind", "status", "slug", "version", "createdAt"]) + .index("by_user_status_created", ["userId", "status", "createdAt"]) + .index("by_owner_publisher_status_created", ["ownerPublisherId", "status", "createdAt"]); + const skillVersionFingerprints = defineTable({ skillId: v.id("skills"), versionId: v.id("skillVersions"), @@ -3132,6 +3203,7 @@ export default defineSchema({ packageTopicSearchDigest, packagePluginCategorySearchDigest, skillVersions, + publishAttempts, skillVersionFingerprints, skillBadges, skillEmbeddings, diff --git a/convex/skills.ts b/convex/skills.ts index a820d1b0..2dbe956d 100644 --- a/convex/skills.ts +++ b/convex/skills.ts @@ -135,9 +135,9 @@ import { import { isPublicSkillVersionAvailableForSkill } from "./lib/skillFileAccess"; import { fetchText, - type PublishResult, - publishVersionForUser, queueHighlightedWebhook, + stageSkillPublishAttemptForUser, + type SkillPublishResult, } from "./lib/skillPublish"; import { getFrontmatterValue, hashSkillFiles } from "./lib/skills"; import { @@ -9705,7 +9705,7 @@ export const publishVersion: ReturnType = action({ }), ), }, - handler: async (ctx, args): Promise => { + handler: async (ctx, args): Promise => { if (args.acceptLicenseTerms !== true) { throw new ConvexError("MIT-0 license terms must be accepted to publish skills"); } @@ -9728,15 +9728,20 @@ export const publishVersion: ReturnType = action({ })) as { publisherId: Id<"publishers"> }) : null; const { icon: _legacyIcon, ...publishArgs } = args; - return publishVersionForUser(ctx, userId, publishArgs, { + return stageSkillPublishAttemptForUser(ctx, userId, publishArgs, { ownerPublisherId: target.publisherId, ownerHandle: target.handle, sourceOwnerPublisherId: source?.publisherId, migrateOwner: args.migrateOwner, + stagePrePublicationChecks: stagedPrePublicationPublishesEnabled(), }); }, }); +function stagedPrePublicationPublishesEnabled() { + return process.env.CLAWHUB_STAGED_PREPUBLICATION_PUBLISHES === "1"; +} + export const generateChangelogPreview = action({ args: { slug: v.string(), diff --git a/e2e/helpers/runtimeErrors.ts b/e2e/helpers/runtimeErrors.ts index 8156b590..cc20d690 100644 --- a/e2e/helpers/runtimeErrors.ts +++ b/e2e/helpers/runtimeErrors.ts @@ -2,12 +2,19 @@ import { expect, type ConsoleMessage, type Page } from "@playwright/test"; import { isKnownOpenClawMediaUrl } from "./externalMedia"; const EXTERNAL_RESOURCE_DNS_ERROR = "Failed to load resource: net::ERR_NAME_NOT_RESOLVED"; +const TRANSIENT_CHROMIUM_RESOURCE_ERRORS = new Set([ + "Failed to load resource: net::ERR_NETWORK_CHANGED", +]); function isIgnoredExternalResourceDnsError(message: ConsoleMessage) { if (message.text() !== EXTERNAL_RESOURCE_DNS_ERROR) return false; return isKnownOpenClawMediaUrl(message.location().url); } +function isIgnoredTransientResourceError(message: ConsoleMessage) { + return TRANSIENT_CHROMIUM_RESOURCE_ERRORS.has(message.text()); +} + export function trackRuntimeErrors(page: Page) { const errors: string[] = []; @@ -18,12 +25,19 @@ export function trackRuntimeErrors(page: Page) { page.on("console", (message) => { if (message.type() !== "error") return; if (isIgnoredExternalResourceDnsError(message)) return; + if (isIgnoredTransientResourceError(message)) return; errors.push(`console:${message.text()}`); }); return errors; } +// React production builds report recoverable hydration mismatches as #418 page errors. +// Keep the filter opt-in so tests still fail on unexpected hydration regressions by default. +export function withoutRecoverableReactHydrationErrors(errors: string[]) { + return errors.filter((error) => !error.includes("pageerror:Minified React error #418")); +} + export async function expectNoRuntimeErrors(page: Page, errors: string[]) { await expect .poll(() => errors, { diff --git a/e2e/local-auth/helpers.ts b/e2e/local-auth/helpers.ts index f4eabaa4..9e527a2b 100644 --- a/e2e/local-auth/helpers.ts +++ b/e2e/local-auth/helpers.ts @@ -1,6 +1,8 @@ import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, type Page, type TestInfo } from "@playwright/test"; +import convexBrowser from "convex/browser"; +import { api } from "../../convex/_generated/api"; import { buildPublisherProfileHref, buildSkillDetailHref } from "../../src/lib/ownerRoute"; import { buildPluginDetailHref, @@ -10,6 +12,8 @@ import { import { waitForHydration } from "../helpers/runtimeErrors"; type DevPersona = "owner" | "user" | "admin" | "abusePublisher"; +const WORKER_TOKEN = process.env.SECURITY_SCAN_WORKER_TOKEN ?? "local-e2e-worker-token"; +const { ConvexHttpClient } = convexBrowser; // The quality gate fingerprints line shape, so vary local-auth fixtures by slug. const FINGERPRINT_SALT_LINES = [ @@ -90,7 +94,7 @@ function skillDetailPath(ownerHandle: string, slug: string) { return buildSkillDetailHref(ownerHandle, slug); } -async function publishedSkillVersionExists( +export async function publishedSkillVersionExists( page: Page, args: { ownerHandle: string; @@ -109,6 +113,53 @@ async function publishedSkillVersionExists( return body?.version?.version === args.version; } +function convexClient() { + const convexUrl = process.env.VITE_CONVEX_URL; + if (!convexUrl) throw new Error("VITE_CONVEX_URL is required"); + return new ConvexHttpClient(convexUrl); +} + +export async function completeMockPrePublicationChecks(args: { + kind: "skill" | "package"; + slug: string; + version: string; + trufflehog?: "clean" | "blocked"; + clawscan?: "clean" | "blocked"; +}) { + const claim = (await convexClient().action(api.publishAttempts.claimPrePublicationChecks, { + token: WORKER_TOKEN, + kind: args.kind, + slug: args.slug, + version: args.version, + })) as null | { + attemptId: string; + claimId: string; + artifactFingerprint: string; + }; + if (!claim) { + throw new Error(`No pending ${args.kind} publish attempt for ${args.slug}@${args.version}`); + } + + return await convexClient().action(api.publishAttempts.completePrePublicationChecks, { + token: WORKER_TOKEN, + attemptId: claim.attemptId, + claimId: claim.claimId, + artifactFingerprint: claim.artifactFingerprint, + trufflehog: { + status: args.trufflehog ?? "clean", + summary: + args.trufflehog === "blocked" + ? "Mock TruffleHog found a redacted secret in the local e2e fixture." + : "Mock TruffleHog found no secrets in the local e2e fixture.", + redactedFindings: args.trufflehog === "blocked" ? ["redacted-secret"] : undefined, + }, + clawscan: { + status: args.clawscan ?? "clean", + summary: "Mock ClawScan completed for the local e2e fixture.", + }, + }); +} + function devPersonaHeaderPattern(persona: DevPersona, expectedHandle: string) { const displayName = persona === "owner" @@ -246,15 +297,28 @@ function parseOwnerHandle(text: string) { async function isNativeOwnerSelect(page: Page, selector: string) { const ownerControl = page.locator(selector); await ownerControl.waitFor({ state: "attached" }); - return await ownerControl.evaluate((node) => node.tagName.toLowerCase() === "select"); + return await ownerControl.evaluate( + (node) => node.tagName.toLowerCase() === "select" && node.checkVisibility(), + ); } async function getSelectedOwnerHandle(page: Page, selector: string) { - const ownerControl = page.locator(selector); + const ownerControl = page.locator(selector).first(); if (await isNativeOwnerSelect(page, selector)) { - return await ownerControl.inputValue(); + const value = await ownerControl.inputValue(); + if (value) return value; } - return parseOwnerHandle(await ownerControl.innerText()); + const directText = await ownerControl.innerText().catch(() => ""); + const directHandle = parseOwnerHandle(directText); + if (directHandle) return directHandle; + + const visibleComboboxText = await page + .getByRole("combobox", { name: "Publishing as" }) + .filter({ hasText: /@/ }) + .first() + .innerText({ timeout: 500 }) + .catch(() => ""); + return parseOwnerHandle(visibleComboboxText); } export async function expectOwnerHandleSelected( @@ -362,17 +426,20 @@ export async function publishSkillVersion( versionLabel: string; changelog: string; versionExists?: () => Promise; + skillMarkdown?: string; + completeChecks?: boolean; }, ) { const skillDir = testInfo.outputPath(`${args.slug}-${args.version}`); await mkdir(skillDir, { recursive: true }); await writeFile( join(skillDir, "SKILL.md"), - skillMd({ - slug: args.slug, - displayName: args.displayName, - versionLabel: args.versionLabel, - }), + args.skillMarkdown ?? + skillMd({ + slug: args.slug, + displayName: args.displayName, + versionLabel: args.versionLabel, + }), "utf8", ); @@ -388,11 +455,15 @@ export async function publishSkillVersion( await expect(publishButton).toBeEnabled({ timeout: 30_000 }); publishUrl = page.url(); await publishButton.click({ timeout: 15_000 }); + const pendingChecks = page.getByText("Running TruffleHog and ClawScan", { exact: false }); await expect .poll( async () => { if (await hasDuplicateVersionAlert(page, args.version)) return "duplicate"; if (await versionExists()) return "published"; + if (await pendingChecks.isVisible({ timeout: 500 }).catch(() => false)) { + return "pending"; + } if (!args.versionExists && detailUrlPattern.test(new URL(page.url()).pathname)) { return "detail"; } @@ -401,6 +472,19 @@ export async function publishSkillVersion( { timeout: 60_000, intervals: [500, 1_000, 2_000] }, ) .not.toBe(""); + if (await pendingChecks.isVisible({ timeout: 500 }).catch(() => false)) { + if (args.completeChecks === false) { + return args.ownerHandle; + } + await completeMockPrePublicationChecks({ + kind: "skill", + slug: args.slug, + version: args.version, + }); + await expect + .poll(versionExists, { timeout: 60_000, intervals: [500, 1_000, 2_000] }) + .toBe(true); + } if (detailUrlPattern.test(new URL(page.url()).pathname)) break; await page.goto(skillDetailPath(args.ownerHandle, args.slug), { waitUntil: "domcontentloaded", diff --git a/e2e/local-auth/malicious-skill-ban-flow.pw.test.ts b/e2e/local-auth/malicious-skill-ban-flow.pw.test.ts index 3a64ecc1..f676a068 100644 --- a/e2e/local-auth/malicious-skill-ban-flow.pw.test.ts +++ b/e2e/local-auth/malicious-skill-ban-flow.pw.test.ts @@ -4,7 +4,12 @@ import { expect, test } from "@playwright/test"; import convexBrowser from "convex/browser"; import { api } from "../../convex/_generated/api"; import type { Id } from "../../convex/_generated/dataModel"; -import { expectHealthyPage, trackRuntimeErrors, waitForHydration } from "../helpers/runtimeErrors"; +import { + expectHealthyPage, + trackRuntimeErrors, + waitForHydration, + withoutRecoverableReactHydrationErrors, +} from "../helpers/runtimeErrors"; import { buildSkillDetailHref, publishSkillVersion, signInAsLocalPublisher } from "./helpers"; test.skip( @@ -233,7 +238,7 @@ function withoutExpectedBannedSessionTeardownErrors(errors: string[]) { "CONVEX Q(publishers:getMyProfileHandle)", "CONVEX M(packages:applyBanToOwnedPackagesBatchInternal)", ]; - return errors.filter( + return withoutRecoverableReactHydrationErrors(errors).filter( (error) => error !== "console:Failed to load resource: the server responded with a status of 503 (Service Unavailable)" && diff --git a/e2e/local-auth/plugin-inspector-findings.pw.test.ts b/e2e/local-auth/plugin-inspector-findings.pw.test.ts index fd0c9bb7..79cf553f 100644 --- a/e2e/local-auth/plugin-inspector-findings.pw.test.ts +++ b/e2e/local-auth/plugin-inspector-findings.pw.test.ts @@ -1,5 +1,5 @@ import { writeFile } from "node:fs/promises"; -import { expect, type Page, test, type TestInfo } from "@playwright/test"; +import { expect, type APIRequestContext, type Page, test, type TestInfo } from "@playwright/test"; import { strToU8, zipSync } from "fflate"; import { expectNoFatalErrorUi, @@ -7,7 +7,13 @@ import { trackRuntimeErrors, waitForHydration, } from "../helpers/runtimeErrors"; -import { buildPluginValidationHref, escapeRegExp, signInAsLocalPersona } from "./helpers"; +import { + buildPluginDetailHref, + buildPluginValidationHref, + completeMockPrePublicationChecks, + escapeRegExp, + signInAsLocalPersona, +} from "./helpers"; test.skip( process.env.VITE_ENABLE_DEV_AUTH !== "1", @@ -162,6 +168,25 @@ async function expectValidationSectionVisible(page: Page, warningName: string) { await expect(validationSection).toBeVisible({ timeout: 10_000 }); } +async function publicPackageVersionExists( + request: APIRequestContext, + name: string, + version: string, +) { + const siteUrl = process.env.VITE_CONVEX_SITE_URL; + if (!siteUrl) throw new Error("VITE_CONVEX_SITE_URL is required"); + const url = `${siteUrl.replace(/\/$/u, "")}/api/v1/packages/${encodeURIComponent( + name, + )}/versions/${encodeURIComponent(version)}`; + const response = await request.get(url, { timeout: 2_000 }).catch(() => null); + if (!response?.ok()) return false; + const body = (await response.json().catch(() => null)) as { + package?: { name?: unknown }; + version?: { version?: unknown }; + } | null; + return body?.package?.name === name && body?.version?.version === version; +} + async function publishWarningPluginWithRetry(args: { errors: string[]; page: Page; @@ -192,9 +217,14 @@ async function publishWarningPluginWithRetry(args: { const publishButton = args.page.getByRole("button", { name: "Publish plugin" }); await expect(publishButton).toBeEnabled({ timeout: 60_000 }); await publishButton.click({ timeout: 15_000 }); - await expect(args.page.getByText("Published. Pending security checks")).toBeVisible({ + await expect(args.page.getByText("Running TruffleHog and ClawScan")).toBeVisible({ timeout: 60_000, }); + await completeMockPrePublicationChecks({ + kind: "package", + slug: warningName, + version: "1.0.0", + }); return { warningDisplayName, warningName }; } catch (error) { lastError = error; @@ -247,6 +277,57 @@ async function publishHardErrorPluginWithRetry(args: { throw lastError; } +test("plugin publish stays private until mocked TruffleHog and ClawScan pass", async ({ + page, + request, +}, testInfo) => { + const errors = trackRuntimeErrors(page); + const suffix = Date.now().toString(36); + const name = `pw-staged-plugin-${suffix}`; + const displayName = `Playwright Staged Plugin ${suffix}`; + const version = "1.0.0"; + + await signInAsLocalPersona(page, "admin"); + await page.goto("/plugins/publish", { waitUntil: "domcontentloaded" }); + await waitForHydration(page); + await uploadPluginZip( + page, + await writePluginZip(testInfo, { + name, + displayName, + kind: "warning", + }), + ); + await expect(page.locator("#pluginName")).toHaveValue(name); + await page.locator("#pluginSourceCommit").fill("abc123"); + const publishButton = page.getByRole("button", { name: "Publish plugin" }); + await expect(publishButton).toBeEnabled({ timeout: 60_000 }); + await publishButton.click({ timeout: 15_000 }); + await expect(page.getByText("Running TruffleHog and ClawScan")).toBeVisible({ + timeout: 60_000, + }); + + await expect(await publicPackageVersionExists(request, name, version)).toBe(false); + await completeMockPrePublicationChecks({ + kind: "package", + slug: name, + version, + }); + await expect + .poll(() => publicPackageVersionExists(request, name, version), { + timeout: 60_000, + intervals: [500, 1_000, 2_000], + }) + .toBe(true); + + await page.goto(buildPluginDetailHref(name), { waitUntil: "domcontentloaded" }); + await waitForHydration(page); + await expect(page.locator("h1.skill-page-title", { hasText: displayName })).toBeVisible({ + timeout: 30_000, + }); + await expectHealthyInspectorPage(page, errors); +}); + test("plugin inspector blocks hard publish errors and publishes warning findings", async ({ page, }, testInfo) => { diff --git a/e2e/local-auth/publish-skill-lifecycle.pw.test.ts b/e2e/local-auth/publish-skill-lifecycle.pw.test.ts index 4ec86781..22e17849 100644 --- a/e2e/local-auth/publish-skill-lifecycle.pw.test.ts +++ b/e2e/local-auth/publish-skill-lifecycle.pw.test.ts @@ -1,4 +1,4 @@ -import { expect, type Page, test } from "@playwright/test"; +import { expect, type APIRequestContext, type Page, test } from "@playwright/test"; import convexBrowser from "convex/browser"; import { api } from "../../convex/_generated/api"; import type { Id } from "../../convex/_generated/dataModel"; @@ -7,8 +7,16 @@ import { expectNoRuntimeErrors, trackRuntimeErrors, waitForHydration, + withoutRecoverableReactHydrationErrors, } from "../helpers/runtimeErrors"; -import { expectOwnerHandleSelected, publishSkillVersion, signInAsLocalPublisher } from "./helpers"; +import { + completeMockPrePublicationChecks, + expectOwnerHandleSelected, + publishedSkillVersionExists, + publishSkillVersion, + signInAsLocalPublisher, + skillMd, +} from "./helpers"; test.skip( process.env.VITE_ENABLE_DEV_AUTH !== "1", @@ -66,7 +74,7 @@ async function expectHealthyPublishPage(page: Page, errors: string[]) { await expectNoFatalErrorUi(page); await expectNoRuntimeErrors( page, - errors.filter( + withoutRecoverableReactHydrationErrors(errors).filter( (error) => !( error.includes("Function execution timed out (maximum duration: 1s)") && @@ -168,6 +176,25 @@ async function waitForSkillCardEndpoint(page: Page, slug: string, markdown: stri ); } +async function publicSkillVersionExists( + request: APIRequestContext, + args: { + ownerHandle: string; + slug: string; + version: string; + }, +) { + const url = `${convexSiteUrl()}/api/v1/skills/${encodeURIComponent(args.slug)}/versions/${encodeURIComponent( + args.version, + )}?ownerHandle=${encodeURIComponent(args.ownerHandle)}`; + const response = await request.get(url, { timeout: 2_000 }).catch(() => null); + if (!response?.ok()) return false; + const body = (await response.json().catch(() => null)) as { + version?: { version?: unknown }; + } | null; + return body?.version?.version === args.version; +} + async function completeScanJob( client: ConvexHttpClientInstance, scanJob: ClaimedScanJob, @@ -280,6 +307,67 @@ test("publishing a skill queues scan, queues skill-card generation, and shows th await expectHealthyPublishPage(page, errors); }); +test("mocked TruffleHog blocks a secret-positive skill upload until the secret is removed", async ({ + page, + request, +}, testInfo) => { + const errors = trackRuntimeErrors(page); + const slug = `pw-secret-${Date.now().toString(36)}`; + const displayName = "Playwright Secret Block Skill"; + const ownerHandle = await signInAsLocalPublisher(page, "admin"); + const version = "1.0.0"; + const secretMarkdown = `${skillMd({ + slug, + displayName, + versionLabel: "secret-positive release", + })} + +## Local secret fixture + +This fake token is intentionally redacted by the mocked TruffleHog worker: +OPENAI_API_KEY=sk-local-e2e-redacted-secret-not-real +`; + + await publishSkillVersion(page, testInfo, { + ownerHandle, + slug, + displayName, + version, + versionLabel: "secret-positive release", + changelog: "Secret-positive release should remain private.", + skillMarkdown: secretMarkdown, + completeChecks: false, + }); + + await completeMockPrePublicationChecks({ + kind: "skill", + slug, + version, + trufflehog: "blocked", + }); + + await expect(await publicSkillVersionExists(request, { ownerHandle, slug, version })).toBe(false); + await expect(await publishedSkillVersionExists(page, { ownerHandle, slug, version })).toBe(false); + + await page.goto("/skills/publish", { waitUntil: "domcontentloaded" }); + await publishSkillVersion(page, testInfo, { + ownerHandle, + slug, + displayName, + version, + versionLabel: "clean retry release", + changelog: "Clean retry after removing the secret.", + skillMarkdown: skillMd({ + slug, + displayName, + versionLabel: "clean retry release", + }), + }); + + await expectCurrentVersion(page, version); + await expectHealthyPublishPage(page, errors); +}); + test("skill publishers can create a skill and publish a new version", async ({ page, }, testInfo) => { diff --git a/emails/secret-blocked-publish.tsx b/emails/secret-blocked-publish.tsx new file mode 100644 index 00000000..9235e16e --- /dev/null +++ b/emails/secret-blocked-publish.tsx @@ -0,0 +1,58 @@ +import { + Badge, + ClawHubEmailLayout, + DetailTable, + EmailHeading, + FindingCard, + Paragraph, +} from "./_components/clawhub"; + +export type SecretBlockedPublishEmailProps = { + artifactKind: "skill" | "plugin"; + artifactName: string; + version: string; + preheader: string; +}; + +export default function SecretBlockedPublishEmail({ + artifactKind, + artifactName, + version, + preheader, +}: SecretBlockedPublishEmailProps) { + const title = `ClawHub blocked a ${artifactKind} publish`; + const railLabel = artifactKind === "plugin" ? "Plugin Review" : "Skill Review"; + return ( + + Secret found + {title} + + TruffleHog found a secret-looking value in this upload. This version was not made public. + + + BLOCKED + , + ], + ]} + /> + + + ); +} + +SecretBlockedPublishEmail.PreviewProps = { + artifactKind: "skill", + artifactName: "secret-skill", + version: "1.0.0", + preheader: "secret-skill@1.0.0 was blocked before public listing because a secret was found.", +} satisfies SecretBlockedPublishEmailProps; diff --git a/scripts/run-playwright-local-auth.ts b/scripts/run-playwright-local-auth.ts index 386766d6..2b27758b 100644 --- a/scripts/run-playwright-local-auth.ts +++ b/scripts/run-playwright-local-auth.ts @@ -553,6 +553,7 @@ async function main() { { name: "AUTH_GITHUB_SECRET", value: e2eEnv.AUTH_GITHUB_SECRET ?? "local-dev" }, { name: "CLAWHUB_DISABLE_CRONS", value: "1" }, { name: "CLAWHUB_EMAIL_CAPTURE_FILE", value: e2eEnv.CLAWHUB_EMAIL_CAPTURE_FILE ?? "" }, + { name: "CLAWHUB_STAGED_PREPUBLICATION_PUBLISHES", value: "1" }, { name: "DEV_AUTH_CONVEX_DEPLOYMENT", value: localAuthDeployment }, { name: "DEV_AUTH_ENABLED", value: "1" }, { name: "JWKS", value: authKeys.JWKS }, diff --git a/src/__tests__/plugins-publish-route.test.tsx b/src/__tests__/plugins-publish-route.test.tsx index b2c401b4..b39dfed5 100644 --- a/src/__tests__/plugins-publish-route.test.tsx +++ b/src/__tests__/plugins-publish-route.test.tsx @@ -284,6 +284,13 @@ describe("plugins publish route", () => { await waitFor(() => { expect(publishRelease).toHaveBeenCalledTimes(1); }); + await waitFor(() => { + expect( + screen.getByText( + /Published\. Pending security checks and verification before public listing\./i, + ), + ).toBeTruthy(); + }); expect(generateUploadUrl).toHaveBeenCalledTimes(3); expect(fetchMock).toHaveBeenCalledTimes(3); @@ -829,7 +836,14 @@ describe("plugins publish route", () => { expect(publishRelease).not.toHaveBeenCalled(); }); - it("shows pending verification messaging after plugin publish", async () => { + it("shows pending verification messaging after staged plugin publish", async () => { + publishRelease.mockResolvedValueOnce({ + ok: true, + status: "pending", + attemptId: "publishAttempts:1", + packageName: "demo-plugin", + version: "1.0.0", + }); renderPublishRoute(); const packageJson = withRelativePath( @@ -865,7 +879,7 @@ describe("plugins publish route", () => { fireEvent.click(screen.getByRole("button", { name: "Publish plugin" })); expect( - await screen.findByText(/Pending security checks and verification before public listing\./i), + await screen.findByText(/Running TruffleHog and ClawScan before public listing\./i), ).toBeTruthy(); expect( screen.getByRole("button", { name: "Publish plugin" }).getAttribute("disabled"), diff --git a/src/__tests__/skills-publish-route.test.tsx b/src/__tests__/skills-publish-route.test.tsx index 3466a65f..01c2e05d 100644 --- a/src/__tests__/skills-publish-route.test.tsx +++ b/src/__tests__/skills-publish-route.test.tsx @@ -967,6 +967,49 @@ describe("Upload route", () => { expect(Object.hasOwn(args!, "icon")).toBe(false); }); + it("keeps publish disabled after staged skill publish is accepted", async () => { + generateUploadUrl.mockResolvedValue("https://upload.local"); + publishVersion.mockResolvedValueOnce({ + status: "pending", + attemptId: "publishAttempts:1", + slug: "pending-skill", + version: "1.0.0", + }); + render(); + + fireEvent.change(screen.getByPlaceholderText("skill-name"), { + target: { value: "pending-skill" }, + }); + fireEvent.change(screen.getByPlaceholderText("My skill"), { + target: { value: "Pending Skill" }, + }); + fireEvent.change(screen.getByPlaceholderText("1.0.0"), { + target: { value: "1.0.0" }, + }); + fireEvent.change(screen.getByPlaceholderText("latest, stable"), { + target: { value: "latest" }, + }); + fireEvent.change(screen.getByTestId("upload-input"), { + target: { files: [new File(["hello"], "SKILL.md", { type: "text/markdown" })] }, + }); + fireEvent.click( + screen.getByRole("checkbox", { + name: /i have the rights to publish this skill under mit-0/i, + }), + ); + + const publishButton = screen.getByRole("button", { name: /publish skill/i }); + await waitFor(() => { + expect(publishButton.getAttribute("disabled")).toBeNull(); + }); + fireEvent.click(publishButton); + + expect( + await screen.findByText(/Running TruffleHog and ClawScan before public listing\./i), + ).toBeTruthy(); + expect(publishButton.getAttribute("disabled")).not.toBeNull(); + }); + it("omits icon when republishing a skill that still has a stored legacy icon", async () => { useSearchMock.mockReturnValue({ updateSlug: "with-icon" }); useQueryMock.mockImplementation((fn: unknown, args: unknown) => { diff --git a/src/routes/plugins/publish.tsx b/src/routes/plugins/publish.tsx index ea13c136..3cfac93e 100644 --- a/src/routes/plugins/publish.tsx +++ b/src/routes/plugins/publish.tsx @@ -273,7 +273,8 @@ export function PublishPluginRoute() { }, [family, isMetadataLocked, name, sourceCommit, sourceRepo, version]); const hasPackageBlocker = Boolean(validationError) || Boolean(ownerScopeError) || codePluginFieldIssues.length > 0; - const hasPublished = status?.startsWith("Published.") ?? false; + const hasPublished = + status?.startsWith("Published.") || status?.startsWith("Publish received.") || false; const isPublishDisabled = !isAuthenticated || isMetadataLocked || @@ -809,7 +810,7 @@ export function PublishPluginRoute() { uploadFile, }); setStatus("Publishing release..."); - await publishRelease({ + const result = await publishRelease({ payload: { name: name.trim(), displayName: displayName.trim() || undefined, @@ -852,9 +853,20 @@ export function PublishPluginRoute() { files: uploaded, }, }); - setStatus( - "Published. Pending security checks and verification before public listing.", - ); + if ( + result && + typeof result === "object" && + "status" in result && + result.status === "pending" + ) { + setStatus( + "Publish received. Running TruffleHog and ClawScan before public listing.", + ); + } else { + setStatus( + "Published. Pending security checks and verification before public listing.", + ); + } } catch (publishError) { const message = formatPublishError(publishError); setError(message); diff --git a/src/routes/skills/publish.tsx b/src/routes/skills/publish.tsx index 54251ea0..f8dc3a17 100644 --- a/src/routes/skills/publish.tsx +++ b/src/routes/skills/publish.tsx @@ -154,7 +154,7 @@ export function Upload() { const changelogRequestRef = useRef(0); const changelogKeyRef = useRef(null); const [status, setStatus] = useState(null); - const isSubmitting = status !== null; + const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); const publisherMemberships = useQuery(api.publishers.listMine, me ? {} : "skip") as | PublisherOwnerMembership[] @@ -591,6 +591,9 @@ export function Upload() { return false; }); const hasFilePanelFooter = Boolean(ignoredLocalMetadataNote || visibleFileIssues.length > 0); + const hasPublished = + status?.startsWith("Published.") || status?.startsWith("Publish received.") || false; + const isPublishDisabled = !validation.ready || isSubmitting || hasPublished; const publishBlockerSummary = !validation.ready && !isSubmitting ? summarizePublishBlockers(validation.issues) : null; @@ -679,6 +682,7 @@ export function Upload() { async function handleSubmit(event: React.FormEvent) { event.preventDefault(); setHasAttempted(true); + if (hasPublished) return; if (!validation.ready) { const message = validation.issues[0] ?? "Fix validation issues to continue."; setError(message); @@ -715,36 +719,36 @@ export function Upload() { toast.error(msg); return; } + setIsSubmitting(true); setStatus("Uploading files…"); - - const uploaded = [] as Array<{ - path: string; - size: number; - storageId: string; - sha256: string; - contentType?: string; - }>; - - for (const file of files) { - const uploadUrl = await generateUploadUrl(); - const rawPath = (file.webkitRelativePath || file.name).replace(/^\.\//, ""); - const path = - stripRoot && rawPath.startsWith(`${stripRoot}/`) - ? rawPath.slice(stripRoot.length + 1) - : rawPath; - const sha256 = await hashFile(file); - const storageId = await uploadFile(uploadUrl, file); - uploaded.push({ - path, - size: file.size, - storageId, - sha256, - contentType: normalizeTextContentType(path, file.type) ?? file.type ?? undefined, - }); - } - - setStatus("Publishing…"); try { + const uploaded = [] as Array<{ + path: string; + size: number; + storageId: string; + sha256: string; + contentType?: string; + }>; + + for (const file of files) { + const uploadUrl = await generateUploadUrl(); + const rawPath = (file.webkitRelativePath || file.name).replace(/^\.\//, ""); + const path = + stripRoot && rawPath.startsWith(`${stripRoot}/`) + ? rawPath.slice(stripRoot.length + 1) + : rawPath; + const sha256 = await hashFile(file); + const storageId = await uploadFile(uploadUrl, file); + uploaded.push({ + path, + size: file.size, + storageId, + sha256, + contentType: normalizeTextContentType(path, file.type) ?? file.type ?? undefined, + }); + } + + setStatus("Publishing…"); const result = await publishVersion({ ownerHandle: ownerHandle || undefined, sourceOwnerHandle: @@ -773,6 +777,11 @@ export function Upload() { setHasAttempted(false); setChangelogSource("user"); if (result) { + if (typeof result === "object" && "status" in result && result.status === "pending") { + setStatus("Publish received. Running TruffleHog and ClawScan before public listing."); + toast.success("Publish received. Security checks are running."); + return; + } const ownerParam = ownerHandle || me?.handle || (me?._id ? String(me._id) : "unknown"); const didSetPostPublishFlash = setPostPublishFlash(ownerParam, trimmedSlug); if (!didSetPostPublishFlash) { @@ -789,6 +798,8 @@ export function Upload() { const message = formatPublishError(publishError); setError(message); toast.error(message); + } finally { + setIsSubmitting(false); } } @@ -1315,7 +1326,7 @@ export function Upload() { variant="primary" size="lg" type="submit" - disabled={!validation.ready || isSubmitting} + disabled={isPublishDisabled} loading={isSubmitting} > {!validation.ready && !isSubmitting ? ( diff --git a/vitest.config.ts b/vitest.config.ts index b4af37c4..548af8a2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -34,6 +34,7 @@ export default defineConfig({ hookTimeout: 15_000, exclude: [ "**/node_modules/**", + "**/.artifacts/**", "**/.vercel/output/**", "**/.output/**", "**/.nitro/**", @@ -64,6 +65,7 @@ export default defineConfig({ ], exclude: [ "node_modules/", + ".artifacts/", ".vercel/output/", ".output/", ".nitro/",