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/",