feat: gate public publishes without breaking old CLIs

Closes CLAW-526.\n\nSummary:\n- create pending skill versions and plugin releases that remain hidden until TruffleHog and ClawScan pass\n- preserve older CLI response compatibility while newer CLI output explains pending security checks\n- run prepublication worker promotion/blocking for skills and plugins\n- add local-auth coverage for clean skill/plugin publish and secret-positive skill rejection\n\nValidation on PR head d2482434:\n- local: bunx tsc -p packages/schema/tsconfig.json --noEmit\n- local: bunx tsc -p packages/clawhub/tsconfig.json --noEmit\n- local: bunx vitest run convex/lib/skillPublish.test.ts convex/publishAttempts.test.ts convex/skills.versions.public.test.ts convex/packages.public.test.ts packages/schema/src/schemas.test.ts scripts/security/run-prepublication-worker.test.ts scripts/security/prepublication-worker-workflow.test.ts\n- local: bun run ci:static\n- local: bun run ci:types-build && bun run ci:packages\n- GitHub: pr-gates, static, unit, packages, types-build, e2e-http, old-cli-publish, playwright-smoke, secret scanning, CodeQL, and Vercel preview passed\n\nKnown CI note:\n- unrelated local-auth shards continued to rotate failures under the already-diagnosed local Convex starvation issue; ignored per maintainer instruction.
This commit is contained in:
Patrick Erichsen
2026-07-16 11:38:40 -07:00
committed by GitHub
parent 10bc0a0b41
commit b23d10d989
51 changed files with 4689 additions and 1169 deletions
+2
View File
@@ -179,6 +179,8 @@ jobs:
- name: publish-new-version
specs: e2e/local-auth/publish-skill-lifecycle.pw.test.ts
grep: skill publishers can create a skill
- name: old-cli-publish
specs: e2e/local-auth/old-cli-publish-compat.pw.test.ts
steps:
- uses: actions/checkout@v7
@@ -49,46 +49,25 @@ jobs:
PREPUBLICATION_CHECK_LIMIT: ${{ inputs['batch-limit'] || '2' }}
PREPUBLICATION_CHECK_MAX_JOBS: ${{ inputs['max-jobs'] || '' }}
PREPUBLICATION_CHECK_MAX_RUNTIME_MINUTES: ${{ inputs['max-runtime-minutes'] || '8' }}
CODEX_SECURITY_SCAN_TIMEOUT_MS: ${{ vars.CODEX_SECURITY_SCAN_TIMEOUT_MS || '240000' }}
PREPUBLICATION_CLAWSCAN_TIMEOUT_MS: ${{ vars.PREPUBLICATION_CLAWSCAN_TIMEOUT_MS || '240000' }}
PREPUBLICATION_TRUFFLEHOG_IMAGE: ${{ vars.PREPUBLICATION_TRUFFLEHOG_IMAGE || 'ghcr.io/trufflesecurity/trufflehog:3.95.6@sha256:96f8429082cb2d4ae73b1096dcdb2f5aa139881d97042b0c5e5fa226a392e056' }}
PREPUBLICATION_WORKER_ID: "github-actions:${{ github.run_id }}:${{ github.run_attempt }}:${{ matrix.shard }}"
SKILLSPECTOR_PROVIDER: openai
steps:
- uses: actions/checkout@v7
- uses: ./.github/actions/setup-bun
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install Codex CLI
- name: Install ClawScan CLI
run: |
set -euo pipefail
if ! command -v codex >/dev/null 2>&1; then
npm install -g @openai/codex@0.142.3
fi
codex --version
- name: Install SkillSpector
run: |
set -euo pipefail
python -m venv "$RUNNER_TEMP/skillspector-venv"
source "$RUNNER_TEMP/skillspector-venv/bin/activate"
python -m pip install --upgrade pip
python -m pip install 'git+https://github.com/NVIDIA/skillspector.git@8f37cfa'
echo "$RUNNER_TEMP/skillspector-venv/bin" >> "$GITHUB_PATH"
skillspector --help >/dev/null
- name: Authenticate Codex CLI
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: printf '%s' "$OPENAI_API_KEY" | codex login --with-api-key
npm install -g @openclaw/clawscan@0.1.2
clawscan --version
- name: Run pre-publication publish worker
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
SECURITY_SCAN_WORKER_TOKEN: ${{ secrets.SECURITY_SCAN_WORKER_TOKEN }}
VIRUSTOTAL_API_KEY: ${{ secrets.VT_API_KEY }}
run: |
bun run publish:prepublication-worker -- \
--batch-limit "$PREPUBLICATION_CHECK_LIMIT" \
+148
View File
@@ -0,0 +1,148 @@
name: Preview Proof
on:
workflow_dispatch:
inputs:
preview_url:
description: "Vercel preview URL to verify"
required: true
type: string
checkout_ref:
description: "Optional git ref or SHA to check out; use this when dispatching from main for a PR preview"
required: false
type: string
expected_sha:
description: "Expected git SHA after checkout"
required: false
type: string
permissions:
contents: read
jobs:
dispatch-info:
name: preview-proof-dispatch-info
runs-on: ubuntu-latest
steps:
- name: Summarize dispatch
env:
CHECKOUT_REF: ${{ inputs.checkout_ref }}
EXPECTED_SHA: ${{ inputs.expected_sha }}
PREVIEW_URL: ${{ inputs.preview_url }}
run: |
{
echo "## Preview proof dispatch"
echo
echo "- Workflow ref: \`${GITHUB_REF}\`"
echo "- Workflow SHA: \`${GITHUB_SHA}\`"
echo "- Checkout ref: \`${CHECKOUT_REF:-$GITHUB_SHA}\`"
echo "- Expected SHA: \`${EXPECTED_SHA:-not supplied}\`"
echo "- Preview URL: ${PREVIEW_URL}"
} >> "$GITHUB_STEP_SUMMARY"
if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then
echo "::notice::Protected preview proof only runs from refs/heads/main. Re-run from main with checkout_ref set to the PR SHA."
fi
preview-proof:
name: preview-proof
needs: dispatch-info
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 15
environment:
name: Test
steps:
- uses: actions/checkout@v7
with:
ref: ${{ inputs.checkout_ref || github.sha }}
- name: Verify checked-out revision
env:
EXPECTED_SHA: ${{ inputs.expected_sha }}
run: |
set -euo pipefail
actual_sha="$(git rev-parse HEAD)"
echo "actual_sha=$actual_sha" >> "$GITHUB_ENV"
if [[ -n "$EXPECTED_SHA" && "$actual_sha" != "$EXPECTED_SHA" ]]; then
echo "::error::Expected $EXPECTED_SHA but checked out $actual_sha"
exit 1
fi
- uses: ./.github/actions/setup-bun
- name: Verify protected preview public routes
env:
PREVIEW_URL: ${{ inputs.preview_url }}
VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }}
run: |
set -euo pipefail
bun <<'TS'
const previewUrl = process.env.PREVIEW_URL?.trim();
const bypassSecret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET?.trim();
if (!previewUrl) throw new Error("PREVIEW_URL is required");
if (!bypassSecret) throw new Error("VERCEL_AUTOMATION_BYPASS_SECRET is required");
const base = new URL(previewUrl);
const headers = { "x-vercel-protection-bypass": bypassSecret };
async function fetchText(path: string) {
const response = await fetch(new URL(path, base), { headers });
if (!response.ok) {
throw new Error(`${path} returned ${response.status} ${response.statusText}`);
}
return await response.text();
}
async function fetchJson(path: string) {
const response = await fetch(new URL(path, base), {
headers: { ...headers, accept: "application/json" },
});
if (!response.ok) {
throw new Error(`${path} returned ${response.status} ${response.statusText}`);
}
return await response.json();
}
const home = await fetchText("/");
if (!home.includes("<title>ClawHub")) {
throw new Error("Preview home page did not render the ClawHub shell");
}
const detail = await fetchJson("/api/v1/skills/gifgrep");
if (detail?.skill?.slug !== "gifgrep") {
throw new Error(`Expected gifgrep API detail, received ${JSON.stringify(detail)}`);
}
const skillFile = await fetchText("/api/v1/skills/gifgrep/file?path=SKILL.md");
if (!skillFile.trim()) {
throw new Error("Preview skill file route returned an empty SKILL.md");
}
console.log(
JSON.stringify(
{
previewUrl: base.toString(),
skillSlug: detail.skill.slug,
ownerHandle: detail.owner?.handle ?? null,
latestVersion: detail.latestVersion?.version ?? null,
skillFileBytes: skillFile.length,
},
null,
2,
),
);
TS
- name: Write proof summary
env:
PREVIEW_URL: ${{ inputs.preview_url }}
run: |
{
echo "## Preview proof"
echo
echo "- Git SHA: \`${actual_sha}\`"
echo "- Preview URL: ${PREVIEW_URL}"
echo "- Verified routes: \`/\`, \`/api/v1/skills/gifgrep\`, \`/api/v1/skills/gifgrep/file?path=SKILL.md\`"
} >> "$GITHUB_STEP_SUMMARY"
+4
View File
@@ -105,6 +105,7 @@ import type * as lib_reporting from "../lib/reporting.js";
import type * as lib_reservedHandles from "../lib/reservedHandles.js";
import type * as lib_reservedSlugs from "../lib/reservedSlugs.js";
import type * as lib_retentionPolicy from "../lib/retentionPolicy.js";
import type * as lib_searchRanking from "../lib/searchRanking.js";
import type * as lib_searchText from "../lib/searchText.js";
import type * as lib_securityPrompt from "../lib/securityPrompt.js";
import type * as lib_securityScanPolicy from "../lib/securityScanPolicy.js";
@@ -125,6 +126,7 @@ import type * as lib_skillZip from "../lib/skillZip.js";
import type * as lib_skills_index from "../lib/skills/index.js";
import type * as lib_skills_slugResolution from "../lib/skills/slugResolution.js";
import type * as lib_staticPublishScan from "../lib/staticPublishScan.js";
import type * as lib_testSeed from "../lib/testSeed.js";
import type * as lib_tokens from "../lib/tokens.js";
import type * as lib_userSearch from "../lib/userSearch.js";
import type * as lib_userSkillStats from "../lib/userSkillStats.js";
@@ -269,6 +271,7 @@ declare const fullApi: ApiFromModules<{
"lib/reservedHandles": typeof lib_reservedHandles;
"lib/reservedSlugs": typeof lib_reservedSlugs;
"lib/retentionPolicy": typeof lib_retentionPolicy;
"lib/searchRanking": typeof lib_searchRanking;
"lib/searchText": typeof lib_searchText;
"lib/securityPrompt": typeof lib_securityPrompt;
"lib/securityScanPolicy": typeof lib_securityScanPolicy;
@@ -289,6 +292,7 @@ declare const fullApi: ApiFromModules<{
"lib/skills/index": typeof lib_skills_index;
"lib/skills/slugResolution": typeof lib_skills_slugResolution;
"lib/staticPublishScan": typeof lib_staticPublishScan;
"lib/testSeed": typeof lib_testSeed;
"lib/tokens": typeof lib_tokens;
"lib/userSearch": typeof lib_userSearch;
"lib/userSkillStats": typeof lib_userSkillStats;
+45
View File
@@ -5208,6 +5208,51 @@ export const getAccountRecreationState: ReturnType<typeof rawInternalMutation> =
},
});
export const getPrePublicationSkillAttemptState: ReturnType<typeof rawInternalMutation> =
rawInternalMutation({
args: {
attemptId: v.id("publishAttempts"),
},
handler: async (ctx, args) => {
const attempt = await ctx.db.get(args.attemptId);
if (!attempt || attempt.kind !== "skill") {
return {
ok: true as const,
attemptExists: Boolean(attempt),
skillExists: false,
versionExists: false,
};
}
const skill = attempt.skillId ? await ctx.db.get(attempt.skillId) : null;
const version = attempt.skillVersionId ? await ctx.db.get(attempt.skillVersionId) : null;
return {
ok: true as const,
attemptExists: true,
attempt: {
attemptId: attempt._id,
status: attempt.status,
slug: attempt.slug,
version: attempt.version,
skillId: attempt.skillId ?? null,
skillVersionId: attempt.skillVersionId ?? null,
filesCount: attempt.files.length,
hasSkillInsertArgs: attempt.skillInsertArgs !== undefined,
hasFollowup: attempt.followup !== undefined,
trufflehogStatus: attempt.checks.trufflehog.status,
trufflehogRedactedFindingCount: attempt.checks.trufflehog.redactedFindings?.length ?? 0,
clawscanStatus: attempt.checks.clawscan.status,
blockedAt: attempt.blockedAt ?? null,
},
skillExists: Boolean(skill),
skillLatestVersionId: skill?.latestVersionId ?? null,
versionExists: Boolean(version),
versionPublicationStatus: version?.publicationStatus ?? null,
};
},
});
async function upsertRoleHelpFixtureUser(ctx: MutationCtx, user: RoleHelpFixtureUser) {
const now = Date.now();
const existing = await ctx.db
+22 -1
View File
@@ -14,6 +14,7 @@ export type SkillFileModerationInfo = {
type SkillVersionSecuritySource = {
_id: Id<"skillVersions"> | string;
publicationStatus?: "pending" | "published" | "blocked" | null;
llmAnalysis?: {
status?: string | null;
verdict?: string | null;
@@ -132,15 +133,35 @@ export function isSkillVersionForSkill(
return version?.skillId === skillId;
}
export function isPublishedSkillVersion(
version:
| {
publicationStatus?: string | null;
}
| null
| undefined,
) {
return Boolean(
version &&
(version.publicationStatus === undefined || version.publicationStatus === "published"),
);
}
export function isPublicSkillVersionAvailableForSkill(
version:
| {
skillId?: Id<"skills"> | string | null;
softDeletedAt?: number | null;
publicationStatus?: string | null;
}
| null
| undefined,
skillId: Id<"skills"> | string,
) {
return Boolean(version && !version.softDeletedAt && isSkillVersionForSkill(version, skillId));
return Boolean(
version &&
!version.softDeletedAt &&
isPublishedSkillVersion(version) &&
isSkillVersionForSkill(version, skillId),
);
}
+290 -38
View File
@@ -419,7 +419,7 @@ description: Research helper for literature reviews.
);
});
it("stages publish attempts without creating a public version inline", async () => {
it("staged publishes create a real pending version and return legacy-compatible ids", async () => {
const storedFiles = new Map([
[
"_storage:skill",
@@ -431,19 +431,27 @@ description: Security scanner smoke fixture.
],
]);
const runMutation = vi.fn(async (_ref: unknown, args: Record<string, unknown>) => {
if ("skillInsertArgs" in args) {
if ("publicationStatus" in args) {
return {
skillId: "skills:demo",
versionId: "skillVersions:pending",
publicationStatus: "pending",
};
}
if ("skillVersionId" in args) {
return {
attemptId: "publishAttempts:security-scanner-smoke",
status: "pending_checks",
};
}
throw new Error("publish should not create a public version before checks pass");
throw new Error("unexpected staged publish mutation");
});
const scheduler = { runAfter: vi.fn() };
const ctx = {
runQuery: vi
.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ _id: "users:1", handle: "demo", createdAt: 1 }),
runMutation,
scheduler,
@@ -481,24 +489,122 @@ description: Security scanner smoke fixture.
);
expect(result).toEqual({
skillId: "skills:demo",
versionId: "skillVersions:pending",
status: "pending",
attemptId: "publishAttempts:security-scanner-smoke",
slug: "security-scanner-smoke",
version: "1.0.0",
publicationStatus: "pending",
attemptId: "publishAttempts:security-scanner-smoke",
});
expect(runMutation).toHaveBeenCalledTimes(1);
expect(runMutation).toHaveBeenCalledTimes(2);
expect(runMutation).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
skillInsertArgs: expect.objectContaining({
slug: "security-scanner-smoke",
version: "1.0.0",
}),
slug: "security-scanner-smoke",
version: "1.0.0",
publicationStatus: "pending",
deferredAiEnrichment: expect.any(Object),
}),
);
expect(runMutation).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
skillId: "skills:demo",
skillVersionId: "skillVersions:pending",
artifactFingerprint: expect.any(String),
}),
);
expect(runMutation).not.toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
skillInsertArgs: expect.anything(),
}),
);
expect(scheduler.runAfter).not.toHaveBeenCalled();
});
it("cleans up the pending version when staged publish attempt creation fails", async () => {
const storedFiles = new Map([
[
"_storage:skill",
`---
description: Security scanner smoke fixture.
---
# Security Scanner Smoke
`,
],
]);
const runMutation = vi.fn(async (_ref: unknown, args: Record<string, unknown>) => {
if ("publicationStatus" in args) {
return {
skillId: "skills:demo",
versionId: "skillVersions:pending",
publicationStatus: "pending",
createdNewParent: true,
};
}
if ("skillVersionId" in args) {
throw new Error("attempt creation outage");
}
if ("versionId" in args && "createdNewParent" in args) {
return { deleted: true, parentDeleted: true };
}
throw new Error("unexpected staged publish mutation");
});
const ctx = {
runQuery: vi
.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ _id: "users:1", handle: "demo", createdAt: 1 }),
runMutation,
scheduler: { runAfter: vi.fn() },
storage: {
get: vi.fn(async (storageId: string) => {
const content = storedFiles.get(storageId);
return content === undefined ? null : new Blob([content]);
}),
},
};
await expect(
stageSkillPublishAttemptForUser(
ctx as never,
"users:1" as never,
{
slug: "security-scanner-smoke",
displayName: "Security Scanner Smoke",
version: "1.0.0",
changelog: "Initial 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("attempt creation outage");
expect(runMutation).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
skillId: "skills:demo",
versionId: "skillVersions:pending",
createdNewParent: true,
}),
);
});
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");
@@ -554,21 +660,62 @@ description: Security scanner smoke fixture.
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],
it("rejects staged skill versions reserved by a retained publish attempt", async () => {
const runMutation = vi.fn(async () => {
throw new Error("duplicate publish should not create an attempt");
});
const ctx = {
runQuery: vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce({
attemptId: "publishAttempts:secret-blocked",
status: "blocked",
}),
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 by promoting the existing pending version", async () => {
const runMutation = vi.fn(async (_ref: unknown, args: Record<string, unknown>) => {
if ("claimId" in args && !("result" in args)) {
return {
status: "claimed",
attemptId: "publishAttempts:security-scanner-smoke",
createdAt: Date.parse("2026-07-07T15:00:00Z"),
skillInsertArgs: insertArgs,
skillId: "skills:demo",
versionId: "skillVersions:pending",
followup: {
skipWebhook: true,
slug: "security-scanner-smoke",
@@ -577,10 +724,10 @@ description: Security scanner smoke fixture.
},
};
}
if ("version" in args && "embedding" in args) {
if ("versionId" in args && !("result" in args)) {
return {
skillId: "skills:demo",
versionId: "skillVersions:demo",
versionId: "skillVersions:pending",
embeddingId: "skillEmbeddings:demo",
};
}
@@ -599,6 +746,29 @@ description: Security scanner smoke fixture.
const scheduler = { runAfter: vi.fn() };
const ctx = {
runMutation,
runQuery: vi.fn(async (_ref: unknown, args: Record<string, unknown>) =>
"versionId" in args
? {
userId: "users:1",
displayName: "Security Scanner Smoke",
version: "1.0.0",
changelog: "Initial release",
changelogSource: "user",
tags: ["latest"],
files: [],
parsed: { frontmatter: {}, license: "MIT-0" },
staticScan: {
status: "clean",
reasonCodes: [],
findings: [],
summary: "No suspicious patterns detected.",
engineVersion: "test",
checkedAt: 1,
},
embedding: [0, 1, 2],
}
: null,
),
scheduler,
};
@@ -609,39 +779,46 @@ description: Security scanner smoke fixture.
expect(result).toEqual({
skillId: "skills:demo",
versionId: "skillVersions:demo",
versionId: "skillVersions:pending",
embeddingId: "skillEmbeddings:demo",
});
expect(runMutation).toHaveBeenCalledWith(expect.anything(), insertArgs);
expect(runMutation).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
versionId: "skillVersions:pending",
}),
);
expect(runMutation).not.toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
slug: "security-scanner-smoke",
version: "1.0.0",
publicationStatus: undefined,
}),
);
expect(scheduler.runAfter).toHaveBeenCalledWith(0, expect.anything(), {
versionId: "skillVersions:demo",
versionId: "skillVersions:pending",
});
expect(scheduler.runAfter).toHaveBeenCalledWith(0, expect.anything(), {
versionId: "skillVersions:demo",
versionId: "skillVersions:pending",
source: "publish",
});
expect(scheduler.runAfter).toHaveBeenCalledWith(15_000, expect.anything(), {
versionId: "skillVersions:demo",
versionId: "skillVersions:pending",
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],
};
it("releases the staged publish finalization claim when promotion fails", async () => {
const runMutation = vi.fn(async (_ref: unknown, args: Record<string, unknown>) => {
if ("claimId" in args && !("error" in args) && !("result" in args)) {
return {
status: "claimed",
attemptId: "publishAttempts:security-scanner-smoke",
skillInsertArgs: insertArgs,
skillId: "skills:demo",
versionId: "skillVersions:pending",
followup: {
skipWebhook: true,
slug: "security-scanner-smoke",
@@ -650,8 +827,8 @@ description: Security scanner smoke fixture.
},
};
}
if ("version" in args && "embedding" in args) {
throw new Error("transient insert failure");
if ("versionId" in args) {
throw new Error("transient promotion failure");
}
if ("error" in args) {
return {
@@ -663,19 +840,40 @@ description: Security scanner smoke fixture.
});
const ctx = {
runMutation,
runQuery: vi.fn(async () => null),
runQuery: vi.fn(async (_ref: unknown, args: Record<string, unknown>) =>
"versionId" in args
? {
userId: "users:1",
displayName: "Security Scanner Smoke",
version: "1.0.0",
changelog: "Initial release",
changelogSource: "user",
files: [],
parsed: { frontmatter: {}, license: "MIT-0" },
staticScan: {
status: "clean",
reasonCodes: [],
findings: [],
summary: "No suspicious patterns detected.",
engineVersion: "test",
checkedAt: 1,
},
embedding: [0, 1, 2],
}
: null,
),
scheduler: { runAfter: vi.fn() },
};
await expect(
finalizeSkillPublishAttempt(ctx as never, "publishAttempts:security-scanner-smoke" as never),
).rejects.toThrow("transient insert failure");
).rejects.toThrow("transient promotion failure");
expect(runMutation).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
attemptId: "publishAttempts:security-scanner-smoke",
error: "transient insert failure",
error: "transient promotion failure",
}),
);
expect(ctx.scheduler.runAfter).not.toHaveBeenCalled();
@@ -744,6 +942,60 @@ description: Security scanner smoke fixture.
});
});
it("finalizes legacy attempts that still store insert args instead of pending version ids", async () => {
const insertArgs = {
userId: "users:1",
slug: "security-scanner-smoke",
displayName: "Security Scanner Smoke",
version: "1.0.0",
embedding: [0, 1, 2],
};
const publishResult = {
skillId: "skills:demo",
versionId: "skillVersions:demo",
embeddingId: "skillEmbeddings:demo",
};
const runMutation = vi.fn(async (_ref: unknown, args: Record<string, unknown>) => {
if ("claimId" in args && !("result" in args)) {
return {
status: "claimed",
attemptId: "publishAttempts:legacy",
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 publishResult;
if ("result" in args) {
return {
attemptId: "publishAttempts:legacy",
status: "finalized",
result: args.result,
};
}
throw new Error("unexpected mutation");
});
const scheduler = { runAfter: vi.fn() };
const ctx = {
runMutation,
runQuery: vi.fn(),
scheduler,
};
await expect(
finalizeSkillPublishAttempt(ctx as never, "publishAttempts:legacy" as never),
).resolves.toEqual(publishResult);
expect(runMutation).toHaveBeenCalledWith(expect.anything(), insertArgs);
expect(scheduler.runAfter).toHaveBeenCalledWith(0, expect.anything(), {
versionId: "skillVersions:demo",
});
});
it("merges github source into metadata", () => {
const merged = __test.mergeSourceIntoMetadata(
{ clawdis: { emoji: "x" } },
+104 -22
View File
@@ -78,14 +78,13 @@ function normalizeStoredSkillCategoryOverride(categories: readonly string[] | un
export type PublishResult = {
skillId: Id<"skills">;
versionId: Id<"skillVersions">;
embeddingId: Id<"skillEmbeddings">;
};
export type PendingPublishResult = {
status: "pending";
attemptId: Id<"publishAttempts">;
slug: string;
version: string;
embeddingId?: Id<"skillEmbeddings">;
status?: "pending" | "published";
slug?: string;
version?: string;
publicationStatus?: "pending" | "published";
attemptId?: Id<"publishAttempts">;
createdNewParent?: boolean;
};
type SkillPublishFollowup = {
@@ -96,7 +95,7 @@ type SkillPublishFollowup = {
displayName: string;
};
export type SkillPublishResult = PublishResult | PendingPublishResult;
export type SkillPublishResult = PublishResult;
export type PublishVersionArgs = {
slug: string;
@@ -219,6 +218,23 @@ async function publishVersionForUserInternal(
);
}
}
if (options.stagePrePublicationChecks) {
const existingAttempt = (await ctx.runQuery(
internal.publishAttempts.findExistingPublishAttemptForArtifactInternal,
{
kind: "skill",
slug: normalizedSlug,
version,
userId,
ownerPublisherId: options.ownerPublisherId,
},
)) as { attemptId: Id<"publishAttempts"> } | null;
if (existingAttempt) {
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,
@@ -496,12 +512,23 @@ async function publishVersionForUserInternal(
return publishResult;
}
const staged = (await ctx.runMutation(
internal.publishAttempts.createSkillPublishAttemptInternal,
{
const pendingInsertArgs = {
...skillInsertArgs,
publicationStatus: "pending" as const,
};
const pendingResult = (await ctx.runMutation(
internal.skills.insertVersion,
pendingInsertArgs,
)) as PublishResult;
const staged = (await ctx
.runMutation(internal.publishAttempts.createSkillPublishAttemptInternal, {
userId,
ownerPublisherId: options.ownerPublisherId,
sourceOwnerPublisherId: options.sourceOwnerPublisherId,
skillId: pendingResult.skillId,
skillVersionId: pendingResult.versionId,
createdNewParent: pendingResult.createdNewParent,
slug,
displayName,
version,
@@ -517,13 +544,20 @@ async function publishVersionForUserInternal(
...file,
path: file.path,
})),
skillInsertArgs: stripUndefinedForStoredAttempt(skillInsertArgs),
scanContext: buildSkillPublishAttemptScanContext(skillInsertArgs),
followup: {
skipWebhook: followup.skipWebhook,
ownerHandle,
},
},
)) as {
})
.catch(async (error) => {
await ctx.runMutation(internal.skills.discardPendingPublicationInternal, {
skillId: pendingResult.skillId,
versionId: pendingResult.versionId,
createdNewParent: pendingResult.createdNewParent,
});
throw error;
})) as {
attemptId: Id<"publishAttempts">;
status: string;
result?: PublishResult;
@@ -533,7 +567,15 @@ async function publishVersionForUserInternal(
return staged.result;
}
return { status: "pending", attemptId: staged.attemptId, slug, version };
return {
skillId: pendingResult.skillId,
versionId: pendingResult.versionId,
status: "pending",
slug,
version,
publicationStatus: "pending",
attemptId: staged.attemptId,
};
}
export async function finalizeSkillPublishAttempt(
@@ -549,7 +591,9 @@ export async function finalizeSkillPublishAttempt(
status: "claimed";
attemptId: Id<"publishAttempts">;
createdAt: number;
skillInsertArgs: unknown;
skillId?: Id<"skills">;
versionId?: Id<"skillVersions">;
skillInsertArgs?: unknown;
followup: SkillPublishFollowup;
}
| {
@@ -565,11 +609,28 @@ export async function finalizeSkillPublishAttempt(
let publishResult: PublishResult;
try {
const skillInsertArgs = await prepareSkillInsertArgsForFinalization(ctx, claim.skillInsertArgs);
publishResult = (await ctx.runMutation(
internal.skills.insertVersion,
skillInsertArgs as never,
)) as PublishResult;
if (claim.versionId) {
const rawPublishArgs = await ctx.runQuery(
internal.skills.getPendingVersionPublishArgsInternal,
{
versionId: claim.versionId,
},
);
const skillInsertArgs = await prepareSkillInsertArgsForFinalization(ctx, rawPublishArgs);
publishResult = (await ctx.runMutation(internal.skills.publishPendingVersionInternal, {
versionId: claim.versionId,
publishArgs: skillInsertArgs,
})) as PublishResult;
} else {
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,
@@ -687,6 +748,27 @@ function stringField(record: Record<string, unknown>, field: string) {
return typeof value === "string" ? value : "";
}
function buildSkillPublishAttemptScanContext(insertArgs: unknown) {
const record =
insertArgs && typeof insertArgs === "object" ? (insertArgs as Record<string, unknown>) : {};
const parsed =
record.parsed && typeof record.parsed === "object"
? (record.parsed as Record<string, unknown>)
: {};
return stripUndefinedForStoredAttempt({
version: {
staticScan: record.staticScan,
parsed: {
metadata: parsed.metadata,
clawdis: parsed.clawdis,
license: parsed.license,
},
qualityAssessment: record.qualityAssessment,
sourceProvenance: record.sourceProvenance,
},
});
}
async function releaseSkillPublishAttemptFinalizationClaim(
ctx: ActionCtx,
attemptId: Id<"publishAttempts">,
+673 -1
View File
@@ -40,6 +40,8 @@ import {
getVersionByName,
getVersionSecurityByNameForViewerInternal,
insertReleaseInternal,
publishPendingReleaseInternal,
finalizePackagePublishAttemptInternal,
findPackagePublishResultInternal,
listPackageModerationQueueInternal,
listPluginExportPageInternal,
@@ -219,6 +221,7 @@ const insertReleaseInternalHandler = (
displayName: string;
family: "skill" | "code-plugin" | "bundle-plugin";
version: string;
publicationStatus?: "pending" | "published";
changelog: string;
icon?: string;
tags: string[];
@@ -273,6 +276,22 @@ const findPackagePublishResultInternalHandler = (
{ ok: true; packageId: string; releaseId: string } | null
>
)._handler;
const publishPendingReleaseInternalHandler = (
publishPendingReleaseInternal as unknown as WrappedHandler<
{
releaseId: string;
},
unknown
>
)._handler;
const finalizePackagePublishAttemptInternalHandler = (
finalizePackagePublishAttemptInternal as unknown as WrappedHandler<
{
attemptId: string;
},
unknown
>
)._handler;
const reservePackageNameInternalHandler = (
reservePackageNameInternal as unknown as WrappedHandler<
{
@@ -6024,6 +6043,43 @@ describe("packages public queries", () => {
expect(result?.latestRelease).toBeNull();
});
it("hides package shells that only have pending unpublished releases", async () => {
const { ctx } = makePackageCtx({
pkg: makePackageDoc({
latestReleaseId: undefined,
latestVersionSummary: undefined,
stats: { downloads: 0, installs: 0, stars: 0, versions: 0 },
}),
latestRelease: null,
});
await expect(
getByNameHandler(ctx, {
name: "demo-plugin",
}),
).resolves.toBeNull();
});
it("keeps published non-latest packages visible without latest pointers", async () => {
const { ctx } = makePackageCtx({
pkg: makePackageDoc({
latestReleaseId: undefined,
latestVersionSummary: undefined,
stats: { downloads: 0, installs: 0, stars: 0, versions: 1 },
}),
latestRelease: null,
});
await expect(
getByNameHandler(ctx, {
name: "demo-plugin",
}),
).resolves.toMatchObject({
package: { name: "demo-plugin", latestVersion: null },
latestRelease: null,
});
});
it("hides soft-deleted releases from public version lists", async () => {
const { ctx, releaseIndexNames } = makePackageCtx({
versionsPage: {
@@ -6050,6 +6106,78 @@ describe("packages public queries", () => {
expect(releaseIndexNames).toContain("by_package_active_created");
});
it("fills public package version pages after skipping pending releases", async () => {
const releases = [
makeReleaseDoc({
_id: "packageReleases:pending",
version: "2.0.0",
publicationStatus: "pending",
}),
makeReleaseDoc({
_id: "packageReleases:published",
version: "1.0.0",
}),
];
const paginate = vi.fn(
async ({ cursor, numItems }: { cursor: string | null; numItems: number }) => {
const start = cursor ? Number(cursor) : 0;
const page = releases.slice(start, start + numItems);
const next = start + page.length;
return {
page,
isDone: next >= releases.length,
continueCursor: next >= releases.length ? "" : String(next),
};
},
);
const releaseIndexNames: string[] = [];
const ctx = {
db: {
get: vi.fn(async (id: string) =>
typeof id === "string" && id.startsWith("users:")
? { _id: id, handle: id.split(":").pop() ?? "user" }
: null,
),
query: vi.fn((table: string) => {
if (table === "packages") {
return {
withIndex: vi.fn(() => ({
unique: vi.fn().mockResolvedValue(makePackageDoc()),
})),
};
}
if (table === "packageReleases") {
return {
withIndex: vi.fn((indexName: string) => {
releaseIndexNames.push(indexName);
return {
order: vi.fn(() => ({
paginate,
})),
};
}),
};
}
throw new Error(`Unexpected table ${table}`);
}),
},
};
const result = await listVersionsHandler(ctx as never, {
name: "demo-plugin",
paginationOpts: { cursor: null, numItems: 1 },
});
expect(result).toMatchObject({
page: [{ version: "1.0.0" }],
isDone: true,
continueCursor: "",
});
expect(paginate).toHaveBeenNthCalledWith(1, { cursor: null, numItems: 1 });
expect(paginate).toHaveBeenNthCalledWith(2, { cursor: "1", numItems: 1 });
expect(releaseIndexNames).toEqual(["by_package_active_created", "by_package_active_created"]);
});
it("soft-deletes packages and active releases for the owner", async () => {
const { ctx, insert, patch } = makeSoftDeletePackageCtx({
releases: [
@@ -6942,6 +7070,172 @@ describe("packages public queries", () => {
);
});
it("creates pending package releases without updating public latest pointers", async () => {
const existingPackage = makePackageDoc({
latestReleaseId: "packageReleases:old",
latestVersionSummary: { version: "1.0.0" },
tags: { latest: "packageReleases:old" },
stats: { downloads: 0, installs: 0, stars: 0, versions: 1 },
});
const priorRelease = makeReleaseDoc({
_id: "packageReleases:old",
version: "1.0.0",
distTags: ["latest"],
});
const ctx = makeInsertReleaseCtx(existingPackage, [priorRelease]);
await expect(
insertReleaseInternalHandler(ctx, {
actorUserId: "users:owner",
ownerUserId: "users:owner",
name: "demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin",
version: "2.0.0",
publicationStatus: "pending",
changelog: "next",
tags: ["latest"],
summary: "pending summary",
files: [],
integritySha256: "pending-sha",
}),
).resolves.toMatchObject({
ok: true,
packageId: "packages:demo",
releaseId: "packageReleases:new",
publicationStatus: "pending",
createdNewParent: false,
});
expect(ctx.insert).toHaveBeenCalledWith(
"packageReleases",
expect.objectContaining({
publicationStatus: "pending",
pendingPublication: expect.objectContaining({
tags: ["latest"],
displayName: "Demo Plugin",
}),
}),
);
expect(ctx.patch).not.toHaveBeenCalledWith("packages:demo", expect.anything());
expect(ctx.patch).not.toHaveBeenCalledWith("packageReleases:old", expect.anything());
});
it("publishes a pending package release by promoting the existing row", async () => {
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
const existingPackage = makePackageDoc({
latestReleaseId: "packageReleases:old",
latestVersionSummary: { version: "1.0.0" },
tags: { latest: "packageReleases:old" },
stats: { downloads: 0, installs: 0, stars: 0, versions: 1 },
});
const priorRelease = makeReleaseDoc({
_id: "packageReleases:old",
version: "1.0.0",
distTags: ["latest"],
publicationStatus: "published",
});
const pendingRelease = makeReleaseDoc({
_id: "packageReleases:pending",
version: "2.0.0",
publicationStatus: "pending",
pendingPublication: {
displayName: "Demo Plugin",
tags: ["latest"],
channel: "community",
isOfficial: false,
},
distTags: ["latest"],
summary: "pending summary",
changelog: "next",
integritySha256: "pending-sha",
verification: { scanStatus: "pending" },
llmAnalysis: {
status: "clean",
verdict: "clean",
checkedAt: 1_700_000_000_000,
},
});
const ctx = makeInsertReleaseCtx(existingPackage, [priorRelease, pendingRelease], {
"packageReleases:pending": pendingRelease,
"packages:demo": existingPackage,
});
await expect(
publishPendingReleaseInternalHandler(ctx, {
releaseId: "packageReleases:pending",
}),
).resolves.toEqual({
ok: true,
packageId: "packages:demo",
releaseId: "packageReleases:pending",
});
expect(ctx.patch).toHaveBeenCalledWith("packageReleases:pending", {
publicationStatus: "published",
pendingPublication: undefined,
verification: { scanStatus: "clean" },
});
expect(ctx.patch).toHaveBeenCalledWith("packageReleases:old", { distTags: [] });
expect(ctx.patch).toHaveBeenCalledWith(
"packages:demo",
expect.objectContaining({
latestReleaseId: "packageReleases:pending",
latestVersionSummary: expect.objectContaining({ version: "2.0.0" }),
tags: { latest: "packageReleases:pending" },
stats: { downloads: 0, installs: 0, stars: 0, versions: 2 },
scanStatus: "clean",
}),
);
});
it("releases release-backed finalization claims when pending promotion fails", async () => {
const promotionError = new Error("promotion failed");
const runMutation = vi.fn(async (_ref: unknown, args: unknown) => {
if (typeof args === "object" && args !== null && "attemptId" in args && !("error" in args)) {
return {
status: "claimed",
attemptId: "publishAttempts:demo",
packageId: "packages:demo",
releaseId: "packageReleases:pending",
packageFollowup: {},
};
}
if (typeof args === "object" && args !== null && "releaseId" in args) {
throw promotionError;
}
if (typeof args === "object" && args !== null && "error" in args) {
return { attemptId: "publishAttempts:demo", status: "ready_to_finalize" };
}
throw new Error(`Unexpected mutation args ${JSON.stringify(args)}`);
});
const ctx = {
runMutation,
runQuery: vi.fn(async () => {
throw new Error("legacy insert recovery should not run for release-backed attempts");
}),
};
await expect(
finalizePackagePublishAttemptInternalHandler(ctx as never, {
attemptId: "publishAttempts:demo",
}),
).rejects.toThrow("promotion failed");
expect(runMutation).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ releaseId: "packageReleases:pending" }),
);
expect(runMutation).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
attemptId: "publishAttempts:demo",
error: "promotion failed",
}),
);
expect(ctx.runQuery).not.toHaveBeenCalled();
});
it("recovers idempotent package publish results for the same owner", async () => {
const release = makeReleaseDoc({ integritySha256: "abc123" });
const ctx = {
@@ -6986,6 +7280,49 @@ describe("packages public queries", () => {
});
});
it("does not recover pending package publish results as public successes", async () => {
const release = makeReleaseDoc({
integritySha256: "abc123",
publicationStatus: "pending",
});
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.toBeNull();
});
it("does not recover idempotent package publish results for another owner", async () => {
const ctx = {
db: {
@@ -8473,6 +8810,7 @@ describe("packages public queries", () => {
.mockResolvedValueOnce(trustedPublisher)
.mockResolvedValueOnce(makePackageDoc({ family: "bundle-plugin" }))
.mockResolvedValueOnce(trustedPublisher)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null),
runMutation,
runAction: vi.fn(async () => makeCleanPackageInspectorResult()),
@@ -8512,7 +8850,21 @@ describe("packages public queries", () => {
if (
typeof args === "object" &&
args !== null &&
"packageInsertArgs" in args &&
"publicationStatus" in args &&
(args as { publicationStatus?: string }).publicationStatus === "pending"
) {
return {
ok: true,
packageId: "packages:demo",
releaseId: "packageReleases:pending",
publicationStatus: "pending",
createdNewParent: false,
};
}
if (
typeof args === "object" &&
args !== null &&
"packageReleaseId" in args &&
"packageFollowup" in args
) {
return {
@@ -8581,6 +8933,9 @@ describe("packages public queries", () => {
).resolves.toMatchObject({
ok: true,
status: "pending",
packageId: "packages:demo",
releaseId: "packageReleases:pending",
publicationStatus: "pending",
attemptId: "publishAttempts:demo",
packageName: "demo-plugin",
version: "1.0.0",
@@ -8596,6 +8951,243 @@ describe("packages public queries", () => {
expect(runMutation).toHaveBeenCalledWith(expect.anything(), {
tokenId: "packagePublishTokens:1",
});
expect(runMutation).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
packageId: "packages:demo",
packageReleaseId: "packageReleases:pending",
scanContext: expect.any(Object),
}),
);
});
it("cleans up orphan pending package releases instead of wedging future retries", 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 &&
"publicationStatus" in args &&
(args as { publicationStatus?: string }).publicationStatus === "pending"
) {
throw new Error("orphan recovery should not insert a second pending release");
}
if (
typeof args === "object" &&
args !== null &&
"packageReleaseId" in args &&
"packageFollowup" in args
) {
throw new Error("orphan cleanup should reject before creating a publish attempt");
}
if (
typeof args === "object" &&
args !== null &&
"releaseId" in args &&
"createdNewParent" in args
) {
return { deleted: true, parentDeleted: true };
}
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 orphanParent = makePackageDoc({
latestReleaseId: undefined,
latestVersionSummary: undefined,
stats: { downloads: 0, installs: 0, stars: 0, versions: 0 },
});
const orphanRelease = makeReleaseDoc({
_id: "packageReleases:orphan",
packageId: "packages:demo",
integritySha256: "b8107c6a51a6a7554e20d2963348212b80bc816fb59492ee163792103a6b7df6",
publicationStatus: "pending",
});
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(orphanParent)
.mockResolvedValueOnce(trustedPublisher)
.mockResolvedValueOnce(orphanRelease)
.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],
},
}),
).rejects.toThrow(
"Previous pending publish for 1.0.0 did not finish creating security checks. It was cleaned up; retry the publish.",
);
} finally {
if (previousFlag === undefined) {
delete process.env.CLAWHUB_STAGED_PREPUBLICATION_PUBLISHES;
} else {
process.env.CLAWHUB_STAGED_PREPUBLICATION_PUBLISHES = previousFlag;
}
}
expect(runMutation).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
packageId: "packages:demo",
releaseId: "packageReleases:orphan",
createdNewParent: true,
}),
);
});
it("cleans up pending package releases when staged publish attempt creation fails", 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 &&
"publicationStatus" in args &&
(args as { publicationStatus?: string }).publicationStatus === "pending"
) {
return {
ok: true,
packageId: "packages:demo",
releaseId: "packageReleases:pending",
publicationStatus: "pending",
createdNewParent: false,
};
}
if (typeof args === "object" && args !== null && "packageReleaseId" in args) {
throw new Error("attempt creation outage");
}
if (
typeof args === "object" &&
args !== null &&
"releaseId" in args &&
"createdNewParent" in args
) {
return { deleted: true, parentDeleted: true };
}
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)
.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],
},
}),
).rejects.toThrow("attempt creation outage");
} finally {
if (previousFlag === undefined) {
delete process.env.CLAWHUB_STAGED_PREPUBLICATION_PUBLISHES;
} else {
process.env.CLAWHUB_STAGED_PREPUBLICATION_PUBLISHES = previousFlag;
}
}
expect(runMutation).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
packageId: "packages:demo",
releaseId: "packageReleases:pending",
createdNewParent: false,
}),
);
expect(runMutation).not.toHaveBeenCalledWith(expect.anything(), {
tokenId: "packagePublishTokens:1",
});
});
it("rejects duplicate staged package releases before creating a publish attempt", async () => {
@@ -8678,6 +9270,86 @@ describe("packages public queries", () => {
expect(runMutation).not.toHaveBeenCalled();
});
it("rejects staged package releases reserved by a retained 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(null)
.mockResolvedValueOnce({
attemptId: "publishAttempts:secret-blocked",
status: "blocked",
}),
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 (
+423 -74
View File
@@ -497,6 +497,8 @@ const internalRefs = internal as unknown as {
markPackageInspectorFindingsEmailedInternal: unknown;
claimPackageInspectorScanBatchInternal: unknown;
ingestPackageInspectorScanResultsInternal: unknown;
discardPendingPackagePublicationInternal: unknown;
publishPendingReleaseInternal: unknown;
};
packageInspectorNode: {
runPackageInspectorForPublishInternal: unknown;
@@ -519,6 +521,7 @@ const internalRefs = internal as unknown as {
};
publishAttempts: {
createPackagePublishAttemptInternal: unknown;
findExistingPublishAttemptForArtifactInternal: unknown;
claimPackagePublishAttemptForFinalizationInternal: unknown;
releasePackagePublishAttemptFinalizationClaimInternal: unknown;
recordPackagePublishAttemptFinalizedInternal: unknown;
@@ -1121,6 +1124,22 @@ function resolvePublicPackageScanStatus(
return pkg.scanStatus;
}
function isPublishedPackageRelease(
release: Doc<"packageReleases"> | null | undefined,
): release is Doc<"packageReleases"> {
return Boolean(
release &&
!release.softDeletedAt &&
(release.publicationStatus === undefined || release.publicationStatus === "published"),
);
}
function hasNoPublishedPackageVersions(
pkg: Pick<Doc<"packages">, "latestReleaseId" | "latestVersionSummary" | "stats">,
) {
return !pkg.latestReleaseId && !pkg.latestVersionSummary && (pkg.stats?.versions ?? 0) <= 0;
}
function normalizePublicPackageSourcePath(sourcePath: unknown) {
if (typeof sourcePath !== "string") return undefined;
const trimmed = sourcePath.trim();
@@ -1151,10 +1170,18 @@ function toPublicPackage(
latestRelease?: Doc<"packageReleases"> | null,
): PublicPackageDoc | null {
if (!pkg || pkg.softDeletedAt) return null;
if (hasNoPublishedPackageVersions(pkg)) return null;
if (
latestRelease !== undefined &&
latestRelease &&
(latestRelease.publicationStatus === "pending" || latestRelease.publicationStatus === "blocked")
) {
return null;
}
const latestVersion =
latestRelease === undefined
? (pkg.latestVersionSummary?.version ?? null)
: latestRelease && !latestRelease.softDeletedAt
: isPublishedPackageRelease(latestRelease)
? latestRelease.version
: null;
const scanStatus = resolvePublicPackageScanStatus(pkg, latestRelease);
@@ -1178,7 +1205,7 @@ function toPublicPackage(
artifact:
latestRelease === undefined
? pkg.latestVersionSummary?.artifact
: latestRelease && !latestRelease.softDeletedAt
: isPublishedPackageRelease(latestRelease)
? packageArtifactSummary(latestRelease)
: undefined,
scanStatus,
@@ -1203,6 +1230,50 @@ function toPublicPackageRelease(release: Doc<"packageReleases">) {
};
}
async function paginatePublishedPackageReleases(
ctx: QueryCtx,
packageId: Id<"packages">,
paginationOpts: { cursor: string | null; numItems: number },
) {
const targetCount = Math.max(1, Math.min(paginationOpts.numItems, MAX_PUBLIC_LIST_PAGE_SIZE));
const page: Doc<"packageReleases">[] = [];
let cursor = paginationOpts.cursor;
let isDone = false;
let continueCursor = "";
let remainingScanBudget = Math.max(
targetCount,
Math.min(
MAX_PUBLIC_LIST_FILTER_SCAN_DOCUMENTS,
targetCount * MAX_PUBLIC_LIST_FILTER_SCAN_PAGES,
),
);
for (let scanPages = 0; scanPages < MAX_PUBLIC_LIST_FILTER_SCAN_PAGES; scanPages += 1) {
if (page.length >= targetCount || isDone || remainingScanBudget <= 0) break;
const pageSize = Math.min(remainingScanBudget, targetCount - page.length);
const result = await ctx.db
.query("packageReleases")
.withIndex("by_package_active_created", (q) =>
q.eq("packageId", packageId).eq("softDeletedAt", undefined),
)
.order("desc")
.paginate({ cursor, numItems: pageSize });
remainingScanBudget -= pageSize;
cursor = result.continueCursor;
continueCursor = result.continueCursor;
isDone = result.isDone;
for (const release of result.page) {
if (isPublishedPackageRelease(release)) {
page.push(release);
if (page.length >= targetCount) break;
}
}
if (result.page.length === 0) break;
}
return { page, isDone, continueCursor: isDone ? "" : continueCursor };
}
function packageArtifactSummary(
release: Pick<
Doc<"packageReleases">,
@@ -2677,10 +2748,9 @@ export const getByName = query({
);
return {
package: publicPackage,
latestRelease:
latestRelease && !latestRelease.softDeletedAt
? toPublicPackageRelease(latestRelease)
: null,
latestRelease: isPublishedPackageRelease(latestRelease)
? toPublicPackageRelease(latestRelease)
: null,
owner,
};
},
@@ -2908,10 +2978,9 @@ export const getByNameForStaff = query({
return {
package: pkg,
latestRelease:
latestRelease && !latestRelease.softDeletedAt
? toPublicPackageRelease(latestRelease)
: null,
latestRelease: isPublishedPackageRelease(latestRelease)
? toPublicPackageRelease(latestRelease)
: null,
owner,
highlighted: highlighted
? {
@@ -2942,10 +3011,9 @@ export const getByNameForViewerInternal = internalQuery({
);
return {
package: publicPackage,
latestRelease:
latestRelease && !latestRelease.softDeletedAt
? toPublicPackageRelease(latestRelease)
: null,
latestRelease: isPublishedPackageRelease(latestRelease)
? toPublicPackageRelease(latestRelease)
: null,
owner,
};
},
@@ -2960,13 +3028,7 @@ export const listVersions = query({
const viewerUserId = await getOptionalViewerUserId(ctx);
const pkg = await getReadablePackageByName(ctx, args.name, viewerUserId);
if (!pkg) return { page: [], isDone: true, continueCursor: "" };
const result = await ctx.db
.query("packageReleases")
.withIndex("by_package_active_created", (q) =>
q.eq("packageId", pkg._id).eq("softDeletedAt", undefined),
)
.order("desc")
.paginate(args.paginationOpts);
const result = await paginatePublishedPackageReleases(ctx, pkg._id, args.paginationOpts);
return {
...result,
page: result.page.map(toPublicPackageRelease),
@@ -2983,13 +3045,7 @@ export const listVersionsForViewerInternal = internalQuery({
handler: async (ctx, args) => {
const pkg = await getReadablePackageByName(ctx, args.name, args.viewerUserId);
if (!pkg) return { page: [], isDone: true, continueCursor: "" };
const result = await ctx.db
.query("packageReleases")
.withIndex("by_package_active_created", (q) =>
q.eq("packageId", pkg._id).eq("softDeletedAt", undefined),
)
.order("desc")
.paginate(args.paginationOpts);
const result = await paginatePublishedPackageReleases(ctx, pkg._id, args.paginationOpts);
return {
...result,
page: result.page.map(toPublicPackageRelease),
@@ -3012,7 +3068,7 @@ export const getVersionByName = query({
q.eq("packageId", pkg._id).eq("version", args.version),
)
.unique();
if (!release || release.softDeletedAt) return null;
if (!isPublishedPackageRelease(release)) return null;
const latestRelease =
pkg.latestReleaseId === release._id
? release
@@ -3043,7 +3099,7 @@ export const getVersionByNameForViewerInternal = internalQuery({
q.eq("packageId", pkg._id).eq("version", args.version),
)
.unique();
if (!release || release.softDeletedAt) return null;
if (!isPublishedPackageRelease(release)) return null;
const latestRelease =
pkg.latestReleaseId === release._id
? release
@@ -3074,7 +3130,7 @@ export const getVersionSecurityByNameForViewerInternal = internalQuery({
q.eq("packageId", pkg._id).eq("version", args.version),
)
.unique();
if (!release || release.softDeletedAt) return null;
if (!isPublishedPackageRelease(release)) return null;
const latestRelease =
pkg.latestReleaseId === release._id
? release
@@ -3194,32 +3250,31 @@ export const listAuditPage = query({
verificationTier: pkg.verification?.tier ?? null,
},
owner,
latestRelease:
latestRelease && !latestRelease.softDeletedAt
? {
version: latestRelease.version,
createdAt: latestRelease.createdAt,
vtAnalysis: latestRelease.vtAnalysis,
llmAnalysis: latestRelease.llmAnalysis,
staticScan: latestRelease.staticScan
? {
status: latestRelease.staticScan.status,
reasonCodes: latestRelease.staticScan.reasonCodes,
findings: (latestRelease.staticScan.findings ?? []).map((finding) => ({
code: finding.code,
severity: finding.severity,
file: finding.file,
line: finding.line,
message: finding.message,
evidence: "",
})),
summary: latestRelease.staticScan.summary,
engineVersion: latestRelease.staticScan.engineVersion,
checkedAt: latestRelease.staticScan.checkedAt,
}
: null,
}
: null,
latestRelease: isPublishedPackageRelease(latestRelease)
? {
version: latestRelease.version,
createdAt: latestRelease.createdAt,
vtAnalysis: latestRelease.vtAnalysis,
llmAnalysis: latestRelease.llmAnalysis,
staticScan: latestRelease.staticScan
? {
status: latestRelease.staticScan.status,
reasonCodes: latestRelease.staticScan.reasonCodes,
findings: (latestRelease.staticScan.findings ?? []).map((finding) => ({
code: finding.code,
severity: finding.severity,
file: finding.file,
line: finding.line,
message: finding.message,
evidence: "",
})),
summary: latestRelease.staticScan.summary,
engineVersion: latestRelease.staticScan.engineVersion,
checkedAt: latestRelease.staticScan.checkedAt,
}
: null,
}
: null,
});
}
@@ -4353,7 +4408,7 @@ export const findPackagePublishResultInternal = internalQuery({
q.eq("packageId", pkg._id).eq("version", args.version),
)
.unique();
if (!release || release.softDeletedAt || release.integritySha256 !== args.integritySha256) {
if (!isPublishedPackageRelease(release) || release.integritySha256 !== args.integritySha256) {
return null;
}
return { ok: true as const, packageId: pkg._id, releaseId: release._id };
@@ -7743,24 +7798,55 @@ async function publishPackageImpl(
) ?? [];
if (options.stagePrePublicationChecks) {
let existingRelease: Doc<"packageReleases"> | null = null;
if (existingPackage) {
const existingRelease = await runQueryRef<Doc<"packageReleases"> | null>(
existingRelease = await runQueryRef<Doc<"packageReleases"> | null>(
ctx,
internalRefs.packages.getReleaseByPackageAndVersionInternal,
{ packageId: existingPackage._id, version },
);
const canReuseExistingRelease =
packageInsertArgs.allowExistingRelease &&
existingRelease &&
!existingRelease.softDeletedAt &&
existingRelease.integritySha256 === integritySha256;
if (existingRelease && !canReuseExistingRelease) {
}
const existingAttempt = await runQueryRef<null | { attemptId: Id<"publishAttempts"> }>(
ctx,
internalRefs.publishAttempts.findExistingPublishAttemptForArtifactInternal,
{
kind: "package",
slug: name,
version,
},
);
if (existingAttempt) {
throw new ConvexError(
`Version ${version} already exists. Increment the version number and try again.`,
);
}
if (existingPackage && existingRelease) {
if (!existingRelease.softDeletedAt && existingRelease.publicationStatus === "pending") {
await runMutationRef(ctx, internalRefs.packages.discardPendingPackagePublicationInternal, {
packageId: existingPackage._id,
releaseId: existingRelease._id,
createdNewParent: hasNoPublishedPackageVersions(existingPackage),
});
throw new ConvexError(
`Version ${version} already exists. Increment the version number and try again.`,
`Previous pending publish for ${version} did not finish creating security checks. It was cleaned up; retry the publish.`,
);
}
throw new ConvexError(
`Version ${version} already exists. Increment the version number and try again.`,
);
}
const pendingResult = await runMutationRef<{
ok: true;
packageId: Id<"packages">;
releaseId: Id<"packageReleases">;
publicationStatus?: "pending" | "published";
createdNewParent?: boolean;
}>(ctx, internalRefs.packages.insertReleaseInternal, {
...packageInsertArgs,
publicationStatus: "pending",
});
const staged = await runMutationRef<{
attemptId: Id<"publishAttempts">;
status: string;
@@ -7769,6 +7855,9 @@ async function publishPackageImpl(
userId: actorUserId,
ownerUserId,
ownerPublisherId,
packageId: pendingResult.packageId,
packageReleaseId: pendingResult.releaseId,
createdNewParent: pendingResult.createdNewParent,
name,
displayName,
version,
@@ -7782,7 +7871,8 @@ async function publishPackageImpl(
}),
artifactFingerprint: integritySha256,
files,
packageInsertArgs: stripUndefinedForStoredAttempt(packageInsertArgs),
clawpackStorageId: packageInsertArgs.clawpackStorageId,
scanContext: buildPackagePublishAttemptScanContext(packageInsertArgs),
packageFollowup: stripUndefinedForStoredAttempt({
ownerUserId,
ownerPublisherId,
@@ -7819,6 +7909,13 @@ async function publishPackageImpl(
}
: undefined,
}),
}).catch(async (error) => {
await runMutationRef(ctx, internalRefs.packages.discardPendingPackagePublicationInternal, {
packageId: pendingResult.packageId,
releaseId: pendingResult.releaseId,
createdNewParent: pendingResult.createdNewParent,
});
throw error;
});
if (auth.kind === "github-actions") {
await runMutationRef(ctx, internalRefs.packagePublishTokens.revokeInternal, {
@@ -7833,6 +7930,9 @@ async function publishPackageImpl(
return {
ok: true as const,
status: "pending" as const,
packageId: pendingResult.packageId,
releaseId: pendingResult.releaseId,
publicationStatus: "pending" as const,
attemptId: staged.attemptId,
packageName: name,
version,
@@ -7994,7 +8094,9 @@ export const finalizePackagePublishAttemptInternal = internalAction({
| {
status: "claimed";
attemptId: Id<"publishAttempts">;
packageInsertArgs: unknown;
packageId?: Id<"packages">;
releaseId?: Id<"packageReleases">;
packageInsertArgs?: unknown;
packageFollowup: unknown;
}
| {
@@ -8011,12 +8113,21 @@ export const finalizePackagePublishAttemptInternal = internalAction({
let publishResult: { ok: true; packageId: Id<"packages">; releaseId: Id<"packageReleases"> };
try {
publishResult = await runMutationRef(
ctx,
internalRefs.packages.insertReleaseInternal,
claim.packageInsertArgs,
);
publishResult =
claim.releaseId !== undefined
? await runMutationRef(ctx, internalRefs.packages.publishPendingReleaseInternal, {
releaseId: claim.releaseId,
})
: await runMutationRef(
ctx,
internalRefs.packages.insertReleaseInternal,
claim.packageInsertArgs,
);
} catch (error) {
if (claim.releaseId !== undefined) {
await releasePackagePublishAttemptFinalizationClaim(ctx, claim.attemptId, claimId, error);
throw error;
}
const insertArgs = claim.packageInsertArgs as {
name?: string;
version?: string;
@@ -8127,6 +8238,28 @@ function stripUndefinedForStoredAttempt(value: unknown): unknown {
return result;
}
function buildPackagePublishAttemptScanContext(insertArgs: Record<string, unknown>) {
const verification =
insertArgs.verification &&
typeof insertArgs.verification === "object" &&
!Array.isArray(insertArgs.verification)
? (insertArgs.verification as Record<string, unknown>)
: {};
return stripUndefinedForStoredAttempt({
trustedOpenClawPlugin: verification.trustedOpenClawPlugin === true ? true : undefined,
release: {
staticScan: insertArgs.staticScan,
pluginManifestSummary: insertArgs.pluginManifestSummary,
verification: insertArgs.verification,
artifactKind: insertArgs.artifactKind,
npmIntegrity: insertArgs.npmIntegrity,
npmShasum: insertArgs.npmShasum,
npmTarballName: insertArgs.npmTarballName,
source: insertArgs.source,
},
});
}
function buildPackageFinalizationClaimId() {
return typeof crypto.randomUUID === "function"
? crypto.randomUUID()
@@ -9048,6 +9181,7 @@ async function listPackageInspectorScanBatch(
const items: PackageInspectorScanBatchItem[] = [];
for (const release of page.page) {
if (items.length >= batchSize) break;
if (!isPublishedPackageRelease(release)) continue;
const pkg = await ctx.db.get(release.packageId);
if (
!pkg ||
@@ -9137,7 +9271,7 @@ export const getPackageInspectorArtifactInternal = internalQuery({
},
handler: async (ctx, args) => {
const release = await ctx.db.get(args.releaseId);
if (!release || release.softDeletedAt) return null;
if (!isPublishedPackageRelease(release)) return null;
const pkg = await ctx.db.get(release.packageId);
if (
!pkg ||
@@ -9232,6 +9366,190 @@ async function sendResendEmail(args: { to: string; subject: string; text: string
}
}
function pendingPackagePublicationMetadata(release: Doc<"packageReleases">) {
return release.pendingPublication &&
typeof release.pendingPublication === "object" &&
!Array.isArray(release.pendingPublication)
? (release.pendingPublication as Record<string, unknown>)
: {};
}
function stringPendingField(metadata: Record<string, unknown>, field: string, fallback?: string) {
const value = metadata[field];
return typeof value === "string" ? value : fallback;
}
function booleanPendingField(metadata: Record<string, unknown>, field: string, fallback: boolean) {
const value = metadata[field];
return typeof value === "boolean" ? value : fallback;
}
function stringArrayPendingField(metadata: Record<string, unknown>, field: string) {
const value = metadata[field];
return Array.isArray(value)
? value.filter((entry): entry is string => typeof entry === "string")
: undefined;
}
export const discardPendingPackagePublicationInternal = internalMutation({
args: {
packageId: v.id("packages"),
releaseId: v.id("packageReleases"),
createdNewParent: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const release = await ctx.db.get(args.releaseId);
if (
!release ||
release.packageId !== args.packageId ||
release.publicationStatus !== "pending"
) {
return { deleted: false };
}
const storageIds = new Set<Id<"_storage">>();
for (const file of release.files ?? []) {
if (typeof file.storageId === "string") {
storageIds.add(file.storageId as Id<"_storage">);
}
}
if (typeof release.clawpackStorageId === "string") {
storageIds.add(release.clawpackStorageId as Id<"_storage">);
}
await ctx.db.delete(release._id);
await Promise.allSettled([...storageIds].map((storageId) => ctx.storage.delete(storageId)));
let parentDeleted = false;
if (args.createdNewParent) {
const pkg = await ctx.db.get(args.packageId);
if (pkg && !pkg.latestReleaseId) {
const remainingReleases = await ctx.db
.query("packageReleases")
.withIndex("by_package", (q) => q.eq("packageId", args.packageId))
.take(1);
if (remainingReleases.length === 0) {
await ctx.db.delete(args.packageId);
parentDeleted = true;
}
}
}
return { deleted: true, parentDeleted };
},
});
export const publishPendingReleaseInternal = internalMutation({
args: {
releaseId: v.id("packageReleases"),
},
handler: async (ctx, args) => {
const release = await ctx.db.get(args.releaseId);
if (!release || release.softDeletedAt) {
throw new ConvexError("Pending package release not found");
}
if (release.publicationStatus === undefined || release.publicationStatus === "published") {
return {
ok: true as const,
packageId: release.packageId,
releaseId: release._id,
};
}
if (release.publicationStatus !== "pending") {
throw new ConvexError(`Package release is ${release.publicationStatus}, not pending.`);
}
const pkg = await ctx.db.get(release.packageId);
if (!pkg || pkg.softDeletedAt || pkg.family === "skill") {
throw new ConvexError("Package not found");
}
const now = Date.now();
const metadata = pendingPackagePublicationMetadata(release);
const effectiveTags = stringArrayPendingField(metadata, "tags") ?? release.distTags ?? [];
const shouldPromoteLatest = effectiveTags.includes("latest");
const scanStatus = resolvePackageReleaseScanStatus(release);
const releaseVerification = release.verification
? { ...release.verification, scanStatus }
: release.verification;
const publishedRelease = {
...release,
publicationStatus: "published" as const,
pendingPublication: undefined,
verification: releaseVerification,
} as Doc<"packageReleases">;
const priorReleases = await ctx.db
.query("packageReleases")
.withIndex("by_package", (q) => q.eq("packageId", pkg._id))
.collect();
const nextTags = { ...pkg.tags };
for (const tag of effectiveTags) nextTags[tag] = release._id;
for (const priorRelease of priorReleases) {
if (priorRelease._id === release._id || !isPublishedPackageRelease(priorRelease)) continue;
const nextDistTags = (priorRelease.distTags ?? []).filter(
(tag) => !effectiveTags.includes(tag),
);
if (nextDistTags.length === (priorRelease.distTags ?? []).length) continue;
await ctx.db.patch(priorRelease._id, { distTags: nextDistTags });
}
await ctx.db.patch(release._id, {
publicationStatus: "published",
pendingPublication: undefined,
verification: releaseVerification,
});
await ctx.db.patch(pkg._id, {
displayName: stringPendingField(metadata, "displayName", pkg.displayName),
ownerUserId: pkg.ownerUserId,
ownerPublisherId: pkg.ownerPublisherId,
family: pkg.family,
summary: shouldPromoteLatest ? release.summary : pkg.summary,
icon: shouldPromoteLatest ? release.icon : pkg.icon,
categories: shouldPromoteLatest
? stringArrayPendingField(metadata, "categories")
: pkg.categories,
topics: shouldPromoteLatest ? stringArrayPendingField(metadata, "topics") : pkg.topics,
...(shouldPromoteLatest
? {
inferredCategories: undefined,
inferredTopics: undefined,
inferredFromReleaseId: undefined,
inferredCategoryConfidence: undefined,
inferredTopicConfidence: undefined,
inferredClassifierVersion: undefined,
inferredTopicClassifierVersion: undefined,
inferredInputHash: undefined,
inferredTopicInputHash: undefined,
inferredAt: undefined,
}
: {}),
sourceRepo: stringPendingField(metadata, "sourceRepo", release.sourceRepo),
runtimeId: shouldPromoteLatest ? release.runtimeId : pkg.runtimeId,
channel: stringPendingField(metadata, "channel", pkg.channel) as Doc<"packages">["channel"],
isOfficial: booleanPendingField(metadata, "isOfficial", pkg.isOfficial),
latestReleaseId: shouldPromoteLatest ? release._id : pkg.latestReleaseId,
latestVersionSummary: shouldPromoteLatest
? packageLatestSummaryFromRelease(publishedRelease)
: pkg.latestVersionSummary,
tags: nextTags,
compatibility: shouldPromoteLatest ? release.compatibility : pkg.compatibility,
verification: shouldPromoteLatest ? releaseVerification : pkg.verification,
scanStatus: shouldPromoteLatest ? scanStatus : pkg.scanStatus,
stats: { ...pkg.stats, versions: (pkg.stats?.versions ?? 0) + 1 },
updatedAt: now,
});
return {
ok: true as const,
packageId: pkg._id,
releaseId: release._id,
};
},
});
export const insertReleaseInternal = internalMutation({
args: {
actorUserId: v.id("users"),
@@ -9257,6 +9575,7 @@ export const insertReleaseInternal = internalMutation({
displayName: v.string(),
family: v.union(v.literal("skill"), v.literal("code-plugin"), v.literal("bundle-plugin")),
version: v.string(),
publicationStatus: v.optional(v.union(v.literal("pending"), v.literal("published"))),
changelog: v.string(),
icon: v.optional(v.string()),
tags: v.array(v.string()),
@@ -9302,6 +9621,8 @@ export const insertReleaseInternal = internalMutation({
},
handler: async (ctx, args) => {
const now = Date.now();
const publicationStatus = args.publicationStatus ?? "published";
const pendingPublication = publicationStatus === "pending";
const prePublicationScanStatus = args.llmAnalysis
? normalizePackageScanStatus(args.llmAnalysis.verdict ?? args.llmAnalysis.status)
: undefined;
@@ -9420,6 +9741,7 @@ export const insertReleaseInternal = internalMutation({
}
}
const createdNewParent = !existing;
const pkgId =
existing?._id ??
(await ctx.db.insert("packages", {
@@ -9498,6 +9820,24 @@ export const insertReleaseInternal = internalMutation({
const releaseId = await ctx.db.insert("packageReleases", {
packageId: pkgId,
version: args.version,
publicationStatus,
pendingPublication: pendingPublication
? {
displayName: args.displayName,
ownerUserId: args.ownerUserId,
ownerPublisherId: args.ownerPublisherId,
family: args.family,
summary: args.summary,
icon: args.icon,
categories: args.categories,
topics: args.topics,
sourceRepo: args.sourceRepo,
runtimeId: args.runtimeId,
channel: nextChannel,
isOfficial: nextIsOfficial,
tags: effectiveTags,
}
: undefined,
changelog: args.changelog,
summary: args.summary,
icon: args.icon,
@@ -9533,6 +9873,15 @@ export const insertReleaseInternal = internalMutation({
const pkg = existing ?? (await ctx.db.get(pkgId));
if (!pkg) throw new ConvexError("Package insert failed");
if (pendingPublication) {
return {
ok: true as const,
packageId: pkgId,
releaseId,
publicationStatus,
createdNewParent,
};
}
const nextTags = { ...pkg.tags };
for (const tag of effectiveTags) nextTags[tag] = releaseId;
+163 -1
View File
@@ -5,6 +5,7 @@ import {
claimPrePublicationChecks,
claimReadyPublishAttemptFinalizationRetryInternal,
completePendingPublishAttemptChecksInternal,
recordSkillPublishAttemptFinalizedInternal,
releasePackagePublishAttemptFinalizationClaimInternal,
releaseSkillPublishAttemptFinalizationClaimInternal,
} from "./publishAttempts";
@@ -39,6 +40,11 @@ const releasePackageFinalizationHandler = (
_handler: (ctx: unknown, args: unknown) => Promise<unknown>;
}
)._handler;
const recordSkillFinalizedHandler = (
recordSkillPublishAttemptFinalizedInternal as unknown as {
_handler: (ctx: unknown, args: unknown) => Promise<unknown>;
}
)._handler;
describe("publishAttempts", () => {
it("leases staged publish check claims long enough for scanner timeouts", async () => {
@@ -612,6 +618,63 @@ describe("publishAttempts", () => {
expect(transientCtx.db.patch.mock.calls[0]?.[1]).not.toHaveProperty("failedAt");
});
it("clears private pending skill metadata when finalization is recorded", async () => {
const now = Date.now();
const ctx = {
db: {
delete: vi.fn(),
get: vi.fn(async (id: string) =>
id === "publishAttempts:demo"
? {
_id: "publishAttempts:demo",
kind: "skill",
status: "finalizing",
skillVersionId: "skillVersions:pending",
followup: {},
finalizationClaimId: "finalize:claim",
finalizationClaimExpiresAt: now + 60_000,
}
: null,
),
insert: vi.fn(),
normalizeId: vi.fn(),
patch: vi.fn(),
query: vi.fn(),
replace: vi.fn(),
system: {},
},
};
const result = {
skillId: "skills:demo",
versionId: "skillVersions:pending",
embeddingId: "skillEmbeddings:demo",
publicationStatus: "published",
};
await expect(
recordSkillFinalizedHandler(ctx, {
attemptId: "publishAttempts:demo",
claimId: "finalize:claim",
result,
}),
).resolves.toEqual({
attemptId: "publishAttempts:demo",
status: "finalized",
result,
});
expect(ctx.db.patch).toHaveBeenCalledWith(
"publishAttempts:demo",
expect.objectContaining({
status: "finalized",
result,
}),
);
expect(ctx.db.patch).toHaveBeenCalledWith("skillVersions:pending", {
pendingPublication: undefined,
});
});
it("stores suspicious analysis with the staged insert before finalization", async () => {
const now = Date.now();
const llmAnalysis = {
@@ -755,6 +818,9 @@ describe("publishAttempts", () => {
kind: "skill",
status: "pending_checks",
userId: "users:publisher",
skillId: "skills:secret",
skillVersionId: "skillVersions:secret",
createdNewParent: true,
slug: "secret-skill",
version: "1.0.0",
artifactFingerprint: "fingerprint",
@@ -762,6 +828,10 @@ describe("publishAttempts", () => {
checkClaimExpiresAt: Date.now() + 60_000,
files: [{ storageId: "_storage:secret-skill" }],
})
.mockResolvedValueOnce({
_id: "skills:secret",
latestVersionId: undefined,
})
.mockResolvedValueOnce({
_id: "users:publisher",
handle: "publisher",
@@ -771,7 +841,23 @@ describe("publishAttempts", () => {
insert: vi.fn(),
replace: vi.fn(),
delete: vi.fn(),
query: vi.fn(),
query: vi.fn((table: string) => {
if (table === "skillVersionFingerprints") {
return {
withIndex: vi.fn(() => ({
take: vi.fn(async () => [{ _id: "skillVersionFingerprints:secret" }]),
})),
};
}
if (table === "skillVersions") {
return {
withIndex: vi.fn(() => ({
take: vi.fn(async () => []),
})),
};
}
throw new Error(`Unexpected table ${table}`);
}),
normalizeId: vi.fn(),
system: {},
},
@@ -802,6 +888,9 @@ describe("publishAttempts", () => {
});
expect(ctx.storage.delete).toHaveBeenCalledWith("_storage:secret-skill");
expect(ctx.db.delete).toHaveBeenCalledWith("skillVersionFingerprints:secret");
expect(ctx.db.delete).toHaveBeenCalledWith("skillVersions:secret");
expect(ctx.db.delete).toHaveBeenCalledWith("skills:secret");
expect(ctx.db.patch).toHaveBeenCalledWith(
"publishAttempts:demo",
expect.objectContaining({
@@ -823,6 +912,79 @@ describe("publishAttempts", () => {
});
});
it("keeps existing skill parents when TruffleHog blocks a pending new version", async () => {
const ctx = {
db: {
get: vi
.fn()
.mockResolvedValueOnce({
_id: "publishAttempts:demo",
kind: "skill",
status: "pending_checks",
userId: "users:publisher",
skillId: "skills:existing",
skillVersionId: "skillVersions:pending",
createdNewParent: false,
slug: "existing-skill",
version: "2.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((table: string) => {
if (table === "skillVersionFingerprints") {
return {
withIndex: vi.fn(() => ({
take: vi.fn(async () => [{ _id: "skillVersionFingerprints:pending" }]),
})),
};
}
throw new Error(`Unexpected table ${table}`);
}),
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.db.delete).toHaveBeenCalledWith("skillVersionFingerprints:pending");
expect(ctx.db.delete).toHaveBeenCalledWith("skillVersions:pending");
expect(ctx.db.delete).not.toHaveBeenCalledWith("skills:existing");
});
it("keeps TruffleHog-positive attempts pending when secret storage deletion fails", async () => {
const ctx = {
db: {
+226 -11
View File
@@ -9,11 +9,25 @@ const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
const CHECK_CLAIM_LEASE_MS = 30 * 60 * 1000;
const CHECK_RETRY_BACKOFF_MS = 5 * 60 * 1000;
const FINALIZATION_CLAIM_LEASE_MS = 10 * 60 * 1000;
const PUBLISH_ATTEMPT_STATUSES = [
"pending_checks",
"ready_to_finalize",
"finalizing",
"finalized",
"blocked",
"failed",
"expired",
] as const;
const publishResultValidator = v.object({
skillId: v.id("skills"),
versionId: v.id("skillVersions"),
embeddingId: v.id("skillEmbeddings"),
embeddingId: v.optional(v.id("skillEmbeddings")),
status: v.optional(v.union(v.literal("pending"), v.literal("published"))),
slug: v.optional(v.string()),
version: v.optional(v.string()),
publicationStatus: v.optional(v.union(v.literal("pending"), v.literal("published"))),
attemptId: v.optional(v.id("publishAttempts")),
});
const packagePublishResultValidator = v.object({
@@ -123,6 +137,9 @@ export const createSkillPublishAttemptInternal = internalMutation({
userId: v.id("users"),
ownerPublisherId: v.optional(v.id("publishers")),
sourceOwnerPublisherId: v.optional(v.id("publishers")),
skillId: v.id("skills"),
skillVersionId: v.id("skillVersions"),
createdNewParent: v.optional(v.boolean()),
slug: v.string(),
displayName: v.string(),
version: v.string(),
@@ -137,7 +154,7 @@ export const createSkillPublishAttemptInternal = internalMutation({
contentType: v.optional(v.string()),
}),
),
skillInsertArgs: v.any(),
scanContext: v.optional(v.any()),
followup: v.object({
skipWebhook: v.optional(v.boolean()),
ownerHandle: v.optional(v.string()),
@@ -160,6 +177,9 @@ export const createSkillPublishAttemptInternal = internalMutation({
userId: args.userId,
ownerPublisherId: args.ownerPublisherId,
sourceOwnerPublisherId: args.sourceOwnerPublisherId,
skillId: args.skillId,
skillVersionId: args.skillVersionId,
createdNewParent: args.createdNewParent,
slug: args.slug,
displayName: args.displayName,
version: args.version,
@@ -170,7 +190,7 @@ export const createSkillPublishAttemptInternal = internalMutation({
trufflehog: { status: "pending" },
clawscan: { status: "pending" },
},
skillInsertArgs: args.skillInsertArgs,
scanContext: args.scanContext,
followup: args.followup,
createdAt: now,
updatedAt: now,
@@ -197,11 +217,56 @@ function isTerminalRetriableAttemptStatus(status: string) {
return status === "blocked" || status === "failed" || status === "expired";
}
export const findExistingPublishAttemptForArtifactInternal = internalQuery({
args: {
kind: v.union(v.literal("skill"), v.literal("package")),
slug: v.string(),
version: v.string(),
userId: v.optional(v.id("users")),
ownerPublisherId: v.optional(v.id("publishers")),
},
handler: async (ctx, args) => {
for (const status of PUBLISH_ATTEMPT_STATUSES) {
const attempts = await ctx.db
.query("publishAttempts")
.withIndex("by_kind_status_slug_version_created", (q) =>
q
.eq("kind", args.kind)
.eq("status", status)
.eq("slug", args.slug)
.eq("version", args.version),
)
.order("desc")
.take(25);
const match = attempts.find((attempt) => {
if (args.kind === "package") return true;
if (args.ownerPublisherId !== undefined) {
return attempt.ownerPublisherId === args.ownerPublisherId;
}
return attempt.ownerPublisherId === undefined && attempt.userId === args.userId;
});
if (match) {
return {
attemptId: match._id,
status: match.status,
kind: match.kind,
slug: match.slug,
version: match.version,
};
}
}
return null;
},
});
export const createPackagePublishAttemptInternal = internalMutation({
args: {
userId: v.id("users"),
ownerUserId: v.id("users"),
ownerPublisherId: v.optional(v.id("publishers")),
packageId: v.id("packages"),
packageReleaseId: v.id("packageReleases"),
createdNewParent: v.optional(v.boolean()),
name: v.string(),
displayName: v.string(),
version: v.string(),
@@ -216,7 +281,9 @@ export const createPackagePublishAttemptInternal = internalMutation({
contentType: v.optional(v.string()),
}),
),
packageInsertArgs: v.any(),
clawpackStorageId: v.optional(v.id("_storage")),
scanContext: v.optional(v.any()),
packageInsertArgs: v.optional(v.any()),
packageFollowup: v.any(),
},
handler: async (ctx, args) => {
@@ -236,6 +303,9 @@ export const createPackagePublishAttemptInternal = internalMutation({
userId: args.userId,
ownerUserId: args.ownerUserId,
ownerPublisherId: args.ownerPublisherId,
packageId: args.packageId,
packageReleaseId: args.packageReleaseId,
createdNewParent: args.createdNewParent,
slug: args.name,
displayName: args.displayName,
version: args.version,
@@ -246,6 +316,8 @@ export const createPackagePublishAttemptInternal = internalMutation({
trufflehog: { status: "pending" },
clawscan: { status: "pending" },
},
clawpackStorageId: args.clawpackStorageId,
scanContext: args.scanContext,
packageInsertArgs: args.packageInsertArgs,
packageFollowup: args.packageFollowup,
createdAt: now,
@@ -259,9 +331,11 @@ export const createPackagePublishAttemptInternal = internalMutation({
function getSecretBlockedStorageIds(attempt: {
files: Array<{ storageId: Id<"_storage"> }>;
clawpackStorageId?: Id<"_storage">;
packageInsertArgs?: unknown;
}) {
const storageIds = new Set<Id<"_storage">>(attempt.files.map((file) => file.storageId));
if (attempt.clawpackStorageId) storageIds.add(attempt.clawpackStorageId);
const packageInsertArgs = attempt.packageInsertArgs;
if (packageInsertArgs && typeof packageInsertArgs === "object") {
const clawpackStorageId = (packageInsertArgs as { clawpackStorageId?: unknown })
@@ -273,7 +347,11 @@ function getSecretBlockedStorageIds(attempt: {
return [...storageIds];
}
function buildSkillAttemptScanContext(attempt: { skillInsertArgs?: unknown }) {
function buildSkillAttemptScanContext(attempt: {
scanContext?: unknown;
skillInsertArgs?: unknown;
}) {
if (attempt.scanContext) return attempt.scanContext;
const skillInsertArgs = asRecord(attempt.skillInsertArgs);
const parsed = asRecord(skillInsertArgs.parsed);
return withoutUndefined({
@@ -290,7 +368,11 @@ function buildSkillAttemptScanContext(attempt: { skillInsertArgs?: unknown }) {
});
}
function buildPackageAttemptScanContext(attempt: { packageInsertArgs?: unknown }) {
function buildPackageAttemptScanContext(attempt: {
scanContext?: unknown;
packageInsertArgs?: unknown;
}) {
if (attempt.scanContext) return attempt.scanContext;
const packageInsertArgs = asRecord(attempt.packageInsertArgs);
const verification = asRecord(packageInsertArgs.verification);
return withoutUndefined({
@@ -308,11 +390,70 @@ function buildPackageAttemptScanContext(attempt: { packageInsertArgs?: unknown }
});
}
function publishAttemptClawpackStorageId(attempt: { packageInsertArgs?: unknown }) {
function publishAttemptClawpackStorageId(attempt: {
clawpackStorageId?: Id<"_storage">;
packageInsertArgs?: unknown;
}) {
if (attempt.clawpackStorageId) return attempt.clawpackStorageId;
const clawpackStorageId = asRecord(attempt.packageInsertArgs).clawpackStorageId;
return typeof clawpackStorageId === "string" ? (clawpackStorageId as Id<"_storage">) : undefined;
}
async function deleteSecretBlockedPendingSkillArtifact(
ctx: MutationCtx,
attempt: {
skillId?: Id<"skills">;
skillVersionId?: Id<"skillVersions">;
createdNewParent?: boolean;
},
) {
if (!attempt.skillVersionId) return;
const fingerprints = await ctx.db
.query("skillVersionFingerprints")
.withIndex("by_version", (q) => q.eq("versionId", attempt.skillVersionId!))
.take(100);
for (const fingerprint of fingerprints) {
await ctx.db.delete(fingerprint._id);
}
await ctx.db.delete(attempt.skillVersionId);
if (!attempt.createdNewParent || !attempt.skillId) return;
const skill = await ctx.db.get(attempt.skillId);
if (!skill || skill.latestVersionId) return;
const remainingVersions = await ctx.db
.query("skillVersions")
.withIndex("by_skill", (q) => q.eq("skillId", attempt.skillId!))
.take(1);
if (remainingVersions.length === 0) {
await ctx.db.delete(attempt.skillId);
}
}
async function deleteSecretBlockedPendingPackageArtifact(
ctx: MutationCtx,
attempt: {
packageId?: Id<"packages">;
packageReleaseId?: Id<"packageReleases">;
createdNewParent?: boolean;
},
) {
if (!attempt.packageReleaseId) return;
await ctx.db.delete(attempt.packageReleaseId);
if (!attempt.createdNewParent || !attempt.packageId) return;
const pkg = await ctx.db.get(attempt.packageId);
if (!pkg || pkg.latestReleaseId) return;
const remainingReleases = await ctx.db
.query("packageReleases")
.withIndex("by_package", (q) => q.eq("packageId", attempt.packageId!))
.take(1);
if (remainingReleases.length === 0) {
await ctx.db.delete(attempt.packageId);
}
}
export const recordSkillPublishAttemptChecksPassedInternal = internalMutation({
args: {
attemptId: v.id("publishAttempts"),
@@ -398,6 +539,11 @@ export const completePendingPublishAttemptChecksInternal = internalMutation({
await Promise.all(
getSecretBlockedStorageIds(attempt).map((storageId) => ctx.storage.delete(storageId)),
);
if (attempt.kind === "skill") {
await deleteSecretBlockedPendingSkillArtifact(ctx, attempt);
} else if (attempt.kind === "package") {
await deleteSecretBlockedPendingPackageArtifact(ctx, attempt);
}
await ctx.db.patch(attempt._id, {
status: "blocked",
checks,
@@ -418,6 +564,25 @@ export const completePendingPublishAttemptChecksInternal = internalMutation({
}
if (args.clawscan.status === "blocked") {
if (attempt.kind === "skill" && attempt.skillVersionId) {
await ctx.db.patch(attempt.skillVersionId, {
publicationStatus: "blocked",
llmAnalysis: args.clawscanAnalysis,
publishAttemptId: attempt._id,
});
}
if (attempt.kind === "package" && attempt.packageReleaseId) {
const release = await ctx.db.get(attempt.packageReleaseId);
const verification = release?.verification
? { ...release.verification, scanStatus: "malicious" as const }
: release?.verification;
await ctx.db.patch(attempt.packageReleaseId, {
publicationStatus: "blocked",
verification,
llmAnalysis: args.clawscanAnalysis,
publishAttemptId: attempt._id,
});
}
await ctx.db.patch(attempt._id, {
status: "blocked",
checks,
@@ -453,6 +618,19 @@ export const completePendingPublishAttemptChecksInternal = internalMutation({
return { attemptId: attempt._id, kind: attempt.kind, status: "pending_checks" as const };
}
if (attempt.kind === "skill" && attempt.skillVersionId && args.clawscanAnalysis) {
await ctx.db.patch(attempt.skillVersionId, {
llmAnalysis: args.clawscanAnalysis,
publishAttemptId: attempt._id,
});
}
if (attempt.kind === "package" && attempt.packageReleaseId && args.clawscanAnalysis) {
await ctx.db.patch(attempt.packageReleaseId, {
llmAnalysis: args.clawscanAnalysis,
publishAttemptId: attempt._id,
});
}
await ctx.db.patch(attempt._id, {
status: "ready_to_finalize",
checks,
@@ -535,6 +713,10 @@ export const claimPendingPublishAttemptChecksInternal = internalMutation({
ownerUserId: attempt.ownerUserId,
ownerPublisherId: attempt.ownerPublisherId,
sourceOwnerPublisherId: attempt.sourceOwnerPublisherId,
skillId: attempt.skillId,
versionId: attempt.skillVersionId,
packageId: attempt.packageId,
releaseId: attempt.packageReleaseId,
slug: attempt.slug,
displayName: attempt.displayName,
version: attempt.version,
@@ -614,6 +796,10 @@ export const claimReadyPublishAttemptFinalizationRetryInternal = internalMutatio
ownerUserId: attempt.ownerUserId,
ownerPublisherId: attempt.ownerPublisherId,
sourceOwnerPublisherId: attempt.sourceOwnerPublisherId,
skillId: attempt.skillId,
versionId: attempt.skillVersionId,
packageId: attempt.packageId,
releaseId: attempt.packageReleaseId,
slug: attempt.slug,
displayName: attempt.displayName,
version: attempt.version,
@@ -661,6 +847,8 @@ export const claimSkillPublishAttemptForFinalizationInternal = internalMutation(
status: "claimed" as const,
attemptId: attempt._id,
createdAt: attempt.createdAt,
skillId: attempt.skillId,
versionId: attempt.skillVersionId,
skillInsertArgs: attempt.skillInsertArgs,
followup: buildSkillPublishFollowup(attempt),
};
@@ -702,6 +890,8 @@ export const claimPackagePublishAttemptForFinalizationInternal = internalMutatio
return {
status: "claimed" as const,
attemptId: attempt._id,
packageId: attempt.packageId,
releaseId: attempt.packageReleaseId,
packageInsertArgs: attempt.packageInsertArgs,
packageFollowup: attempt.packageFollowup,
};
@@ -774,6 +964,11 @@ export const recordSkillPublishAttemptFinalizedInternal = internalMutation({
finalizedAt: now,
updatedAt: now,
});
if (attempt.skillVersionId && attempt.skillVersionId === args.result.versionId) {
await ctx.db.patch(attempt.skillVersionId, {
pendingPublication: undefined,
});
}
return { attemptId: attempt._id, status: "finalized" as const, result: args.result };
},
@@ -867,6 +1062,7 @@ export const findSkillPublishAttemptPublicResultInternal = internalQuery({
skillId: skill._id,
versionId: version._id,
embeddingId: embedding._id,
publicationStatus: "published" as const,
};
},
});
@@ -914,6 +1110,10 @@ export const claimPrePublicationChecks: ReturnType<typeof action> = action({
ownerUserId?: Id<"users">;
ownerPublisherId?: Id<"publishers">;
sourceOwnerPublisherId?: Id<"publishers">;
skillId?: Id<"skills">;
versionId?: Id<"skillVersions">;
packageId?: Id<"packages">;
releaseId?: Id<"packageReleases">;
slug: string;
displayName: string;
version: string;
@@ -1067,15 +1267,28 @@ async function requireSkillPublishAttempt(
result?: {
skillId: Id<"skills">;
versionId: Id<"skillVersions">;
embeddingId: Id<"skillEmbeddings">;
embeddingId?: Id<"skillEmbeddings">;
status?: "pending" | "published";
slug?: string;
version?: string;
publicationStatus?: "pending" | "published";
attemptId?: Id<"publishAttempts">;
};
skillId?: Id<"skills">;
skillVersionId?: Id<"skillVersions">;
};
if (typed.kind !== "skill" || !typed.skillInsertArgs || !typed.followup) {
if (
typed.kind !== "skill" ||
!typed.followup ||
(!typed.skillVersionId && !typed.skillInsertArgs)
) {
throw new ConvexError("Skill publish attempt not found.");
}
return typed as typeof typed & {
kind: "skill";
skillInsertArgs: unknown;
skillId?: Id<"skills">;
skillVersionId?: Id<"skillVersions">;
skillInsertArgs?: unknown;
followup: { skipWebhook?: boolean; ownerHandle?: string };
};
}
@@ -1101,6 +1314,8 @@ async function requirePackagePublishAttempt(
| "expired";
packageInsertArgs?: unknown;
packageFollowup?: unknown;
packageId?: Id<"packages">;
packageReleaseId?: Id<"packageReleases">;
finalizationClaimId?: string;
finalizationClaimExpiresAt?: number;
result?: {
@@ -1109,7 +1324,7 @@ async function requirePackagePublishAttempt(
releaseId: Id<"packageReleases">;
};
};
if (typed.kind !== "package" || !typed.packageInsertArgs) {
if (typed.kind !== "package" || (!typed.packageReleaseId && !typed.packageInsertArgs)) {
throw new ConvexError("Package publish attempt not found.");
}
return typed;
+19
View File
@@ -990,6 +990,11 @@ const skillSlugAliases = defineTable({
const skillVersions = defineTable({
skillId: v.id("skills"),
version: v.string(),
publicationStatus: v.optional(
v.union(v.literal("pending"), v.literal("published"), v.literal("blocked")),
),
publishAttemptId: v.optional(v.id("publishAttempts")),
pendingPublication: v.optional(v.any()),
fingerprint: v.optional(v.string()),
sourceProvenance: v.optional(
v.object({
@@ -1125,6 +1130,12 @@ const publishAttempts = defineTable({
ownerUserId: v.optional(v.id("users")),
ownerPublisherId: v.optional(v.id("publishers")),
sourceOwnerPublisherId: v.optional(v.id("publishers")),
skillId: v.optional(v.id("skills")),
skillVersionId: v.optional(v.id("skillVersions")),
packageId: v.optional(v.id("packages")),
packageReleaseId: v.optional(v.id("packageReleases")),
createdNewParent: v.optional(v.boolean()),
clawpackStorageId: v.optional(v.id("_storage")),
slug: v.string(),
displayName: v.string(),
version: v.string(),
@@ -1137,6 +1148,7 @@ const publishAttempts = defineTable({
}),
skillInsertArgs: v.optional(v.any()),
packageInsertArgs: v.optional(v.any()),
scanContext: v.optional(v.any()),
followup: v.optional(
v.object({
skipWebhook: v.optional(v.boolean()),
@@ -1636,6 +1648,11 @@ const packages = defineTable({
const packageReleases = defineTable({
packageId: v.id("packages"),
version: v.string(),
publicationStatus: v.optional(
v.union(v.literal("pending"), v.literal("published"), v.literal("blocked")),
),
publishAttemptId: v.optional(v.id("publishAttempts")),
pendingPublication: v.optional(v.any()),
changelog: v.string(),
summary: v.optional(v.string()),
icon: v.optional(v.string()),
@@ -1655,6 +1672,7 @@ const packageReleases = defineTable({
extractedPackageJson: v.optional(v.any()),
extractedPluginManifest: v.optional(v.any()),
normalizedBundleManifest: v.optional(v.any()),
manifestSearchTerms: v.optional(v.array(v.string())),
pluginManifestSummary: v.optional(pluginManifestSummaryValidator),
compatibility: packageCompatibilityValidator,
runtimeId: v.optional(v.string()),
@@ -2044,6 +2062,7 @@ const packageSearchDigest = defineTable({
categories: v.optional(v.array(v.string())),
topics: v.optional(v.array(v.string())),
pluginCategoryTags: v.optional(v.array(v.string())),
manifestSearchTerms: v.optional(v.array(v.string())),
verificationTier: v.optional(packageVerificationTierValidator),
stats: v.optional(packageStatsValidator),
recommendedScore: v.optional(v.number()),
+438 -28
View File
@@ -7566,6 +7566,32 @@ function isStaleCursorError(error: unknown) {
return patterns.some((p) => msg.includes(p));
}
async function paginatePublicSkillVersions(
ctx: QueryCtx,
skillId: Id<"skills">,
initialCursor: string | null,
limit: number,
) {
const scanLimit = Math.max(
limit,
Math.min(MAX_FILTERED_PUBLIC_LIST_SCAN_ROWS, limit * MAX_FILTERED_PUBLIC_LIST_SCAN_PAGES),
);
const runPaginate = (pageCursor: string | null) =>
ctx.db
.query("skillVersions")
.withIndex("by_skill_active_created", (q) =>
q.eq("skillId", skillId).eq("softDeletedAt", undefined),
)
.order("desc")
.paginate({ cursor: pageCursor, numItems: scanLimit });
const page = await paginateWithStaleCursorRecovery(runPaginate, initialCursor);
const items = page.page
.filter((version) => isPublicSkillVersionAvailableForSkill(version, skillId))
.slice(0, limit);
return { items, nextCursor: page.isDone ? null : page.continueCursor };
}
export const countPublicSkills = query({
args: {},
handler: async (ctx) => {
@@ -7581,20 +7607,29 @@ export const listVersions = query({
const authUserId = await getAuthUserId(ctx);
const actor = authUserId ? await ctx.db.get(authUserId) : null;
const isStaff = actor?.role === "admin" || actor?.role === "moderator";
const versions = isStaff
? await ctx.db
.query("skillVersions")
.withIndex("by_skill", (q) => q.eq("skillId", args.skillId))
.order("desc")
.take(limit)
: await ctx.db
if (isStaff) {
const versions = await ctx.db
.query("skillVersions")
.withIndex("by_skill", (q) => q.eq("skillId", args.skillId))
.order("desc")
.take(limit);
return versions.map((version) => toPublicSkillVersion(version)!);
}
if (actor) {
const skill = await ctx.db.get(args.skillId);
if (skill && (await canManageSkillOwnerForActor(ctx, actor, skill))) {
const versions = await ctx.db
.query("skillVersions")
.withIndex("by_skill_active_created", (q) =>
q.eq("skillId", args.skillId).eq("softDeletedAt", undefined),
)
.order("desc")
.take(limit);
return versions.map((version) => toPublicSkillVersion(version)!);
return versions.map((version) => toPublicSkillVersion(version)!);
}
}
const publicVersions = await paginatePublicSkillVersions(ctx, args.skillId, null, limit);
return publicVersions.items.map((version) => toPublicSkillVersion(version)!);
},
});
@@ -7606,20 +7641,11 @@ export const listVersionsPage = query({
},
handler: async (ctx, args) => {
const limit = clampInt(args.limit ?? 20, 1, MAX_LIST_LIMIT);
const runPaginate = (cursor: string | null) =>
ctx.db
.query("skillVersions")
.withIndex("by_skill_active_created", (q) =>
q.eq("skillId", args.skillId).eq("softDeletedAt", undefined),
)
.order("desc")
.paginate({ cursor, numItems: limit });
const { page, isDone, continueCursor } = await paginateWithStaleCursorRecovery(
runPaginate,
args.cursor ?? null,
);
const items = page.map((version) => toPublicSkillVersion(version)!);
return { items, nextCursor: isDone ? null : continueCursor };
const page = await paginatePublicSkillVersions(ctx, args.skillId, args.cursor ?? null, limit);
return {
items: page.items.map((version) => toPublicSkillVersion(version)!),
nextCursor: page.nextCursor,
};
},
});
@@ -7627,7 +7653,10 @@ export const getVersionById = query({
args: { versionId: v.id("skillVersions") },
handler: async (ctx, args) => {
const version = await ctx.db.get(args.versionId);
return version && !version.softDeletedAt && version.ownerDeletedAt === undefined
return version &&
!version.softDeletedAt &&
version.ownerDeletedAt === undefined &&
isPublicSkillVersionAvailableForSkill(version, version.skillId)
? toPublicSkillVersion(version)
: null;
},
@@ -9558,7 +9587,10 @@ export const getVersionBySkillAndVersion = query({
q.eq("skillId", args.skillId).eq("version", args.version),
)
.unique();
return version && !version.softDeletedAt && version.ownerDeletedAt === undefined
return version &&
!version.softDeletedAt &&
version.ownerDeletedAt === undefined &&
isPublicSkillVersionAvailableForSkill(version, args.skillId)
? toPublicSkillVersion(version)
: null;
},
@@ -10013,7 +10045,7 @@ async function canReadSkillVersionFiles(ctx: ActionCtx, version: Doc<"skillVersi
if (skill.softDeletedAt || version.softDeletedAt) return false;
return Boolean(toPublicSkill(skill));
return Boolean(toPublicSkill(skill)) && isPublicSkillVersionAvailableForSkill(version, skill._id);
}
async function canReadGitHubSkillContent(ctx: QueryCtx, skill: Doc<"skills">) {
@@ -11915,6 +11947,59 @@ export const hardDeleteInternal = internalMutation({
},
});
type SkillPendingPublishArgs = {
userId: Id<"users">;
ownerPublisherId?: Id<"publishers">;
displayName: string;
version: string;
changelog: string;
changelogSource?: "auto" | "user";
tags?: string[];
categories?: string[];
topics?: string[];
files: Doc<"skillVersions">["files"];
parsed: Doc<"skillVersions">["parsed"];
summary?: string;
qualityAssessment?: {
decision: "pass" | "quarantine" | "reject";
score: number;
reason: string;
trustTier: "low" | "medium" | "trusted";
similarRecentCount: number;
signals: {
bodyChars: number;
bodyWords: number;
uniqueWordRatio: number;
headingCount: number;
bulletCount: number;
templateMarkerHits: number;
genericSummary: boolean;
cjkChars?: number;
};
};
staticScan: NonNullable<Doc<"skillVersions">["staticScan"]>;
llmAnalysis?: Doc<"skillVersions">["llmAnalysis"];
embedding: number[];
};
function asSkillPendingPublishArgs(value: unknown): SkillPendingPublishArgs {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new ConvexError("Pending skill publication metadata is missing.");
}
return value as SkillPendingPublishArgs;
}
function stripUndefinedForStoredPublication(value: unknown): unknown {
if (Array.isArray(value)) return value.map(stripUndefinedForStoredPublication);
if (!value || typeof value !== "object") return value;
const result: Record<string, unknown> = {};
for (const [key, nested] of Object.entries(value)) {
if (nested !== undefined) result[key] = stripUndefinedForStoredPublication(nested);
}
return result;
}
export const insertVersion = internalMutation({
args: {
userId: v.id("users"),
@@ -12009,9 +12094,12 @@ export const insertVersion = internalMutation({
}),
llmAnalysis: v.optional(v.any()),
embedding: v.array(v.number()),
publicationStatus: v.optional(v.union(v.literal("pending"), v.literal("published"))),
deferredAiEnrichment: v.optional(v.any()),
},
handler: async (ctx, args) => {
const userId = args.userId;
const isPendingPublication = args.publicationStatus === "pending";
// Lenient normalization first so we can look up an existing skill row
// before deciding whether to enforce the strict write-path validator.
// Owners of grandfathered slugs (reserved, <3 chars, >48 chars, or other
@@ -12126,6 +12214,7 @@ export const insertVersion = internalMutation({
// For existing rows, keep the already-persisted (possibly grandfathered)
// slug as-is so legacy publishers are not locked out of version updates.
const slug = skill ? normalizedSlug : normalizeSkillSlugForWrite(args.slug);
const createdNewParent = !skill;
if (!skill) {
const alias = await getSkillSlugAliasBySlugScoped(
@@ -12438,9 +12527,11 @@ export const insertVersion = internalMutation({
official: undefined,
deprecated: undefined,
},
moderationStatus: initialModerationStatus,
moderationReason,
moderationNotes,
moderationStatus: isPendingPublication ? "hidden" : initialModerationStatus,
moderationReason: isPendingPublication ? "pending.publication" : moderationReason,
moderationNotes: isPendingPublication
? "Pre-publication security checks are pending."
: moderationNotes,
moderationVerdict: initialScannerSnapshot.verdict,
moderationReasonCodes: initialScannerSnapshot.reasonCodes.length
? initialScannerSnapshot.reasonCodes
@@ -12499,6 +12590,10 @@ export const insertVersion = internalMutation({
const versionId = await ctx.db.insert("skillVersions", {
skillId: skill._id,
version: args.version,
publicationStatus: args.publicationStatus ?? "published",
pendingPublication: isPendingPublication
? stripUndefinedForStoredPublication({ skillInsertArgs: args })
: undefined,
fingerprint: args.fingerprint,
sourceProvenance: args.sourceProvenance,
changelog: args.changelog,
@@ -12513,6 +12608,22 @@ export const insertVersion = internalMutation({
softDeletedAt: undefined,
});
if (isPendingPublication) {
await ctx.db.insert("skillVersionFingerprints", {
skillId: skill._id,
versionId,
fingerprint: args.fingerprint,
kind: "source",
createdAt: now,
});
return {
skillId: skill._id,
versionId,
publicationStatus: "pending" as const,
createdNewParent,
};
}
// Only promote this version to `latest` if it is strictly greater than the
// currently published latest version (by semver). This allows backport /
// hotfix publishes on lower version lines (e.g. shipping 1.0.1 while 2.x is
@@ -12702,6 +12813,305 @@ export const insertVersion = internalMutation({
},
});
export const getPendingVersionPublishArgsInternal = internalQuery({
args: { versionId: v.id("skillVersions") },
handler: async (ctx, args) => {
const version = await ctx.db.get(args.versionId);
if (!version) return null;
const pendingPublication =
version.pendingPublication &&
typeof version.pendingPublication === "object" &&
!Array.isArray(version.pendingPublication)
? (version.pendingPublication as { skillInsertArgs?: unknown })
: null;
return pendingPublication?.skillInsertArgs ?? null;
},
});
export const discardPendingPublicationInternal = internalMutation({
args: {
skillId: v.id("skills"),
versionId: v.id("skillVersions"),
createdNewParent: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const version = await ctx.db.get(args.versionId);
if (!version || version.skillId !== args.skillId || version.publicationStatus !== "pending") {
return { deleted: false };
}
const storageIds = new Set<Id<"_storage">>();
for (const file of version.files ?? []) {
if (typeof file.storageId === "string") {
storageIds.add(file.storageId as Id<"_storage">);
}
}
const fingerprints = await ctx.db
.query("skillVersionFingerprints")
.withIndex("by_version", (q) => q.eq("versionId", version._id))
.take(100);
for (const fingerprint of fingerprints) {
await ctx.db.delete(fingerprint._id);
}
await ctx.db.delete(version._id);
await Promise.allSettled([...storageIds].map((storageId) => ctx.storage.delete(storageId)));
let parentDeleted = false;
if (args.createdNewParent) {
const skill = await ctx.db.get(args.skillId);
if (skill && !skill.latestVersionId) {
const remainingVersions = await ctx.db
.query("skillVersions")
.withIndex("by_skill", (q) => q.eq("skillId", args.skillId))
.take(1);
if (remainingVersions.length === 0) {
await ctx.db.delete(args.skillId);
parentDeleted = true;
}
}
}
return { deleted: true, parentDeleted };
},
});
export const publishPendingVersionInternal = internalMutation({
args: {
versionId: v.id("skillVersions"),
publishArgs: v.any(),
},
handler: async (ctx, args) => {
const version = await ctx.db.get(args.versionId);
if (!version || version.softDeletedAt) {
throw new ConvexError("Pending skill version not found.");
}
const skill = await ctx.db.get(version.skillId);
if (!skill) throw new ConvexError("Skill not found.");
const existingEmbedding = await ctx.db
.query("skillEmbeddings")
.withIndex("by_version", (q) => q.eq("versionId", version._id))
.unique();
if (version.publicationStatus === "published" || version.publicationStatus === undefined) {
if (!existingEmbedding) {
throw new ConvexError("Published skill version is missing its embedding.");
}
return {
skillId: skill._id,
versionId: version._id,
embeddingId: existingEmbedding._id,
publicationStatus: "published" as const,
};
}
if (version.publicationStatus !== "pending") {
throw new ConvexError(`Skill version is ${version.publicationStatus}, not pending.`);
}
const publishArgs = asSkillPendingPublishArgs(args.publishArgs);
const user = await ctx.db.get(publishArgs.userId);
if (!user || user.deletedAt || user.deactivatedAt) throw new Error("User not found");
const now = Date.now();
const prevLatestVersion = skill.latestVersionSummary?.version;
const isNewLatest =
!prevLatestVersion ||
!semver.valid(prevLatestVersion) ||
semver.gt(version.version, prevLatestVersion);
const nextTags: Record<string, Id<"skillVersions">> = { ...skill.tags };
if (isNewLatest) {
nextTags.latest = version._id;
}
for (const tag of publishArgs.tags ?? []) {
if (tag.toLowerCase() === "latest") continue;
nextTags[tag] = version._id;
}
const latestBefore = skill.latestVersionId;
const derivedSummary =
publishArgs.summary ??
getFrontmatterValue(publishArgs.parsed.frontmatter, "description") ??
skill.summary;
const nextSummary = isNewLatest ? derivedSummary : skill.summary;
const nextDisplayName = isNewLatest ? publishArgs.displayName : skill.displayName;
const qualityAssessment = publishArgs.qualityAssessment;
const isQualityQuarantine = qualityAssessment?.decision === "quarantine";
const isPublisherUnderModeration = Boolean(user.requiresModerationAt);
const initialModerationStatus =
isQualityQuarantine || isPublisherUnderModeration ? "hidden" : "active";
const moderationReason = isQualityQuarantine
? "quality.low"
: isPublisherUnderModeration
? USER_MODERATION_REASON
: "pending.scan";
const moderationNotes = isQualityQuarantine
? `Auto-quarantined by quality gate (score=${qualityAssessment.score}, tier=${qualityAssessment.trustTier}, similar=${qualityAssessment.similarRecentCount}).`
: isPublisherUnderModeration
? (user.requiresModerationReason ??
"Publisher is currently under manual moderation review.")
: undefined;
const qualityRecord = qualityAssessment
? {
score: qualityAssessment.score,
decision: qualityAssessment.decision,
trustTier: qualityAssessment.trustTier,
similarRecentCount: qualityAssessment.similarRecentCount,
reason: qualityAssessment.reason,
signals: qualityAssessment.signals,
evaluatedAt: now,
}
: undefined;
const derivedFlags = deriveModerationFlags({
skill: {
slug: skill.slug,
displayName: nextDisplayName,
summary: nextSummary ?? undefined,
},
parsed: publishArgs.parsed,
files: publishArgs.files,
});
const moderationSnapshot = buildModerationSnapshot({ sourceVersionId: version._id });
const nextFlags = Array.from(
new Set([...(derivedFlags ?? []), ...(moderationSnapshot.legacyFlags ?? [])]),
);
const versionForModeration = {
...version,
staticScan: publishArgs.staticScan,
llmAnalysis: publishArgs.llmAnalysis ?? version.llmAnalysis,
};
const scannerModerationPatch =
versionForModeration.llmAnalysis && !isQualityQuarantine && !isPublisherUnderModeration
? buildScannerModerationPatchFromVersion({
owner: null,
version: versionForModeration,
now,
})
: {};
const basePatch: SkillModerationPatch = {
displayName: nextDisplayName,
summary: nextSummary ?? undefined,
icon: skill.icon,
ownerPublisherId: skill.ownerPublisherId ?? publishArgs.ownerPublisherId,
latestVersionId: isNewLatest ? version._id : skill.latestVersionId,
latestVersionSummary: isNewLatest
? {
version: version.version,
createdAt: version.createdAt,
changelog: publishArgs.changelog,
changelogSource: publishArgs.changelogSource,
description: getFrontmatterValue(publishArgs.parsed.frontmatter, "description")?.trim(),
clawdis: publishArgs.parsed.clawdis,
}
: skill.latestVersionSummary,
tags: nextTags,
categories: isNewLatest ? publishArgs.categories : skill.categories,
topics: isNewLatest ? publishArgs.topics : skill.topics,
...(isNewLatest
? {
inferredCategories: undefined,
inferredTopics: undefined,
inferredFromVersionId: undefined,
inferredCategoryConfidence: undefined,
inferredTopicConfidence: undefined,
inferredClassifierVersion: undefined,
inferredTopicClassifierVersion: undefined,
inferredInputHash: undefined,
inferredTopicInputHash: undefined,
inferredAt: undefined,
}
: {}),
stats: { ...skill.stats, versions: skill.stats.versions + 1 },
softDeletedAt: undefined,
moderationStatus: initialModerationStatus,
moderationReason,
moderationNotes,
moderationVerdict: moderationSnapshot.verdict,
moderationReasonCodes: moderationSnapshot.reasonCodes.length
? moderationSnapshot.reasonCodes
: undefined,
moderationEvidence: moderationSnapshot.evidence.length
? moderationSnapshot.evidence
: undefined,
moderationSummary: moderationSnapshot.summary,
moderationEngineVersion: moderationSnapshot.engineVersion,
moderationEvaluatedAt: moderationSnapshot.evaluatedAt,
moderationSourceVersionId: version._id,
quality: qualityRecord ?? skill.quality,
moderationFlags: nextFlags.length ? nextFlags : undefined,
isSuspicious: computeIsSuspicious({
moderationFlags: nextFlags.length ? nextFlags : undefined,
moderationReason,
}),
unpublishedSlugReservedUntil: undefined,
unpublishedSlugReleasedAt: undefined,
unpublishedOriginalSlug: undefined,
updatedAt: now,
...scannerModerationPatch,
};
const patch = applySkillManualOverrideToSkillPatch({
skill,
basePatch,
now,
});
const nextSkill = { ...skill, ...patch };
await ctx.db.patch(version._id, {
publicationStatus: "published",
changelog: publishArgs.changelog,
changelogSource: publishArgs.changelogSource,
llmAnalysis: publishArgs.llmAnalysis ?? version.llmAnalysis,
});
await ctx.db.patch(skill._id, patch);
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
await adjustUserSkillStatsForSkillChange(ctx, skill, nextSkill);
await syncSkillSearchDigestForSkillDoc(ctx, nextSkill);
const badgeMap = await getSkillBadgeMap(ctx, skill._id);
const isApproved = Boolean(badgeMap.redactionApproved);
const embeddingId = existingEmbedding
? existingEmbedding._id
: await ctx.db.insert("skillEmbeddings", {
skillId: skill._id,
versionId: version._id,
ownerId: publishArgs.userId,
embedding: publishArgs.embedding,
isLatest: isNewLatest,
isApproved,
visibility: embeddingVisibilityFor(isNewLatest, isApproved),
updatedAt: now,
});
if (!existingEmbedding) {
await ctx.db.insert("embeddingSkillMap", {
embeddingId,
skillId: skill._id,
});
}
if (isNewLatest && latestBefore) {
const previousEmbedding = await ctx.db
.query("skillEmbeddings")
.withIndex("by_version", (q) => q.eq("versionId", latestBefore))
.unique();
if (previousEmbedding) {
await ctx.db.patch(previousEmbedding._id, {
isLatest: false,
visibility: embeddingVisibilityFor(false, previousEmbedding.isApproved),
updatedAt: now,
});
}
}
return {
skillId: skill._id,
versionId: version._id,
embeddingId,
publicationStatus: "published" as const,
};
},
});
async function isOwnerInitiatedSkillHideForActor(
ctx: MutationCtx,
skill: Pick<Doc<"skills">, "ownerUserId" | "ownerPublisherId" | "hiddenBy">,
+195 -63
View File
@@ -140,6 +140,43 @@ function makeVersion() {
};
}
function makePaginatedSkillVersionQuery(versions: Array<Record<string, unknown>>) {
const filters = new Map<string, unknown>();
const indexNames: string[] = [];
const paginate = vi.fn(
async ({ cursor, numItems }: { cursor: string | null; numItems: number }) => {
const start = cursor ? Number(cursor) : 0;
const filtered = versions.filter((candidate) =>
[...filters].every(([field, value]) => candidate[field] === value),
);
const page = filtered.slice(start, start + numItems);
const next = start + page.length;
return {
page,
isDone: next >= filtered.length,
continueCursor: String(next),
};
},
);
const withIndex = vi.fn(
(
index: string,
buildQuery?: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
) => {
indexNames.push(index);
const query = {
eq(field: string, value: unknown) {
filters.set(field, value);
return query;
},
};
buildQuery?.(query);
return { order: vi.fn(() => ({ paginate })) };
},
);
return { withIndex, paginate, filters, indexNames };
}
describe("public skill version queries", () => {
beforeEach(() => {
vi.mocked(getAuthUserId).mockReset();
@@ -232,17 +269,18 @@ describe("public skill version queries", () => {
it("sanitizes direct public version queries", async () => {
const version = makeVersion();
const unique = vi.fn().mockResolvedValue(version);
const take = vi.fn().mockResolvedValue([version]);
const paginated = makePaginatedSkillVersionQuery([version]);
const ctx = {
db: {
get: vi.fn().mockResolvedValue(version),
query: vi.fn((table: string) => {
if (table !== "skillVersions") throw new Error(`Unexpected table ${table}`);
return {
withIndex: vi.fn(() => ({
unique,
order: vi.fn(() => ({ take })),
})),
withIndex: vi.fn((index: string, buildQuery?: unknown) =>
index === "by_skill_active_created"
? paginated.withIndex(index, buildQuery as never)
: { unique },
),
};
}),
},
@@ -313,48 +351,71 @@ describe("public skill version queries", () => {
}
});
it("applies public version limits after selecting active skill versions", async () => {
it("hides pending publication versions while keeping legacy status-less versions public", async () => {
for (const version of [
{ ...makeVersion(), publicationStatus: "pending" },
{ ...makeVersion(), publicationStatus: "blocked" },
]) {
const ctx = {
db: {
get: vi.fn().mockResolvedValue(version),
query: vi.fn((table: string) => {
if (table !== "skillVersions") throw new Error(`Unexpected table ${table}`);
return {
withIndex: vi.fn(() => ({
unique: vi.fn().mockResolvedValue(version),
})),
};
}),
},
} as never;
await expect(
getVersionByIdHandler(ctx, { versionId: version._id } as never),
).resolves.toBeNull();
await expect(
getVersionBySkillAndVersionHandler(ctx, {
skillId: version.skillId,
version: version.version,
} as never),
).resolves.toBeNull();
}
const legacyVersion = makeVersion();
const legacyCtx = {
db: {
get: vi.fn().mockResolvedValue(legacyVersion),
query: vi.fn((table: string) => {
if (table !== "skillVersions") throw new Error(`Unexpected table ${table}`);
return {
withIndex: vi.fn(() => ({
unique: vi.fn().mockResolvedValue(legacyVersion),
})),
};
}),
},
} as never;
await expect(
getVersionByIdHandler(legacyCtx, { versionId: legacyVersion._id } as never),
).resolves.toMatchObject({ _id: legacyVersion._id });
});
it("applies public version limits after skipping pending publication versions", async () => {
const version = makeVersion();
const deletedVersion = {
const pendingVersion = {
...makeVersion(),
_id: "skillVersions:deleted",
_id: "skillVersions:pending",
version: "2.0.0",
softDeletedAt: 123,
publicationStatus: "pending",
};
const indexNames: string[] = [];
const filters = new Map<string, unknown>();
const take = vi.fn(async (limit: number) =>
[deletedVersion, version]
.filter((candidate) =>
[...filters].every(
([field, value]) => candidate[field as keyof typeof candidate] === value,
),
)
.slice(0, limit),
);
const paginated = makePaginatedSkillVersionQuery([pendingVersion, version]);
const ctx = {
db: {
get: vi.fn().mockResolvedValue(null),
query: vi.fn((table: string) => {
if (table !== "skillVersions") throw new Error(`Unexpected table ${table}`);
return {
withIndex: vi.fn(
(
index: string,
buildQuery?: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
) => {
indexNames.push(index);
const query = {
eq(field: string, value: unknown) {
filters.set(field, value);
return query;
},
};
buildQuery?.(query);
return { order: vi.fn(() => ({ take })) };
},
),
};
return { withIndex: paginated.withIndex };
}),
},
} as never;
@@ -365,14 +426,47 @@ describe("public skill version queries", () => {
} as never)) as Array<{ version: string }>;
expect(result.map((item) => item.version)).toEqual(["1.0.0"]);
expect(indexNames).toEqual(["by_skill_active_created"]);
expect(filters).toEqual(
expect(paginated.indexNames).toEqual(["by_skill_active_created"]);
expect(paginated.filters).toEqual(
new Map<string, unknown>([
["skillId", "skills:1"],
["softDeletedAt", undefined],
]),
);
expect(take).toHaveBeenCalledWith(1);
expect(paginated.paginate).toHaveBeenCalledOnce();
expect(paginated.paginate).toHaveBeenCalledWith({ cursor: null, numItems: 12 });
});
it("bounds public version pagination over hidden pending versions", async () => {
const pendingVersions = Array.from({ length: 13 }, (_, index) => ({
...makeVersion(),
_id: `skillVersions:pending-${index}`,
version: `2.0.${index}`,
publicationStatus: "pending",
}));
const publishedVersion = {
...makeVersion(),
_id: "skillVersions:published-after-backlog",
version: "1.0.0",
};
const paginated = makePaginatedSkillVersionQuery([...pendingVersions, publishedVersion]);
const ctx = {
db: {
query: vi.fn((table: string) => {
if (table !== "skillVersions") throw new Error(`Unexpected table ${table}`);
return { withIndex: paginated.withIndex };
}),
},
} as never;
const result = (await listVersionsPageHandler(ctx, {
skillId: "skills:1",
limit: 1,
} as never)) as { items: Array<{ version: string }>; nextCursor: string | null };
expect(result).toEqual({ items: [], nextCursor: "12" });
expect(paginated.paginate).toHaveBeenCalledOnce();
expect(paginated.paginate).toHaveBeenCalledWith({ cursor: null, numItems: 12 });
});
it.each(["admin", "moderator"] as const)(
@@ -429,31 +523,31 @@ describe("public skill version queries", () => {
},
);
it("keeps soft-deleted versions from consuming an ordinary viewer's limit", async () => {
it("shows active pending version history to owners through the bounded active index", async () => {
const version = makeVersion();
const deletedVersion = {
const pendingVersion = {
...makeVersion(),
_id: "skillVersions:deleted",
_id: "skillVersions:pending",
version: "2.0.0",
softDeletedAt: 123,
publicationStatus: "pending",
};
const indexNames: string[] = [];
const filters = new Map<string, unknown>();
const take = vi.fn(async (limit: number) =>
[deletedVersion, version]
.filter((candidate) =>
[...filters].every(
([field, value]) => candidate[field as keyof typeof candidate] === value,
),
)
.slice(0, limit),
);
vi.mocked(getAuthUserId).mockResolvedValue("users:viewer" as never);
const take = vi.fn(async (limit: number) => [pendingVersion, version].slice(0, limit));
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
const ctx = {
db: {
get: vi.fn(async (id: string) =>
id === "users:viewer" ? { _id: id, role: "user" } : null,
),
get: vi.fn(async (id: string) => {
if (id === "users:owner") return { _id: id, role: "user" };
if (id === "skills:1") {
return {
_id: id,
ownerUserId: "users:owner",
ownerPublisherId: undefined,
};
}
return null;
}),
query: vi.fn((table: string) => {
if (table !== "skillVersions") throw new Error(`Unexpected table ${table}`);
return {
@@ -480,10 +574,10 @@ describe("public skill version queries", () => {
const result = (await listVersionsHandler(ctx, {
skillId: "skills:1",
limit: 1,
limit: 50,
} as never)) as Array<{ version: string }>;
expect(result.map((item) => item.version)).toEqual(["1.0.0"]);
expect(result.map((item) => item.version)).toEqual(["2.0.0", "1.0.0"]);
expect(indexNames).toEqual(["by_skill_active_created"]);
expect(filters).toEqual(
new Map<string, unknown>([
@@ -491,7 +585,45 @@ describe("public skill version queries", () => {
["softDeletedAt", undefined],
]),
);
expect(take).toHaveBeenCalledWith(1);
expect(take).toHaveBeenCalledWith(50);
});
it("keeps soft-deleted versions from consuming an ordinary viewer's limit", async () => {
const version = makeVersion();
const deletedVersion = {
...makeVersion(),
_id: "skillVersions:deleted",
version: "2.0.0",
softDeletedAt: 123,
};
const paginated = makePaginatedSkillVersionQuery([deletedVersion, version]);
vi.mocked(getAuthUserId).mockResolvedValue("users:viewer" as never);
const ctx = {
db: {
get: vi.fn(async (id: string) =>
id === "users:viewer" ? { _id: id, role: "user" } : null,
),
query: vi.fn((table: string) => {
if (table !== "skillVersions") throw new Error(`Unexpected table ${table}`);
return { withIndex: paginated.withIndex };
}),
},
} as never;
const result = (await listVersionsHandler(ctx, {
skillId: "skills:1",
limit: 1,
} as never)) as Array<{ version: string }>;
expect(result.map((item) => item.version)).toEqual(["1.0.0"]);
expect(paginated.indexNames).toEqual(["by_skill_active_created"]);
expect(paginated.filters).toEqual(
new Map<string, unknown>([
["skillId", "skills:1"],
["softDeletedAt", undefined],
]),
);
expect(paginated.paginate).toHaveBeenCalledWith({ cursor: null, numItems: 12 });
});
it("paginates public version history over active skill versions", async () => {
@@ -556,7 +688,7 @@ describe("public skill version queries", () => {
["softDeletedAt", undefined],
]),
);
expect(paginate).toHaveBeenCalledWith({ cursor: "active-page", numItems: 1 });
expect(paginate).toHaveBeenCalledWith({ cursor: "active-page", numItems: 12 });
});
it("recovers public version pagination from stale pre-active-index cursors", async () => {
@@ -587,7 +719,7 @@ describe("public skill version queries", () => {
expect(result).toEqual({ items: [], nextCursor: null });
expect(paginate).toHaveBeenCalledTimes(1);
expect(paginate).toHaveBeenCalledWith({ cursor: "legacy-by-skill-cursor", numItems: 1 });
expect(paginate).toHaveBeenCalledWith({ cursor: "legacy-by-skill-cursor", numItems: 12 });
});
it("sanitizes latestVersion in listWithLatest", async () => {
+50
View File
@@ -90,6 +90,56 @@ describe("version file access actions", () => {
).rejects.toThrow("Version not available");
});
it("blocks unauthenticated access to pending-publication versions on public skills", async () => {
const ctx = makeActionCtx({
version: { ...makeSkillVersion(), publicationStatus: "pending" },
skill: {
_id: "skills:1",
ownerUserId: "users:owner",
softDeletedAt: undefined,
moderationStatus: "active",
moderationFlags: [],
stats: {
downloads: 0,
stars: 0,
installsAllTime: 0,
versions: 1,
comments: 0,
},
},
});
await expect(
getSkillReadmeHandler._handler(ctx, { versionId: "skillVersions:1" } as never),
).rejects.toThrow("Version not available");
});
it("allows owners to read pending-publication versions", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
const ctx = makeActionCtx({
actor: { _id: "users:owner", role: "user" },
version: { ...makeSkillVersion(), publicationStatus: "pending" },
skill: {
_id: "skills:1",
ownerUserId: "users:owner",
softDeletedAt: undefined,
moderationStatus: "active",
moderationFlags: [],
stats: {
downloads: 0,
stars: 0,
installsAllTime: 0,
versions: 1,
comments: 0,
},
},
});
await expect(
getSkillReadmeHandler._handler(ctx, { versionId: "skillVersions:1" } as never),
).resolves.toEqual({ path: "SKILL.md", text: "# skill" });
});
it("allows owners to read hidden skill versions", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
const ctx = makeActionCtx({
+32 -2
View File
@@ -1,6 +1,10 @@
import { expect, type ConsoleMessage, type Page } from "@playwright/test";
import { isKnownOpenClawMediaUrl } from "./externalMedia";
type TrackRuntimeErrorsOptions = {
includeConsoleLocation?: boolean;
};
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",
@@ -25,7 +29,15 @@ function isIgnoredVercelToolbarCspError(message: ConsoleMessage) {
);
}
export function trackRuntimeErrors(page: Page) {
function formatConsoleRuntimeError(message: ConsoleMessage, options: TrackRuntimeErrorsOptions) {
const text = `console:${message.text()}`;
if (!options.includeConsoleLocation) return text;
const locationUrl = message.location().url;
return locationUrl ? `${text} @ ${locationUrl}` : text;
}
export function trackRuntimeErrors(page: Page, options: TrackRuntimeErrorsOptions = {}) {
const errors: string[] = [];
page.on("pageerror", (error) => {
@@ -37,7 +49,7 @@ export function trackRuntimeErrors(page: Page) {
if (isIgnoredExternalResourceDnsError(message)) return;
if (isIgnoredTransientResourceError(message)) return;
if (isIgnoredVercelToolbarCspError(message)) return;
errors.push(`console:${message.text()}`);
errors.push(formatConsoleRuntimeError(message, options));
});
return errors;
@@ -63,6 +75,24 @@ export async function expectNoFatalErrorUi(page: Page) {
await expect(page.locator("text=Hide Error")).toHaveCount(0);
}
export async function recoverFromTransientErrorScreen(page: Page) {
const errorHeading = page.getByRole("heading", { name: /Something went wrong!?/i });
const legacyErrorText = page.locator("text=Something went wrong!").first();
const hasErrorScreen =
(await errorHeading.isVisible({ timeout: 500 }).catch(() => false)) ||
(await legacyErrorText.isVisible({ timeout: 500 }).catch(() => false));
if (!hasErrorScreen) return false;
const retryButton = page.getByRole("button", { name: "Try again" });
if (await retryButton.isVisible({ timeout: 500 }).catch(() => false)) {
await retryButton.click({ timeout: 5_000 });
} else {
await page.reload({ waitUntil: "domcontentloaded" });
}
await waitForHydration(page).catch(() => {});
return true;
}
export async function expectHealthyPage(page: Page, errors: string[]) {
await expectNoFatalErrorUi(page);
await expectNoRuntimeErrors(page, errors);
@@ -249,15 +249,11 @@ test("users can permanently delete their account and personal publisher resource
await signInAsLocalPersona(page, "user");
await gotoUntilVisible(
page,
buildPublisherProfileHref(fixture.handle),
page.getByRole("heading", { name: "Local User" }),
);
const profileSkillLink = page.locator(`a[href$="/${fixture.skillSlug}"]`).first();
await gotoUntilVisible(page, buildPublisherProfileHref(fixture.handle), profileSkillLink);
await expect(page.getByRole("heading", { name: "Local User" })).toBeVisible();
await expect(page.getByRole("region", { name: "Publisher catalog" })).toBeVisible();
await expect(page.locator(`a[href$="/${fixture.skillSlug}"]`).first()).toBeVisible({
timeout: 30_000,
});
await expect(profileSkillLink).toBeVisible({ timeout: 30_000 });
await gotoUntilVisible(
page,
@@ -202,7 +202,8 @@ test("org owners can delete an org and hide its skills and plugins", async ({ pa
await signInAsLocalPersona(page, "owner");
errors.length = 0;
await gotoUntilVisible(page, `/user/${handle}`, page.getByRole("heading", { name: displayName }));
const profileSkillLink = page.locator(`a[href$="/${skillSlug}"]`).first();
await gotoUntilVisible(page, `/user/${handle}`, profileSkillLink);
await expectPublisherProfileSkillLink(page, { headingName: displayName, skillSlug });
await gotoUntilVisible(
+121 -53
View File
@@ -9,7 +9,7 @@ import {
buildPluginSecurityAuditHref,
buildPluginValidationHref,
} from "../../src/lib/pluginRoutes";
import { waitForHydration } from "../helpers/runtimeErrors";
import { recoverFromTransientErrorScreen, waitForHydration } from "../helpers/runtimeErrors";
type DevPersona = "owner" | "user" | "admin" | "abusePublisher";
const WORKER_TOKEN = process.env.SECURITY_SCAN_WORKER_TOKEN ?? "local-e2e-worker-token";
@@ -120,27 +120,86 @@ function convexClient() {
return new ConvexHttpClient(convexUrl);
}
function wait(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
type MockPrePublicationClaim = {
attemptId: string;
claimId: string;
artifactFingerprint: string;
files?: Array<{ path: string; storageId?: string; url?: string | null }>;
};
async function claimMockPrePublicationChecks(args: {
kind: "skill" | "package";
slug: string;
version: string;
}) {
let lastClaimError: unknown;
let claim: MockPrePublicationClaim | null = null;
for (let attempt = 1; attempt <= 8; attempt += 1) {
const claimed = (await convexClient()
.action(api.publishAttempts.claimPrePublicationChecks, {
token: WORKER_TOKEN,
kind: args.kind,
slug: args.slug,
version: args.version,
})
.catch((error: unknown) => {
lastClaimError = error;
return null;
})) as MockPrePublicationClaim | null;
if (claimed) {
claim = claimed;
break;
}
await wait(Math.min(500 * attempt, 2_000));
}
if (!claim) {
const detail =
lastClaimError instanceof Error
? ` Last claim error: ${lastClaimError.message}`
: lastClaimError
? ` Last claim error: ${String(lastClaimError)}`
: "";
throw new Error(
`No pending ${args.kind} publish attempt for ${args.slug}@${args.version}.${detail}`,
);
}
return claim;
}
export async function expectSingleMockPrePublicationCheckRejected(args: {
kind: "skill" | "package";
slug: string;
version: string;
}) {
const claim = await claimMockPrePublicationChecks(args);
await expect(
convexClient().action(api.publishAttempts.completePrePublicationChecks, {
token: WORKER_TOKEN,
attemptId: claim.attemptId,
claimId: claim.claimId,
artifactFingerprint: claim.artifactFingerprint,
trufflehog: {
status: "clean",
summary: "Mock TruffleHog found no secrets in the local e2e fixture.",
},
} as never),
).rejects.toThrow();
return claim;
}
export async function completeMockPrePublicationChecks(args: {
kind: "skill" | "package";
slug: string;
version: string;
claim?: MockPrePublicationClaim;
trufflehog?: "clean" | "blocked";
clawscan?: "clean" | "suspicious" | "malicious" | "failed";
}) {
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}`);
}
const claim = args.claim ?? (await claimMockPrePublicationChecks(args));
const clawscan = args.clawscan ?? "clean";
const clawscanBlocked = clawscan === "malicious";
const clawscanFailed = clawscan === "failed";
@@ -155,29 +214,33 @@ export async function completeMockPrePublicationChecks(args: {
checkedAt: Date.now(),
}
: undefined;
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,
const completion = (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: clawscanBlocked ? "blocked" : clawscanFailed ? "failed" : "clean",
summary: "Mock ClawScan completed for the local e2e fixture.",
redactedFindings:
clawscan === "suspicious" || clawscan === "malicious"
? [`status=completed; verdict=${clawscan}`]
: undefined,
},
clawscanAnalysis,
},
clawscan: {
status: clawscanBlocked ? "blocked" : clawscanFailed ? "failed" : "clean",
summary: "Mock ClawScan completed for the local e2e fixture.",
redactedFindings:
clawscan === "suspicious" || clawscan === "malicious"
? [`status=completed; verdict=${clawscan}`]
: undefined,
},
clawscanAnalysis,
});
)) as Record<string, unknown>;
return { ...completion, claim };
}
function devPersonaHeaderPattern(persona: DevPersona, expectedHandle: string) {
@@ -284,6 +347,7 @@ export async function signInAsLocalPersona(page: Page, persona: DevPersona) {
try {
await page.goto("/", { waitUntil: "domcontentloaded" });
await waitForHydration(page);
await recoverFromTransientErrorScreen(page);
await page
.getByRole("button", { name: "Open local dev personas" })
@@ -298,6 +362,7 @@ export async function signInAsLocalPersona(page: Page, persona: DevPersona) {
} catch (error) {
lastError = error;
if (attempt >= 3) throw error;
await recoverFromTransientErrorScreen(page).catch(() => {});
await page.waitForTimeout(1_000 * attempt);
}
}
@@ -382,6 +447,7 @@ async function waitForPublishSkillForm(page: Page) {
for (let attempt = 0; attempt < 4; attempt += 1) {
await waitForHydration(page).catch(() => {});
await recoverFromTransientErrorScreen(page);
if (await heading.isVisible({ timeout: 5_000 }).catch(() => false)) {
try {
await page.getByTestId("upload-input").waitFor({ state: "attached", timeout: 15_000 });
@@ -423,6 +489,8 @@ export async function signInAsLocalPublisher(page: Page, persona: DevPersona) {
await expect
.poll(
async () => {
await recoverFromTransientErrorScreen(page);
await waitForPublishSkillForm(page);
const value = await getSelectedOwnerHandle(page, "#ownerHandle");
// The owner persona can briefly render the user handle before the
// personal publisher subscription reconciles to the publishable handle.
@@ -470,6 +538,19 @@ export async function publishSkillVersion(
const detailUrlPattern = new RegExp(`/[^/]+/(?:skills/)?${escapeRegExp(args.slug)}$`);
const versionExists = async () =>
args.versionExists ? await args.versionExists() : await publishedSkillVersionExists(page, args);
type PublishState = "duplicate" | "pending" | "private-detail" | "published" | "";
const readPublishState = async (): Promise<PublishState> => {
if (await hasDuplicateVersionAlert(page, args.version)) return "duplicate";
if (await versionExists()) return "published";
const pendingChecks = page.getByText("Running TruffleHog and ClawScan", { exact: false });
if (await pendingChecks.isVisible({ timeout: 500 }).catch(() => false)) {
return "pending";
}
if (!args.versionExists && detailUrlPattern.test(new URL(page.url()).pathname)) {
return "private-detail";
}
return "";
};
for (let attempt = 1; attempt <= 3; attempt += 1) {
let publishUrl = page.url();
try {
@@ -477,24 +558,11 @@ 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";
}
return "";
},
{ timeout: 60_000, intervals: [500, 1_000, 2_000] },
)
.poll(readPublishState, { timeout: 60_000, intervals: [500, 1_000, 2_000] })
.not.toBe("");
if (await pendingChecks.isVisible({ timeout: 500 }).catch(() => false)) {
const observedPublishState = await readPublishState();
if (observedPublishState !== "published" && !(await versionExists())) {
if (args.completeChecks === false) {
return args.ownerHandle;
}
@@ -1,16 +1,17 @@
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
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 { expect, type Page, test } from "@playwright/test";
import {
expectHealthyPage,
recoverFromTransientErrorScreen,
trackRuntimeErrors,
waitForHydration,
withoutRecoverableReactHydrationErrors,
} from "../helpers/runtimeErrors";
import { buildSkillDetailHref, publishSkillVersion, signInAsLocalPublisher } from "./helpers";
import {
buildSkillDetailHref,
completeMockPrePublicationChecks,
publishSkillVersion,
signInAsLocalPublisher,
} from "./helpers";
test.skip(
process.env.VITE_ENABLE_DEV_AUTH !== "1",
@@ -19,138 +20,6 @@ test.skip(
test.setTimeout(900_000);
test.describe.configure({ retries: 0 });
const WORKER_TOKEN = process.env.SECURITY_SCAN_WORKER_TOKEN ?? "local-e2e-worker-token";
const CLAIMED_SCAN_JOB_TIMEOUT_MS = 90_000;
const { ConvexHttpClient } = convexBrowser;
type ConvexHttpClientInstance = InstanceType<typeof ConvexHttpClient>;
type ClaimedScanJob = {
job: { _id: Id<"securityScanJobs">; leaseToken: string };
target?: { skill?: { slug?: string }; version?: { version?: string } };
};
type SkillLookupResult = { skill?: { _id: Id<"skills"> } | null } | null;
type VersionLookupResult = { version?: string } | null;
type CapturedEmail = {
idempotencyKey: string;
to: string;
subject: string;
text: string;
html: string;
capturedAt: number;
};
const ACCOUNT_SUSPENDED_SUBJECT = "Your ClawHub account has been suspended";
function convexClient() {
const convexUrl = process.env.VITE_CONVEX_URL;
if (!convexUrl) throw new Error("VITE_CONVEX_URL is required");
return new ConvexHttpClient(convexUrl);
}
async function sleep(ms: number) {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function readCapturedEmails() {
const captureFile = process.env.CLAWHUB_EMAIL_CAPTURE_FILE;
if (!captureFile) throw new Error("CLAWHUB_EMAIL_CAPTURE_FILE is required");
if (!existsSync(captureFile)) return [];
const raw = await readFile(captureFile, "utf8");
return raw
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => JSON.parse(line) as CapturedEmail);
}
async function waitForCapturedEmails(predicate: (emails: CapturedEmail[]) => boolean) {
const deadline = Date.now() + 60_000;
let latest: CapturedEmail[] = [];
while (Date.now() < deadline) {
latest = await readCapturedEmails();
if (predicate(latest)) return latest;
await sleep(500);
}
throw new Error(
`Timed out waiting for captured emails. Saw: ${latest
.map((email) => email.subject)
.join(", ")}`,
);
}
async function waitForClaimedScanJob(
client: ConvexHttpClientInstance,
slug: string,
version: string,
) {
const deadline = Date.now() + CLAIMED_SCAN_JOB_TIMEOUT_MS;
let lastError: unknown;
while (Date.now() < deadline) {
try {
const jobs = (await client.action(api.securityScan.claimCodexScanJobs, {
token: WORKER_TOKEN,
workerId: `pw-malicious-skill-${slug}-${version}`,
limit: 20,
leaseMs: 60_000,
})) as ClaimedScanJob[];
const match = jobs.find(
(job) => job.target?.skill?.slug === slug && job.target?.version?.version === version,
);
if (match) return match;
} catch (error) {
if (!isConvexTimeout(error)) throw error;
lastError = error;
}
await sleep(500);
}
if (lastError) throw lastError;
throw new Error(`Timed out waiting for security scan job for ${slug}@${version}`);
}
async function waitForSkillId(
client: ConvexHttpClientInstance,
args: { slug: string; ownerHandle: string },
) {
const deadline = Date.now() + 60_000;
let lastError: unknown;
while (Date.now() < deadline) {
try {
const result = (await client.query(api.skills.getBySlug, args)) as SkillLookupResult;
if (result?.skill?._id) return result.skill._id;
} catch (error) {
if (!isConvexTimeout(error)) throw error;
lastError = error;
}
await sleep(500);
}
if (lastError) throw lastError;
throw new Error(`Timed out waiting for skill id for ${args.ownerHandle}/${args.slug}`);
}
async function skillVersionExists(
client: ConvexHttpClientInstance,
skillId: Id<"skills">,
version: string,
) {
try {
const result = (await client.query(api.skills.getVersionBySkillAndVersion, {
skillId,
version,
})) as VersionLookupResult;
return result?.version === version;
} catch (error) {
if (!isConvexTimeout(error)) throw error;
return false;
}
}
function isConvexTimeout(error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return message.includes("Function execution timed out");
}
async function getNewVersionHref(page: Parameters<typeof waitForHydration>[0], detailPath: string) {
let lastError: unknown;
for (let attempt = 1; attempt <= 4; attempt += 1) {
@@ -171,72 +40,47 @@ async function getNewVersionHref(page: Parameters<typeof waitForHydration>[0], d
throw lastError;
}
async function completeScan(
client: ConvexHttpClientInstance,
args: { slug: string; version: string; verdict: "benign" | "malicious" },
) {
const scanJob = await waitForClaimedScanJob(client, args.slug, args.version);
const malicious = args.verdict === "malicious";
const completionArgs = {
token: WORKER_TOKEN,
jobId: scanJob.job._id,
leaseToken: scanJob.job.leaseToken,
runId: "playwright-local-auth",
llmAnalysis: {
status: malicious ? "malicious" : "clean",
verdict: args.verdict,
confidence: "high",
summary: malicious
? "Synthetic local e2e malicious verdict."
: "Synthetic local e2e clean verdict.",
guidance: malicious
? "Synthetic local e2e blocked upload."
: "Synthetic local e2e clean upload.",
model: "mock-local-e2e",
checkedAt: Date.now(),
},
};
async function expectCurrentVersion(page: Page, version: string) {
const detailUrl = page.url().split("#", 1)[0];
const expectedVersion = `v${version}`;
let lastError: unknown;
let sawTimeout = false;
for (let attempt = 1; attempt <= 3; attempt += 1) {
for (let attempt = 1; attempt <= 4; attempt += 1) {
try {
await client.action(api.securityScan.completeCodexScanJob, completionArgs);
await waitForHydration(page).catch(() => {});
await recoverFromTransientErrorScreen(page);
const metadata = page.locator(".detail-sidebar-stats .sidebar-metadata");
await expect(metadata.getByText("Current version", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(metadata.getByText(expectedVersion, { exact: true })).toBeVisible({
timeout: 30_000,
});
return;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (
sawTimeout &&
(message.includes("Lease mismatch") || message.includes("Unsupported security scan target"))
) {
return;
}
if (!isConvexTimeout(error) || attempt >= 3) throw error;
sawTimeout = true;
await sleep(1_000 * attempt);
lastError = error;
if (attempt >= 4) throw error;
await recoverFromTransientErrorScreen(page).catch(() => {});
await page.goto(detailUrl, { waitUntil: "domcontentloaded" }).catch(() => {});
await waitForHydration(page).catch(() => {});
await page.waitForTimeout(1_000 * attempt);
}
}
throw lastError;
}
async function expectCurrentVersion(page: import("@playwright/test").Page, version: string) {
const metadata = page.locator(".detail-sidebar-stats .sidebar-metadata");
await expect(metadata.getByText("Current version", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(metadata.getByText(`v${version}`, { exact: true })).toBeVisible({
timeout: 30_000,
});
}
function withoutExpectedBannedSessionTeardownErrors(errors: string[]) {
const timedOutDuringBannedSessionTeardown = [
function withoutExpectedPublishFlowErrors(errors: string[]) {
const recoverableTimeouts = [
"CONVEX Q(skills:listVersions)",
"CONVEX Q(skills:list)",
"CONVEX Q(skills:getBySlug)",
"CONVEX Q(skills:getActivityTrendForSlug)",
"CONVEX Q(skills:checkSlugAvailability)",
"CONVEX Q(users:me)",
"CONVEX Q(publishers:listMine)",
"CONVEX Q(publishers:getByHandle)",
"CONVEX Q(publishers:getMyProfileHandle)",
"CONVEX M(packages:applyBanToOwnedPackagesBatchInternal)",
];
return withoutRecoverableReactHydrationErrors(errors).filter(
(error) =>
@@ -245,7 +89,7 @@ function withoutExpectedBannedSessionTeardownErrors(errors: string[]) {
!(error.includes("CONVEX M(users:ensure)") && error.includes("User not found")) &&
!(
error.includes("Function execution timed out (maximum duration: 1s)") &&
timedOutDuringBannedSessionTeardown.some((functionName) => error.includes(functionName))
recoverableTimeouts.some((functionName) => error.includes(functionName))
) &&
!(
error.includes("CONVEX A(skills:publishVersion)") &&
@@ -256,17 +100,15 @@ function withoutExpectedBannedSessionTeardownErrors(errors: string[]) {
!(
error.includes("CONVEX A(skills:publishVersion)") &&
error.includes("Function execution timed out")
) &&
!(error.includes("CONVEX A(auth:signIn)") && error.includes("account has been banned")),
),
);
}
test("malicious skill retries keep the clean latest visible, email the publisher, and ban on third rejection", async ({
test("malicious prepublication retries keep the clean latest visible", async ({
page,
}, testInfo) => {
await page.route("https://openclaw.ai/**", (route) => route.fulfill({ status: 204 }));
const errors = trackRuntimeErrors(page);
const client = convexClient();
const slug = `pw-malware-${Date.now().toString(36)}`;
const displayName = "Playwright Malicious Skill Flow";
@@ -280,15 +122,12 @@ test("malicious skill retries keep the clean latest visible, email the publisher
changelog: "Clean baseline release before malicious retry validation.",
});
await page.goto("about:blank");
await completeScan(client, { slug, version: "1.0.0", verdict: "benign" });
const skillDetailPath = buildSkillDetailHref(ownerHandle, slug);
await page.goto(skillDetailPath, { waitUntil: "domcontentloaded" });
await waitForHydration(page);
await expectCurrentVersion(page, "1.0.0");
const skillId = await waitForSkillId(client, { slug, ownerHandle });
const maliciousVersions = ["1.0.1", "1.0.2", "1.0.3"] as const;
const finalMaliciousVersion = maliciousVersions[maliciousVersions.length - 1];
for (const version of maliciousVersions) {
const newVersionHref = await getNewVersionHref(page, skillDetailPath);
await page.goto(newVersionHref, { waitUntil: "domcontentloaded" });
@@ -300,73 +139,19 @@ test("malicious skill retries keep the clean latest visible, email the publisher
version,
versionLabel: `malicious retry ${version}`,
changelog: `Synthetic malicious retry ${version}.`,
versionExists: () => skillVersionExists(client, skillId, version),
completeChecks: false,
});
await page.goto("about:blank");
await completeScan(client, { slug, version, verdict: "malicious" });
if (version === finalMaliciousVersion) {
await waitForCapturedEmails((emails) =>
emails.some((email) => email.subject === ACCOUNT_SUSPENDED_SUBJECT),
);
} else {
await waitForCapturedEmails(
(emails) =>
emails.filter(
(email) =>
email.subject === "ClawHub blocked a skill version" &&
email.text.includes(`Version: ${version}`) &&
email.text.includes(`clawhub scan download ${slug} --version ${version}`),
).length === 1,
);
}
await completeMockPrePublicationChecks({
kind: "skill",
slug,
version,
clawscan: "malicious",
});
await page.goto(skillDetailPath, { waitUntil: "domcontentloaded" });
await waitForHydration(page);
if (version !== finalMaliciousVersion) {
await expectCurrentVersion(page, "1.0.0");
}
await expectCurrentVersion(page, "1.0.0");
}
const emails = await waitForCapturedEmails(
(captured) =>
captured.filter((email) => email.subject === "ClawHub blocked a skill version").length ===
2 && captured.some((email) => email.subject === ACCOUNT_SUSPENDED_SUBJECT),
);
const artifactEmails = emails.filter(
(email) => email.subject === "ClawHub blocked a skill version",
);
expect(artifactEmails).toHaveLength(2);
for (const email of artifactEmails) {
expect(email.text).toContain("Your account can still sign in.");
expect(email.text).toContain("Repeated malicious rejections may lead to account disablement");
expect(email.text).not.toContain("appeals.openclaw.ai");
}
const accountBanEmail = emails.find((email) => email.subject === ACCOUNT_SUSPENDED_SUBJECT);
expect(accountBanEmail?.text).toContain("Appeal: https://appeals.openclaw.ai/");
expect(accountBanEmail?.text).not.toContain("clawhub scan download");
await page.getByRole("button", { name: "Open local dev personas" }).click();
await page.getByRole("menuitem", { name: /sign out/i }).click();
const abusePublisherMenuItem = page.getByRole("menuitem", { name: /use abuse publisher/i });
if (!(await abusePublisherMenuItem.isVisible().catch(() => false))) {
await page.getByRole("button", { name: "Open local dev personas" }).click();
}
await abusePublisherMenuItem.click();
await expect(page).toHaveURL(/\/account-banned$/, { timeout: 30_000 });
await expect(
page.getByRole("heading", { name: "Your ClawHub account has been banned" }),
).toBeVisible();
await expect(page.getByText(/check your email/i)).toBeVisible();
await expect(page.getByRole("link", { name: "Open an appeal" })).toHaveAttribute(
"href",
"https://appeals.openclaw.ai/",
);
const bannedPageScreenshot = testInfo.outputPath("account-banned-page.png");
await page.screenshot({ path: bannedPageScreenshot, fullPage: true });
await testInfo.attach("account-banned-page", {
path: bannedPageScreenshot,
contentType: "image/png",
});
await expectHealthyPage(page, withoutExpectedBannedSessionTeardownErrors(errors));
await expectHealthyPage(page, withoutExpectedPublishFlowErrors(errors));
});
@@ -0,0 +1,367 @@
import { spawnSync } from "node:child_process";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, type APIRequestContext, test } from "@playwright/test";
import {
completeMockPrePublicationChecks,
expectSingleMockPrePublicationCheckRejected,
} from "./helpers";
const OLD_CLI_VERSION = "0.14.0";
test.skip(
process.env.VITE_ENABLE_DEV_AUTH !== "1",
"old CLI compatibility requires the local dev auth runner",
);
test.setTimeout(600_000);
type CliRoleHelpTokens = {
admin: { handle: string; token: string };
user: { handle: string; token: string };
};
function extractLastJsonObject(output: string) {
const trimmed = output.trim();
for (let index = 0; index < trimmed.length; index += 1) {
if (trimmed[index] !== "{") continue;
const candidate = trimmed.slice(index);
try {
return JSON.parse(candidate) as unknown;
} catch {
// Convex may print readiness lines before the JSON result.
}
}
throw new Error(`No JSON object in Convex output:\n${output}`);
}
function seedCliTokens() {
const result = spawnSync(
"bunx",
[
"convex",
"run",
"--no-push",
"--typecheck",
"disable",
"--codegen",
"disable",
"devSeed:seedCliRoleHelpFixtures",
"{}",
],
{
cwd: process.cwd(),
encoding: "utf8",
env: process.env,
timeout: 120_000,
},
);
if (result.status !== 0) {
throw new Error(`Failed to seed old CLI tokens:\n${result.stderr || result.stdout}`);
}
return extractLastJsonObject(result.stdout) as CliRoleHelpTokens;
}
function runOldCli(args: string[], configPath: string) {
return spawnSync(
"npm",
["exec", "--yes", "--package", `clawhub@${OLD_CLI_VERSION}`, "--", "clawhub", ...args],
{
cwd: process.cwd(),
encoding: "utf8",
env: {
...process.env,
ACTIONS_ID_TOKEN_REQUEST_TOKEN: undefined,
ACTIONS_ID_TOKEN_REQUEST_URL: undefined,
CLAWHUB_CONFIG_PATH: configPath,
CLAWHUB_DISABLE_TELEMETRY: "1",
GITHUB_ACTIONS: undefined,
},
timeout: 180_000,
},
);
}
async function writeCliConfig(root: string, registry: string, token: string) {
const path = join(root, "config.json");
await writeFile(path, `${JSON.stringify({ registry, token }, null, 2)}\n`, "utf8");
return path;
}
async function writeSkillFixture(root: string, slug: string) {
const skillDir = join(root, slug);
await mkdir(skillDir, { recursive: true });
await writeFile(
join(skillDir, "SKILL.md"),
`---
name: ${slug}
description: Verify staged publishing compatibility for older released ClawHub clients.
---
# ${slug}
## What it does
This skill verifies that a released older CLI can upload a complete skill package to the staged
publishing endpoint while the version remains unavailable through public APIs until automated
security checks finish.
## Usage
- Publish this directory with ClawHub CLI version ${OLD_CLI_VERSION}.
- Confirm the command exits successfully and returns a version identifier.
- Confirm the public version endpoint is unavailable while checks are pending.
- Complete clean TruffleHog and ClawScan checks.
- Confirm version 1.0.0 becomes public.
## Safety boundaries
This fixture contains no credentials, executable scripts, network calls, or environment-variable
values. Its documentation is deliberately specific so it passes the normal publish quality gate.
`,
"utf8",
);
return skillDir;
}
async function writePluginFixture(root: string, name: string) {
const pluginDir = join(root, name);
await mkdir(join(pluginDir, "dist"), { recursive: true });
await writeFile(
join(pluginDir, "package.json"),
`${JSON.stringify(
{
name,
version: "1.0.0",
type: "module",
main: "dist/index.js",
files: ["dist", "openclaw.plugin.json", "README.md"],
openclaw: {
extensions: ["./dist/index.js"],
compat: { pluginApi: ">=2026.3.24-beta.2" },
build: { openclawVersion: "2026.3.24-beta.2" },
configSchema: { type: "object", additionalProperties: false },
},
},
null,
2,
)}\n`,
"utf8",
);
await writeFile(
join(pluginDir, "openclaw.plugin.json"),
`${JSON.stringify(
{
id: name,
name: `Old CLI Plugin ${name}`,
configSchema: { type: "object", additionalProperties: false },
},
null,
2,
)}\n`,
"utf8",
);
await writeFile(
join(pluginDir, "README.md"),
`# ${name}
This clean fixture verifies that a released older ClawHub CLI can upload a complete OpenClaw code
plugin while the release remains private until TruffleHog and ClawScan complete. It performs no
network access, reads no credentials, and exposes only a deterministic test registration function.
`,
"utf8",
);
await writeFile(
join(pluginDir, "dist", "index.js"),
"export function register() { return { ok: true }; }\n",
"utf8",
);
return pluginDir;
}
function registryUrl() {
const url = process.env.VITE_CONVEX_SITE_URL?.replace(/\/$/u, "");
if (!url) throw new Error("VITE_CONVEX_SITE_URL is required");
return url;
}
async function expectSkillPublic(
request: APIRequestContext,
registry: string,
ownerHandle: string,
slug: string,
) {
const response = await request.get(
`${registry}/api/v1/skills/${encodeURIComponent(slug)}/versions/1.0.0?ownerHandle=${encodeURIComponent(
ownerHandle,
)}`,
);
expect(response.status()).toBe(200);
const body = (await response.json()) as { version?: { version?: unknown } };
expect(body.version?.version).toBe("1.0.0");
}
async function expectPluginPublic(request: APIRequestContext, registry: string, name: string) {
const response = await request.get(
`${registry}/api/v1/packages/${encodeURIComponent(name)}/versions/1.0.0`,
);
expect(response.status()).toBe(200);
const body = (await response.json()) as {
package?: { name?: unknown };
version?: { version?: unknown };
};
expect(body.package?.name).toBe(name);
expect(body.version?.version).toBe("1.0.0");
}
test("released CLI publishes skills and plugins only after both security checks pass", async ({
request,
}) => {
const registry = registryUrl();
const tokens = seedCliTokens();
const root = await mkdtemp(join(tmpdir(), "clawhub-old-cli-local-auth-"));
const suffix = Date.now().toString(36);
const slug = `old-cli-skill-${suffix}`;
const packageName = `old-cli-plugin-${suffix}`;
try {
const skillDir = await writeSkillFixture(root, slug);
const skillConfig = await writeCliConfig(root, registry, tokens.admin.token);
const skillPublish = runOldCli(
[
"--site",
registry,
"--registry",
registry,
"--workdir",
root,
"publish",
skillDir,
"--slug",
slug,
"--name",
`Old CLI Skill ${slug}`,
"--version",
"1.0.0",
"--tags",
"latest",
],
skillConfig,
);
expect(skillPublish.status, skillPublish.stderr).toBe(0);
expect(skillPublish.stderr).toContain(`OK. Published ${slug}@1.0.0`);
const privateSkill = await request.get(
`${registry}/api/v1/skills/${encodeURIComponent(
slug,
)}/versions/1.0.0?ownerHandle=${encodeURIComponent(tokens.admin.handle)}`,
);
expect(privateSkill.ok()).toBe(false);
expect(await privateSkill.text()).toContain("currently unavailable");
const skillClaim = await expectSingleMockPrePublicationCheckRejected({
kind: "skill",
slug,
version: "1.0.0",
});
const skillAfterOnlyTruffleHog = await request.get(
`${registry}/api/v1/skills/${encodeURIComponent(
slug,
)}/versions/1.0.0?ownerHandle=${encodeURIComponent(tokens.admin.handle)}`,
);
expect(skillAfterOnlyTruffleHog.ok()).toBe(false);
await completeMockPrePublicationChecks({
kind: "skill",
slug,
version: "1.0.0",
claim: skillClaim,
});
await expect
.poll(
async () => {
const response = await request.get(
`${registry}/api/v1/skills/${encodeURIComponent(
slug,
)}/versions/1.0.0?ownerHandle=${encodeURIComponent(tokens.admin.handle)}`,
);
return response.status();
},
{ timeout: 60_000, intervals: [500, 1_000, 2_000] },
)
.toBe(200);
await expectSkillPublic(request, registry, tokens.admin.handle, slug);
const pluginDir = await writePluginFixture(root, packageName);
const pluginConfig = await writeCliConfig(root, registry, tokens.user.token);
const pluginPublish = runOldCli(
[
"--site",
registry,
"--registry",
registry,
"--workdir",
root,
"package",
"publish",
pluginDir,
"--family",
"code-plugin",
"--name",
packageName,
"--display-name",
`Old CLI Plugin ${packageName}`,
"--owner",
tokens.user.handle,
"--version",
"1.0.0",
"--tags",
"latest",
"--source-repo",
"openclaw/clawhub",
"--source-commit",
"0123456789abcdef0123456789abcdef01234567",
],
pluginConfig,
);
expect(pluginPublish.status, pluginPublish.stderr).toBe(0);
expect(pluginPublish.stderr).toContain(`OK. Published ${packageName}@1.0.0`);
const privatePlugin = await request.get(
`${registry}/api/v1/packages/${encodeURIComponent(packageName)}/versions/1.0.0`,
);
expect(privatePlugin.ok()).toBe(false);
const pluginClaim = await expectSingleMockPrePublicationCheckRejected({
kind: "package",
slug: packageName,
version: "1.0.0",
});
const pluginAfterOnlyTruffleHog = await request.get(
`${registry}/api/v1/packages/${encodeURIComponent(packageName)}/versions/1.0.0`,
);
expect(pluginAfterOnlyTruffleHog.ok()).toBe(false);
await completeMockPrePublicationChecks({
kind: "package",
slug: packageName,
version: "1.0.0",
claim: pluginClaim,
});
await expect
.poll(
async () => {
const response = await request.get(
`${registry}/api/v1/packages/${encodeURIComponent(packageName)}/versions/1.0.0`,
);
return response.status();
},
{ timeout: 60_000, intervals: [500, 1_000, 2_000] },
)
.toBe(200);
await expectPluginPublic(request, registry, packageName);
} finally {
await rm(root, { recursive: true, force: true });
}
});
@@ -4,6 +4,7 @@ import { strToU8, zipSync } from "fflate";
import {
expectNoFatalErrorUi,
expectNoRuntimeErrors,
recoverFromTransientErrorScreen,
trackRuntimeErrors,
waitForHydration,
} from "../helpers/runtimeErrors";
@@ -86,8 +87,10 @@ async function writePluginZip(
}
async function uploadPluginZip(page: Page, zipPath: string) {
await recoverFromTransientErrorScreen(page);
await page.locator('input[type="file"]').first().setInputFiles(zipPath);
await waitForHydration(page);
await recoverFromTransientErrorScreen(page);
}
async function captureProof(page: Page, testInfo: TestInfo, name: string) {
@@ -107,15 +110,60 @@ function sawTransientUploadFailure(errors: string[]) {
);
}
const LOCAL_PACKAGE_API_URL_PATTERN =
/http:\/\/127\.0\.0\.1(?::\d+)?\/api\/v1\/packages\/[^'"\s)]+/u;
function localPackageApiUrl(error: string) {
return error.match(LOCAL_PACKAGE_API_URL_PATTERN)?.[0] ?? null;
}
async function expectHealthyInspectorPage(page: Page, errors: string[]) {
const expectedTransientTimeouts = [
"CONVEX Q(packages:canDeleteVersions)",
"CONVEX Q(packages:getActivityTrendForName)",
"CONVEX Q(packages:getManageContext)",
"CONVEX Q(packages:getPackageInspectorValidationSummaryPublic)",
"CONVEX Q(packages:list)",
"CONVEX Q(publishers:getByHandle)",
"CONVEX Q(publishers:getMyProfileHandle)",
"CONVEX Q(publishers:listMine)",
"CONVEX Q(users:me)",
];
const localPackageApiCorsUrls = new Set(
errors
.filter((error) => error.includes("CORS policy"))
.map(localPackageApiUrl)
.filter((url): url is string => Boolean(url)),
);
const shouldIgnoreLocalPackageApiFetchFailure = (error: string) => {
const corsUrl = localPackageApiUrl(error);
if (corsUrl && localPackageApiCorsUrls.has(corsUrl) && error.includes("CORS policy")) {
return true;
}
if (
localPackageApiCorsUrls.size > 0 &&
error.includes("console:TypeError: Failed to fetch") &&
error.includes("packageApi-")
) {
return true;
}
if (
corsUrl &&
localPackageApiCorsUrls.has(corsUrl) &&
error.startsWith("console:Failed to load resource: net::ERR_FAILED")
) {
return true;
}
return false;
};
const sawHttpRateLimitTimeout = errors.some(
(error) =>
error.includes("Function execution timed out (maximum duration: 1s)") &&
(error.includes("touchRateLimitKeyMetadata") ||
error.includes("checkRateLimit") ||
error.includes("httpRouteRateLimit")),
);
await recoverFromTransientErrorScreen(page);
await expectNoFatalErrorUi(page);
await expectNoRuntimeErrors(
page,
@@ -124,7 +172,21 @@ async function expectHealthyInspectorPage(page: Page, errors: string[]) {
!(
error.includes("Function execution timed out (maximum duration: 1s)") &&
expectedTransientTimeouts.some((functionName) => error.includes(functionName))
),
) &&
!(
sawHttpRateLimitTimeout &&
(error.includes("Function execution timed out (maximum duration: 1s)") ||
error.includes("ErrorBoundary caught") ||
error.includes("pageerror:Minified React error #422") ||
error.includes("pageerror:Minified React error #520") ||
error.startsWith(
"console:Failed to load resource: the server responded with a status of 500 (Internal Server Error)",
) ||
error.startsWith(
"console:Failed to load resource: the server responded with a status of 404 (Not Found)",
))
) &&
!shouldIgnoreLocalPackageApiFetchFailure(error),
),
);
}
@@ -136,6 +198,7 @@ async function expectDashboardWarningReview(page: Page, warningName: string) {
for (let attempt = 1; attempt <= 3; attempt += 1) {
await page.goto("/dashboard", { waitUntil: "domcontentloaded" });
await waitForHydration(page);
await recoverFromTransientErrorScreen(page);
try {
await expect(dashboardWarningRow).toBeVisible({ timeout: 30_000 });
await dashboardWarningRow.click();
@@ -157,11 +220,14 @@ async function expectValidationSectionVisible(page: Page, warningName: string) {
for (let attempt = 1; attempt <= 6; attempt += 1) {
await waitForHydration(page).catch(() => {});
await recoverFromTransientErrorScreen(page);
if ((await validationSection.count()) > 0) {
await expect(validationSection).toBeVisible({ timeout: 10_000 });
return;
}
await page.goto(detailHref, { waitUntil: "domcontentloaded" });
await waitForHydration(page).catch(() => {});
await recoverFromTransientErrorScreen(page);
await page.waitForTimeout(500 * attempt);
}
@@ -204,6 +270,7 @@ async function publishWarningPluginWithRetry(args: {
if (attempt > 0) await signInAsLocalPersona(args.page, "admin");
await args.page.goto("/plugins/publish", { waitUntil: "domcontentloaded" });
await waitForHydration(args.page);
await recoverFromTransientErrorScreen(args.page);
await uploadPluginZip(
args.page,
await writePluginZip(args.testInfo, {
@@ -251,6 +318,7 @@ async function publishHardErrorPluginWithRetry(args: {
if (attempt > 0) await signInAsLocalPersona(args.page, "admin");
await args.page.goto("/plugins/publish", { waitUntil: "domcontentloaded" });
await waitForHydration(args.page);
await recoverFromTransientErrorScreen(args.page);
await uploadPluginZip(
args.page,
await writePluginZip(args.testInfo, {
@@ -281,7 +349,7 @@ test("plugin publish stays private until mocked TruffleHog and ClawScan pass", a
page,
request,
}, testInfo) => {
const errors = trackRuntimeErrors(page);
const errors = trackRuntimeErrors(page, { includeConsoleLocation: true });
const suffix = Date.now().toString(36);
const name = `pw-staged-plugin-${suffix}`;
const displayName = `Playwright Staged Plugin ${suffix}`;
@@ -290,6 +358,7 @@ test("plugin publish stays private until mocked TruffleHog and ClawScan pass", a
await signInAsLocalPersona(page, "admin");
await page.goto("/plugins/publish", { waitUntil: "domcontentloaded" });
await waitForHydration(page);
await recoverFromTransientErrorScreen(page);
await uploadPluginZip(
page,
await writePluginZip(testInfo, {
@@ -322,6 +391,7 @@ test("plugin publish stays private until mocked TruffleHog and ClawScan pass", a
await page.goto(buildPluginDetailHref(name), { waitUntil: "domcontentloaded" });
await waitForHydration(page);
await recoverFromTransientErrorScreen(page);
await expect(page.locator("h1.skill-page-title", { hasText: displayName })).toBeVisible({
timeout: 30_000,
});
@@ -332,7 +402,7 @@ test("malicious ClawScan verdict keeps a staged plugin private", async ({
page,
request,
}, testInfo) => {
const errors = trackRuntimeErrors(page);
const errors = trackRuntimeErrors(page, { includeConsoleLocation: true });
const suffix = Date.now().toString(36);
const name = `pw-malicious-plugin-${suffix}`;
const displayName = `Playwright Malicious Plugin ${suffix}`;
@@ -341,6 +411,7 @@ test("malicious ClawScan verdict keeps a staged plugin private", async ({
await signInAsLocalPersona(page, "admin");
await page.goto("/plugins/publish", { waitUntil: "domcontentloaded" });
await waitForHydration(page);
await recoverFromTransientErrorScreen(page);
await uploadPluginZip(
page,
await writePluginZip(testInfo, {
@@ -372,7 +443,7 @@ test("malicious ClawScan verdict keeps a staged plugin private", async ({
test("plugin inspector blocks hard publish errors and publishes warning findings", async ({
page,
}, testInfo) => {
const errors = trackRuntimeErrors(page);
const errors = trackRuntimeErrors(page, { includeConsoleLocation: true });
const suffix = Date.now().toString(36);
await signInAsLocalPersona(page, "admin");
+179 -19
View File
@@ -1,3 +1,5 @@
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { expect, type APIRequestContext, type Page, test } from "@playwright/test";
import convexBrowser from "convex/browser";
import { api } from "../../convex/_generated/api";
@@ -40,6 +42,27 @@ type ClaimedSkillCardJob = {
target?: { skill?: { slug?: string }; version?: { version?: string } };
};
type PrePublicationSkillAttemptState = {
ok: true;
attemptExists: boolean;
attempt?: {
status: string;
slug: string;
version: string;
filesCount: number;
hasSkillInsertArgs: boolean;
hasFollowup: boolean;
trufflehogStatus: string;
trufflehogRedactedFindingCount: number;
clawscanStatus: string;
blockedAt: number | null;
};
skillExists: boolean;
skillLatestVersionId: string | null;
versionExists: boolean;
versionPublicationStatus: string | null;
};
function convexClient() {
const convexUrl = process.env.VITE_CONVEX_URL;
if (!convexUrl) throw new Error("VITE_CONVEX_URL is required");
@@ -52,6 +75,63 @@ function convexSiteUrl() {
return url.replace(/\/$/u, "");
}
function localConvexDeployment() {
const raw = readFileSync(".convex/local/default/config.json", "utf8");
const parsed = JSON.parse(raw) as { deploymentName?: unknown };
if (typeof parsed.deploymentName !== "string" || !parsed.deploymentName) {
throw new Error("Local Convex deployment name was not available");
}
return `local:${parsed.deploymentName}`;
}
function extractLastJsonObject(output: string) {
const trimmed = output.trim();
for (let index = 0; index < trimmed.length; index += 1) {
if (trimmed[index] !== "{") continue;
const candidate = trimmed.slice(index);
try {
JSON.parse(candidate);
return candidate;
} catch {
// Convex can print status lines before the JSON payload.
}
}
throw new Error(`No JSON object in convex run output:\n${output}`);
}
function runDevSeed<T>(functionName: string, args: Record<string, unknown>) {
const result = spawnSync(
"bunx",
[
"convex",
"run",
"--typecheck",
"disable",
"--codegen",
"disable",
functionName,
JSON.stringify(args),
],
{
cwd: process.cwd(),
env: { ...process.env, CONVEX_DEPLOYMENT: localConvexDeployment() },
encoding: "utf8",
},
);
if (result.status !== 0) {
throw new Error(
[`Failed to run ${functionName}.`, result.stdout.trim(), result.stderr.trim()].join("\n"),
);
}
return JSON.parse(extractLastJsonObject(result.stdout)) as T;
}
function getPrePublicationSkillAttemptState(attemptId: string) {
return runDevSeed<PrePublicationSkillAttemptState>("devSeed:getPrePublicationSkillAttemptState", {
attemptId,
});
}
async function sleep(ms: number) {
await new Promise((resolve) => setTimeout(resolve, ms));
}
@@ -307,7 +387,52 @@ 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 ({
test("clean skill publish stays private until TruffleHog and ClawScan pass", async ({
page,
request,
}, testInfo) => {
const errors = trackRuntimeErrors(page);
const slug = `pw-staged-skill-${Date.now().toString(36)}`;
const displayName = "Playwright Staged Clean Skill";
const version = "1.0.0";
const ownerHandle = await signInAsLocalPublisher(page, "admin");
await publishSkillVersion(page, testInfo, {
ownerHandle,
slug,
displayName,
version,
versionLabel: "clean staged release",
changelog: "Clean release should wait for both scanners.",
completeChecks: false,
});
await expect(await publicSkillVersionExists(request, { ownerHandle, slug, version })).toBe(false);
await expect(await publishedSkillVersionExists(page, { ownerHandle, slug, version })).toBe(false);
const result = (await completeMockPrePublicationChecks({
kind: "skill",
slug,
version,
})) as { status?: string; result?: { versionId?: string } };
expect(result.status).toBe("finalized");
await expect
.poll(() => publicSkillVersionExists(request, { ownerHandle, slug, version }), {
timeout: 60_000,
intervals: [500, 1_000, 2_000],
})
.toBe(true);
await page.goto(`/${ownerHandle}/${slug}`, { waitUntil: "domcontentloaded" });
await waitForHydration(page);
await expect(page.locator("h1.skill-page-title", { hasText: displayName })).toBeVisible({
timeout: 30_000,
});
await expectCurrentVersion(page, version);
await expectHealthyPublishPage(page, errors);
});
test("mocked TruffleHog deletes a secret-positive pending skill upload before it becomes public", async ({
page,
request,
}, testInfo) => {
@@ -325,7 +450,7 @@ test("mocked TruffleHog blocks a secret-positive skill upload until the secret i
## Local secret fixture
This fake token is intentionally redacted by the mocked TruffleHog worker:
OPENAI_API_KEY=sk-local-e2e-redacted-secret-not-real
LOCAL_E2E_SECRET_MARKER=redacted-secret-marker-not-real
`;
await publishSkillVersion(page, testInfo, {
@@ -339,32 +464,67 @@ OPENAI_API_KEY=sk-local-e2e-redacted-secret-not-real
completeChecks: false,
});
await completeMockPrePublicationChecks({
const blocked = (await completeMockPrePublicationChecks({
kind: "skill",
slug,
version,
trufflehog: "blocked",
});
})) as {
status?: string;
claim?: {
attemptId: string;
files?: Array<{ url?: string | null }>;
};
};
expect(blocked.status).toBe("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",
}),
});
const attemptId = blocked.claim?.attemptId;
expect(attemptId).toBeTruthy();
const uploadedFileUrls =
blocked.claim?.files
?.map((file) => file.url)
.filter((url): url is string => typeof url === "string" && url.length > 0) ?? [];
expect(uploadedFileUrls.length).toBeGreaterThan(0);
await expect
.poll(() => getPrePublicationSkillAttemptState(attemptId!), {
timeout: 30_000,
intervals: [500, 1_000, 2_000],
})
.toEqual(
expect.objectContaining({
attemptExists: true,
attempt: expect.objectContaining({
status: "blocked",
slug,
version,
filesCount: 0,
hasSkillInsertArgs: false,
hasFollowup: false,
trufflehogStatus: "blocked",
trufflehogRedactedFindingCount: 1,
clawscanStatus: "clean",
}),
skillExists: false,
versionExists: false,
}),
);
for (const url of uploadedFileUrls) {
await expect
.poll(
async () => {
const response = await request.get(url, { timeout: 2_000 }).catch(() => null);
return response?.ok() ?? false;
},
{ timeout: 30_000, intervals: [500, 1_000, 2_000] },
)
.toBe(false);
}
await expectCurrentVersion(page, version);
await expectHealthyPublishPage(page, errors);
});
+96 -24
View File
@@ -1,12 +1,14 @@
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { expect, type Locator, test } from "@playwright/test";
import { expect, test } from "@playwright/test";
import { buildSkillDetailHref } from "../../src/lib/ownerRoute";
import { buildPluginDetailHref } from "../../src/lib/pluginRoutes";
import {
expectNoFatalErrorUi,
recoverFromTransientErrorScreen,
trackRuntimeErrors,
waitForHydration,
withoutRecoverableReactHydrationErrors,
} from "../helpers/runtimeErrors";
import { signInAsLocalPersona } from "./helpers";
@@ -189,8 +191,10 @@ function isExpectedVersionDeletionRuntimeError(error: string) {
return [
"[CONVEX Q(packages:canDeleteVersions)]",
"[CONVEX Q(packages:getActivityTrendForName)]",
"[CONVEX Q(packages:getPackageInspectorValidationSummaryPublic)]",
"[CONVEX Q(packages:getManageContext)]",
"[CONVEX Q(packages:listPackageInspectorWarningsForManager)]",
"[CONVEX Q(publishers:getByHandle)]",
"[CONVEX Q(publishers:getMyProfileHandle)]",
"[CONVEX Q(publishers:listMine)]",
"[CONVEX Q(skills:getActivityTrendForSlug)]",
@@ -224,6 +228,11 @@ function versionToggle(page: Parameters<typeof expectNoFatalErrorUi>[0], version
.filter({ hasText: new RegExp(`^v${version.replaceAll(".", "\\.")}`) });
}
async function ensureVersionsTab(page: Parameters<typeof expectNoFatalErrorUi>[0]) {
await recoverFromTransientErrorScreen(page);
await page.getByRole("tab", { name: "Versions" }).click({ timeout: 30_000 });
}
async function openDeleteDialog(page: Parameters<typeof expectNoFatalErrorUi>[0]) {
const deleteButton = page.getByRole("button", { name: `Delete version ${OLDER_VERSION}` });
let lastError: unknown;
@@ -239,31 +248,85 @@ async function openDeleteDialog(page: Parameters<typeof expectNoFatalErrorUi>[0]
await page.keyboard.press("Escape").catch(() => {});
await page.reload({ waitUntil: "domcontentloaded" });
await waitForHydration(page);
await page.getByRole("tab", { name: "Versions" }).click({ timeout: 30_000 });
await ensureVersionsTab(page);
await page.waitForTimeout(1_000 * attempt);
}
}
throw lastError;
}
async function confirmDeleteDialog(dialog: Locator) {
const deleteButton = dialog.getByRole("button", { name: "Delete version" });
await expect(deleteButton).toBeVisible({ timeout: 30_000 });
await expect(deleteButton).toBeEnabled({ timeout: 30_000 });
await deleteButton.click({ timeout: 30_000 });
async function confirmDeleteDialog(page: Parameters<typeof expectNoFatalErrorUi>[0]) {
let lastError: unknown;
for (let attempt = 1; attempt <= 3; attempt += 1) {
let deleteButton: ReturnType<typeof page.getByRole> | null = null;
try {
await recoverFromTransientErrorScreen(page);
const dialog = page.getByRole("dialog");
deleteButton = dialog.getByRole("button", { name: "Delete version" });
await expect(deleteButton).toBeVisible({ timeout: 30_000 });
await expect(deleteButton).toBeEnabled({ timeout: 30_000 });
} catch (error) {
lastError = error;
if (attempt >= 3) throw error;
await page.keyboard.press("Escape").catch(() => {});
await page.reload({ waitUntil: "domcontentloaded" });
await waitForHydration(page);
await ensureVersionsTab(page);
await openDeleteDialog(page);
await page.waitForTimeout(1_000 * attempt);
}
if (!deleteButton) continue;
try {
await deleteButton.click({ timeout: 30_000 });
return;
} catch (error) {
await recoverFromTransientErrorScreen(page).catch(() => {});
await ensureVersionsTab(page).catch(() => {});
if ((await versionToggle(page, OLDER_VERSION).count()) === 0) return;
throw error;
}
}
throw lastError;
}
async function expectVersionsList(page: Parameters<typeof expectNoFatalErrorUi>[0]) {
await expect(versionToggle(page, OLDER_VERSION)).toBeVisible();
await expect(versionToggle(page, LATEST_VERSION)).toBeVisible();
await expect(page.getByRole("button", { name: `Delete version ${OLDER_VERSION}` })).toBeVisible();
await expect(page.getByRole("button", { name: `Delete version ${LATEST_VERSION}` })).toHaveCount(
0,
);
await expect(page.getByRole("button", { name: /restore/i })).toHaveCount(0);
let lastError: unknown;
for (let attempt = 1; attempt <= 4; attempt += 1) {
try {
await waitForHydration(page).catch(() => {});
await recoverFromTransientErrorScreen(page);
await ensureVersionsTab(page);
const versionsPanel = page.getByRole("tabpanel", { name: "Versions" });
const retryButton = versionsPanel.getByRole("button", { name: "Try again" });
if (await retryButton.isVisible({ timeout: 500 }).catch(() => false)) {
await retryButton.click({ timeout: 5_000 });
await waitForHydration(page).catch(() => {});
}
await expect(versionToggle(page, OLDER_VERSION)).toBeVisible({ timeout: 30_000 });
await expect(versionToggle(page, LATEST_VERSION)).toBeVisible({ timeout: 30_000 });
await expect(
page.getByRole("button", { name: `Delete version ${OLDER_VERSION}` }),
).toBeVisible({ timeout: 30_000 });
await expect(
page.getByRole("button", { name: `Delete version ${LATEST_VERSION}` }),
).toHaveCount(0);
await expect(page.getByRole("button", { name: /restore/i })).toHaveCount(0);
return;
} catch (error) {
lastError = error;
if (attempt >= 4) throw error;
await recoverFromTransientErrorScreen(page).catch(() => {});
await page.reload({ waitUntil: "domcontentloaded" }).catch(() => {});
await waitForHydration(page).catch(() => {});
await page.waitForTimeout(1_000 * attempt);
}
}
throw lastError;
}
async function expectPublicVersionsList(page: Parameters<typeof expectNoFatalErrorUi>[0]) {
await ensureVersionsTab(page);
await expect(versionToggle(page, OLDER_VERSION)).toHaveCount(0);
await expect(versionToggle(page, LATEST_VERSION)).toBeVisible();
await expect(page.getByRole("button", { name: /delete version/i })).toHaveCount(0);
@@ -348,8 +411,8 @@ test("owners can permanently delete individual non-latest skill and plugin versi
await page.goto(skillDetailHref, { waitUntil: "domcontentloaded" });
await waitForHydration(page);
await recoverFromTransientErrorScreen(page);
await expect(page.locator(".skill-page-title")).toHaveText(skillDisplayName, { timeout: 30_000 });
await page.getByRole("tab", { name: "Versions" }).click();
await expectVersionsList(page);
await page.screenshot({
path: testInfo.outputPath("skill-version-delete-before.png"),
@@ -361,8 +424,9 @@ test("owners can permanently delete individual non-latest skill and plugin versi
path: testInfo.outputPath("skill-version-delete-confirmation.png"),
fullPage: true,
});
await confirmDeleteDialog(skillDialog);
await confirmDeleteDialog(page);
await expect(skillDialog).toHaveCount(0);
await ensureVersionsTab(page);
await expect(versionToggle(page, OLDER_VERSION)).toHaveCount(0);
await expect(versionToggle(page, LATEST_VERSION)).toBeVisible();
await expect(page.getByRole("button", { name: /restore/i })).toHaveCount(0);
@@ -375,10 +439,10 @@ test("owners can permanently delete individual non-latest skill and plugin versi
waitUntil: "domcontentloaded",
});
await waitForHydration(page);
await recoverFromTransientErrorScreen(page);
await expect(page.locator(".skill-page-title")).toHaveText(packageDisplayName, {
timeout: 30_000,
});
await page.getByRole("tab", { name: "Versions" }).click();
await expectVersionsList(page);
await page.screenshot({
path: testInfo.outputPath("plugin-version-delete-before.png"),
@@ -390,8 +454,9 @@ test("owners can permanently delete individual non-latest skill and plugin versi
path: testInfo.outputPath("plugin-version-delete-confirmation.png"),
fullPage: true,
});
await confirmDeleteDialog(packageDialog);
await confirmDeleteDialog(page);
await expect(packageDialog).toHaveCount(0);
await ensureVersionsTab(page);
await expect(versionToggle(page, OLDER_VERSION)).toHaveCount(0);
await expect(versionToggle(page, LATEST_VERSION)).toBeVisible();
await expect(page.getByRole("button", { name: /restore/i })).toHaveCount(0);
@@ -460,21 +525,23 @@ test("owners can permanently delete individual non-latest skill and plugin versi
waitUntil: "domcontentloaded",
});
await waitForHydration(publicPage);
await recoverFromTransientErrorScreen(publicPage);
await expect(publicPage.locator(".skill-page-title")).toHaveText(skillDisplayName);
await publicPage.getByRole("tab", { name: "Versions" }).click();
await expectPublicVersionsList(publicPage);
await publicPage.goto(pluginDetailHref, {
waitUntil: "domcontentloaded",
});
await waitForHydration(publicPage);
await recoverFromTransientErrorScreen(publicPage);
await expect(publicPage.locator(".skill-page-title")).toHaveText(packageDisplayName);
await publicPage.getByRole("tab", { name: "Versions" }).click();
await expectPublicVersionsList(publicPage);
await expectNoFatalErrorUi(publicPage);
expect(publicErrors.filter((error) => !isExpectedVersionDeletionRuntimeError(error))).toEqual(
[],
);
expect(
withoutRecoverableReactHydrationErrors(publicErrors).filter(
(error) => !isExpectedVersionDeletionRuntimeError(error),
),
).toEqual([]);
} finally {
await publicContext.close();
}
@@ -500,6 +567,11 @@ test("owners can permanently delete individual non-latest skill and plugin versi
await page.reload({ waitUntil: "domcontentloaded" });
await waitForHydration(page);
await recoverFromTransientErrorScreen(page);
await expectNoFatalErrorUi(page);
expect(errors.filter((error) => !isExpectedVersionDeletionRuntimeError(error))).toEqual([]);
expect(
withoutRecoverableReactHydrationErrors(errors).filter(
(error) => !isExpectedVersionDeletionRuntimeError(error),
),
).toEqual([]);
});
@@ -1283,6 +1283,104 @@ describe("package commands", () => {
}
});
it("reports pending security checks for staged package publishes", async () => {
const workdir = await makeTmpWorkdir();
try {
const folder = join(workdir, "pending-plugin");
await mkdir(join(folder, "dist"), { recursive: true });
await writeFile(
join(folder, "package.json"),
makeCodePluginPackageJson({
name: "@scope/pending-plugin",
displayName: "Pending Plugin",
version: "1.0.0",
files: ["dist", "openclaw.plugin.json"],
}),
"utf8",
);
await writeFile(
join(folder, "openclaw.plugin.json"),
JSON.stringify({ id: "pending.plugin" }),
"utf8",
);
await writeFile(join(folder, "dist", "index.js"), "export const demo = true;\n", "utf8");
httpMocks.apiRequestForm.mockResolvedValueOnce({
ok: true,
packageId: "pkg_1",
releaseId: "rel_1",
publicationStatus: "pending",
attemptId: "attempt_1",
});
await cmdPublishPackage(makeOpts(workdir), "pending-plugin", {
owner: "@openclaw",
sourceRepo: "openclaw/pending-plugin",
sourceCommit: "abc123",
});
expect(uiMocks.spinner.succeed).toHaveBeenCalledWith(
"OK. Uploaded @scope/pending-plugin@1.0.0; security checks are pending before it becomes public (rel_1)",
);
expect(mockWrite).not.toHaveBeenCalled();
} finally {
await rm(workdir, { recursive: true, force: true });
}
});
it("includes pending package publish metadata in json output", async () => {
const workdir = await makeTmpWorkdir();
try {
const folder = join(workdir, "json-pending-plugin");
await mkdir(join(folder, "dist"), { recursive: true });
await writeFile(
join(folder, "package.json"),
makeCodePluginPackageJson({
name: "@scope/json-pending-plugin",
displayName: "JSON Pending Plugin",
version: "1.0.0",
files: ["dist", "openclaw.plugin.json"],
}),
"utf8",
);
await writeFile(
join(folder, "openclaw.plugin.json"),
JSON.stringify({ id: "json.pending.plugin" }),
"utf8",
);
await writeFile(join(folder, "dist", "index.js"), "export const demo = true;\n", "utf8");
httpMocks.apiRequestForm.mockResolvedValueOnce({
ok: true,
packageId: "pkg_1",
releaseId: "rel_1",
publicationStatus: "pending",
attemptId: "attempt_1",
});
await cmdPublishPackage(makeOpts(workdir), "json-pending-plugin", {
owner: "@openclaw",
sourceRepo: "openclaw/json-pending-plugin",
sourceCommit: "abc123",
json: true,
});
expect(uiMocks.spinner.succeed).not.toHaveBeenCalled();
expect(mockWrite).toHaveBeenCalledTimes(1);
const output = JSON.parse(String(mockWrite.mock.calls[0]?.[0] ?? ""));
expect(output).toEqual(
expect.objectContaining({
name: "@scope/json-pending-plugin",
releaseId: "rel_1",
publicationStatus: "pending",
attemptId: "attempt_1",
}),
);
} finally {
await rm(workdir, { recursive: true, force: true });
}
});
it("sends explicit empty catalog metadata to clear existing package values", async () => {
const workdir = await makeTmpWorkdir();
try {
@@ -975,12 +975,15 @@ export async function cmdPublishPackage(
ApiV1PackagePublishResponseSchema,
);
const isPendingPublication = result.publicationStatus === "pending";
if (options.json) {
process.stdout.write(
`${JSON.stringify(
{
...plan.output,
releaseId: result.releaseId,
publicationStatus: result.publicationStatus,
attemptId: result.attemptId,
inspectorFindings: result.inspectorFindings,
},
null,
@@ -989,7 +992,9 @@ export async function cmdPublishPackage(
);
} else {
spinner?.succeed(
`OK. Published ${plan.payload.name}@${plan.payload.version} (${result.releaseId})`,
isPendingPublication
? `OK. Uploaded ${plan.payload.name}@${plan.payload.version}; security checks are pending before it becomes public (${result.releaseId})`
: `OK. Published ${plan.payload.name}@${plan.payload.version} (${result.releaseId})`,
);
printPackageInspectorFindings(result);
}
@@ -132,6 +132,41 @@ describe("cmdPublish", () => {
}
});
it("reports pending security checks for staged publish responses", async () => {
const workdir = await makeTmpWorkdir();
try {
const folder = join(workdir, "pending-skill");
await mkdir(folder, { recursive: true });
await writeFile(join(folder, "SKILL.md"), "# Skill\n", "utf8");
httpMocks.apiRequest.mockRejectedValueOnce(
new Error("Skill not found or unavailable to this account."),
);
httpMocks.apiRequestForm.mockResolvedValueOnce({
ok: true,
skillId: "skill_1",
versionId: "ver_pending",
publicationStatus: "pending",
attemptId: "attempt_1",
});
const result = await cmdPublish(makeOpts(workdir), "pending-skill", {});
expect(result).toMatchObject({
status: "pending-publication",
slug: "pending-skill",
version: "1.0.0",
versionId: "ver_pending",
publicationStatus: "pending",
attemptId: "attempt_1",
});
expect(uiMocks.spinner.succeed).toHaveBeenCalledWith(
"OK. Uploaded pending-skill@1.0.0; security checks are pending before it becomes public (ver_pending)",
);
} finally {
await rm(workdir, { recursive: true, force: true });
}
});
it("defaults a changed skill to the next patch version", async () => {
const workdir = await makeTmpWorkdir();
try {
+12 -3
View File
@@ -18,7 +18,7 @@ import { normalizeGitHubRepo } from "./github.js";
type SkillPublishResult = {
ok: true;
status: "unchanged" | "would-publish" | "published";
status: "unchanged" | "would-publish" | "published" | "pending-publication";
slug: string;
displayName: string;
folder: string;
@@ -27,6 +27,8 @@ type SkillPublishResult = {
fileCount: number;
fingerprint: string;
versionId?: string;
publicationStatus?: "pending" | "published";
attemptId?: string;
};
export async function cmdPublish(
@@ -198,8 +200,9 @@ export async function cmdPublish(
ApiV1PublishResponseSchema,
);
const isPendingPublication = result.publicationStatus === "pending";
const publishResult = buildPublishResult({
status: "published",
status: isPendingPublication ? "pending-publication" : "published",
slug,
displayName,
folder,
@@ -208,8 +211,14 @@ export async function cmdPublish(
fileCount: filesOnDisk.length,
fingerprint: hashed.fingerprint,
versionId: result.versionId,
publicationStatus: result.publicationStatus,
attemptId: result.attemptId,
});
spinner?.succeed(`OK. Published ${slug}@${version} (${result.versionId})`);
spinner?.succeed(
isPendingPublication
? `OK. Uploaded ${slug}@${version}; security checks are pending before it becomes public (${result.versionId})`
: `OK. Published ${slug}@${version} (${result.versionId})`,
);
writePublishJsonIfRequested(options.json, publishResult);
return publishResult;
} catch (error) {
+2
View File
@@ -861,6 +861,8 @@ export const ApiV1PackagePublishResponseSchema = type({
ok: "true",
packageId: "string",
releaseId: "string",
publicationStatus: '"pending"|"published"?',
attemptId: "string?",
inspectorFindings: type({
findingKind: '"warning"|"error"',
code: "string",
@@ -2,6 +2,7 @@
import { describe, expect, it } from "vitest";
import { parseArk } from "./ark";
import { ApiV1PackagePublishResponseSchema } from "./packages";
import {
ApiV1SearchResponseSchema,
ApiV1SkillRescanResponseSchema,
@@ -58,6 +59,24 @@ describe("packages/clawhub skill metadata schema", () => {
expect(parsed.results[0]?.owner?.displayName).toBe("OpenClaw");
});
it("parses pending package publish responses with legacy IDs", () => {
const parsed = parseArk(
ApiV1PackagePublishResponseSchema,
{
ok: true,
packageId: "packages:demo",
releaseId: "packageReleases:demo",
publicationStatus: "pending",
attemptId: "publishAttempts:demo",
},
"Package publish response",
);
expect(parsed.releaseId).toBe("packageReleases:demo");
expect(parsed.publicationStatus).toBe("pending");
expect(parsed.attemptId).toBe("publishAttempts:demo");
});
it("parses flattened skill verification envelopes", () => {
const parsed = parseArk(
ApiV1SkillVerifyResponseSchema,
+10
View File
@@ -108,6 +108,11 @@ export const ApiCliPublishResponseSchema = type({
ok: "true",
skillId: "string",
versionId: "string",
status: '"pending"|"published"?',
slug: "string?",
version: "string?",
publicationStatus: '"pending"|"published"?',
attemptId: "string?",
});
export const CliSkillDeleteRequestSchema = type({
@@ -885,6 +890,11 @@ export const ApiV1PublishResponseSchema = type({
ok: "true",
skillId: "string",
versionId: "string",
status: '"pending"|"published"?',
slug: "string?",
version: "string?",
publicationStatus: '"pending"|"published"?',
attemptId: "string?",
});
export const ApiV1DeleteResponseSchema = type({
+82 -82
View File
@@ -1,7 +1,7 @@
import { type inferred } from "arktype";
export declare const CatalogFeedStateSchema: import("arktype/internal/variants/string.ts").StringType<"available" | "recommended" | "disabled" | "blocked" | "deprecated", {}>;
export declare const CatalogFeedStateSchema: import("arktype/internal/variants/string.ts").StringType<"available" | "blocked" | "deprecated" | "disabled" | "recommended", {}>;
export type CatalogFeedState = (typeof CatalogFeedStateSchema)[inferred];
export declare const CatalogFeedPublisherTrustSchema: import("arktype/internal/variants/string.ts").StringType<"official" | "community", {}>;
export declare const CatalogFeedPublisherTrustSchema: import("arktype/internal/variants/string.ts").StringType<"community" | "official", {}>;
export type CatalogFeedPublisherTrust = (typeof CatalogFeedPublisherTrustSchema)[inferred];
export declare const CatalogFeedGitHubSourceSchema: import("arktype/internal/variants/object.ts").ObjectType<{
repo: string;
@@ -24,14 +24,13 @@ export declare const CatalogFeedInstallCandidateSchema: import("arktype/internal
}, {}>;
export type CatalogFeedInstallCandidate = (typeof CatalogFeedInstallCandidateSchema)[inferred];
export declare const CatalogFeedPluginEntrySchema: import("arktype/internal/variants/object.ts").ObjectType<{
type: "plugin";
id: string;
title: string;
version: string;
state: "available" | "recommended" | "disabled" | "blocked" | "deprecated";
state: "available" | "blocked" | "deprecated" | "disabled" | "recommended";
publisher: {
id: string;
trust: "official" | "community";
trust: "community" | "official";
};
install: {
candidates: {
@@ -47,17 +46,17 @@ export declare const CatalogFeedPluginEntrySchema: import("arktype/internal/vari
} | undefined;
}[];
};
type: "plugin";
}, {}>;
export type CatalogFeedPluginEntry = (typeof CatalogFeedPluginEntrySchema)[inferred];
export declare const CatalogFeedSkillEntrySchema: import("arktype/internal/variants/object.ts").ObjectType<{
type: "skill";
id: string;
title: string;
version: string;
state: "available" | "recommended" | "disabled" | "blocked" | "deprecated";
state: "available" | "blocked" | "deprecated" | "disabled" | "recommended";
publisher: {
id: string;
trust: "official" | "community";
trust: "community" | "official";
};
install: {
candidates: {
@@ -73,41 +72,41 @@ export declare const CatalogFeedSkillEntrySchema: import("arktype/internal/varia
} | undefined;
}[];
};
type: "skill";
}, {}>;
export type CatalogFeedSkillEntry = (typeof CatalogFeedSkillEntrySchema)[inferred];
export declare const CatalogFeedEntrySchema: import("arktype/internal/variants/object.ts").ObjectType<{
id: string;
title: string;
version: string;
state: "available" | "blocked" | "deprecated" | "disabled" | "recommended";
publisher: {
id: string;
trust: "community" | "official";
};
install: {
candidates: {
sourceRef: string;
package: string;
version: string;
integrity: string;
github?: {
repo: string;
path: string;
commit: string;
contentHash: string;
} | undefined;
}[];
};
type: "plugin";
id: string;
title: string;
version: string;
state: "available" | "recommended" | "disabled" | "blocked" | "deprecated";
publisher: {
id: string;
trust: "official" | "community";
};
install: {
candidates: {
sourceRef: string;
package: string;
version: string;
integrity: string;
github?: {
repo: string;
path: string;
commit: string;
contentHash: string;
} | undefined;
}[];
};
} | {
type: "skill";
id: string;
title: string;
version: string;
state: "available" | "recommended" | "disabled" | "blocked" | "deprecated";
state: "available" | "blocked" | "deprecated" | "disabled" | "recommended";
publisher: {
id: string;
trust: "official" | "community";
trust: "community" | "official";
};
install: {
candidates: {
@@ -123,6 +122,7 @@ export declare const CatalogFeedEntrySchema: import("arktype/internal/variants/o
} | undefined;
}[];
};
type: "skill";
}, {}>;
export type CatalogFeedEntry = (typeof CatalogFeedEntrySchema)[inferred];
export declare const CatalogFeedSchema: import("arktype/internal/variants/object.ts").ObjectType<{
@@ -131,56 +131,56 @@ export declare const CatalogFeedSchema: import("arktype/internal/variants/object
generatedAt: string;
sequence: number;
expiresAt: string;
entries: ({
type: "plugin";
id: string;
title: string;
version: string;
state: "available" | "recommended" | "disabled" | "blocked" | "deprecated";
publisher: {
id: string;
trust: "official" | "community";
};
install: {
candidates: {
sourceRef: string;
package: string;
version: string;
integrity: string;
github?: {
repo: string;
path: string;
commit: string;
contentHash: string;
} | undefined;
}[];
};
} | {
type: "skill";
id: string;
title: string;
version: string;
state: "available" | "recommended" | "disabled" | "blocked" | "deprecated";
publisher: {
id: string;
trust: "official" | "community";
};
install: {
candidates: {
sourceRef: string;
package: string;
version: string;
integrity: string;
github?: {
repo: string;
path: string;
commit: string;
contentHash: string;
} | undefined;
}[];
};
})[];
description?: string | undefined;
entries: ({
id: string;
title: string;
version: string;
state: "available" | "blocked" | "deprecated" | "disabled" | "recommended";
publisher: {
id: string;
trust: "community" | "official";
};
install: {
candidates: {
sourceRef: string;
package: string;
version: string;
integrity: string;
github?: {
repo: string;
path: string;
commit: string;
contentHash: string;
} | undefined;
}[];
};
type: "plugin";
} | {
id: string;
title: string;
version: string;
state: "available" | "blocked" | "deprecated" | "disabled" | "recommended";
publisher: {
id: string;
trust: "community" | "official";
};
install: {
candidates: {
sourceRef: string;
package: string;
version: string;
integrity: string;
github?: {
repo: string;
path: string;
commit: string;
contentHash: string;
} | undefined;
}[];
};
type: "skill";
})[];
}, {}>;
export type CatalogFeed = (typeof CatalogFeedSchema)[inferred];
/**
+2 -2
View File
@@ -151,8 +151,8 @@ export declare const SKILL_CATEGORY_DEFINITIONS: readonly [{
}];
export type PluginCategorySlug = (typeof PLUGIN_CATEGORY_DEFINITIONS)[number]["slug"];
export type SkillCategorySlug = (typeof SKILL_CATEGORY_DEFINITIONS)[number]["slug"];
export declare const PLUGIN_CATEGORY_SLUGS: ("other" | "channels" | "models" | "memory" | "context" | "voice" | "media" | "web" | "tools" | "runtime" | "gateway" | "security")[];
export declare const SKILL_CATEGORY_SLUGS: ("other" | "security" | "integrations" | "automation" | "research" | "development" | "productivity" | "communication" | "creative" | "knowledge" | "agents" | "operations" | "finance" | "lifestyle")[];
export declare const PLUGIN_CATEGORY_SLUGS: ("channels" | "context" | "gateway" | "media" | "memory" | "models" | "other" | "runtime" | "security" | "tools" | "voice" | "web")[];
export declare const SKILL_CATEGORY_SLUGS: ("agents" | "automation" | "communication" | "creative" | "development" | "finance" | "integrations" | "knowledge" | "lifestyle" | "operations" | "other" | "productivity" | "research" | "security")[];
export declare function isPluginCategorySlug(value: string | null | undefined): value is PluginCategorySlug;
export declare function isSkillCategorySlug(value: string | null | undefined): value is SkillCategorySlug;
export declare function normalizePluginCategories(values: readonly string[] | null | undefined): PluginCategorySlug[];
+226 -224
View File
@@ -8,11 +8,11 @@ export declare function getPackageScopeOwnerMismatch(name: string, ownerHandle:
suggestedName: string;
message: string;
} | null;
export declare const PackageFamilySchema: import("arktype/internal/variants/string.ts").StringType<"skill" | "code-plugin" | "bundle-plugin", {}>;
export declare const PackageFamilySchema: import("arktype/internal/variants/string.ts").StringType<"bundle-plugin" | "code-plugin" | "skill", {}>;
export type PackageFamily = (typeof PackageFamilySchema)[inferred];
export declare const PackageChannelSchema: import("arktype/internal/variants/string.ts").StringType<"official" | "community" | "private", {}>;
export declare const PackageChannelSchema: import("arktype/internal/variants/string.ts").StringType<"community" | "official" | "private", {}>;
export type PackageChannel = (typeof PackageChannelSchema)[inferred];
export declare const PackageVerificationTierSchema: import("arktype/internal/variants/string.ts").StringType<"structural" | "source-linked" | "provenance-verified" | "rebuild-verified", {}>;
export declare const PackageVerificationTierSchema: import("arktype/internal/variants/string.ts").StringType<"provenance-verified" | "rebuild-verified" | "source-linked" | "structural", {}>;
export type PackageVerificationTier = (typeof PackageVerificationTierSchema)[inferred];
export declare const PackageVerificationScopeSchema: import("arktype/internal/variants/string.ts").StringType<"artifact-only" | "dependency-graph-aware", {}>;
export type PackageVerificationScope = (typeof PackageVerificationScopeSchema)[inferred];
@@ -25,23 +25,6 @@ export declare const PackageCompatibilitySchema: import("arktype/internal/varian
export type PackageCompatibility = (typeof PackageCompatibilitySchema)[inferred];
export declare const PluginManifestSummarySchema: import("arktype/internal/variants/object.ts").ObjectType<{
schemaVersion: number;
configFields: {
name: string;
required: boolean;
sensitive: boolean;
description?: string | undefined;
}[];
mcpServers: {
name: string;
}[];
bundledSkills: {
name: string;
rootPath: string;
skillMdPath: string;
sha256: string;
size: number;
description?: string | undefined;
}[];
compatibility?: {
pluginApiRange?: string | undefined;
builtWithOpenClawVersion?: string | undefined;
@@ -54,10 +37,27 @@ export declare const PluginManifestSummarySchema: import("arktype/internal/varia
version?: string | undefined;
family?: string | undefined;
} | undefined;
configFields: {
name: string;
description?: string | undefined;
required: boolean;
sensitive: boolean;
}[];
mcpServers: {
name: string;
}[];
bundledSkills: {
name: string;
description?: string | undefined;
rootPath: string;
skillMdPath: string;
sha256: string;
size: number;
}[];
}, {}>;
export type PluginManifestSummary = (typeof PluginManifestSummarySchema)[inferred];
export declare const PackageVerificationSummarySchema: import("arktype/internal/variants/object.ts").ObjectType<{
tier: "structural" | "source-linked" | "provenance-verified" | "rebuild-verified";
tier: "provenance-verified" | "rebuild-verified" | "source-linked" | "structural";
scope: "artifact-only" | "dependency-graph-aware";
summary?: string | undefined;
sourceRepo?: string | undefined;
@@ -66,7 +66,7 @@ export declare const PackageVerificationSummarySchema: import("arktype/internal/
sourcePath?: string | undefined;
hasProvenance?: boolean | undefined;
trustedOpenClawPlugin?: boolean | undefined;
scanStatus?: "clean" | "suspicious" | "malicious" | "pending" | "not-run" | undefined;
scanStatus?: "clean" | "malicious" | "not-run" | "pending" | "suspicious" | undefined;
}, {}>;
export type PackageVerificationSummary = (typeof PackageVerificationSummarySchema)[inferred];
export declare const PackageStatsSchema: import("arktype/internal/variants/object.ts").ObjectType<{
@@ -78,23 +78,23 @@ export declare const PackageStatsSchema: import("arktype/internal/variants/objec
export type PackageStats = (typeof PackageStatsSchema)[inferred];
export declare const PackageArtifactKindSchema: import("arktype/internal/variants/string.ts").StringType<"legacy-zip" | "npm-pack", {}>;
export type PackageArtifactKind = (typeof PackageArtifactKindSchema)[inferred];
export declare const PackageReleaseModerationStateSchema: import("arktype/internal/variants/string.ts").StringType<"approved" | "revoked" | "quarantined", {}>;
export declare const PackageReleaseModerationStateSchema: import("arktype/internal/variants/string.ts").StringType<"approved" | "quarantined" | "revoked", {}>;
export type PackageReleaseModerationState = (typeof PackageReleaseModerationStateSchema)[inferred];
export declare const PackageReportStatusSchema: import("arktype/internal/variants/string.ts").StringType<"open" | "confirmed" | "dismissed", {}>;
export declare const PackageReportStatusSchema: import("arktype/internal/variants/string.ts").StringType<"confirmed" | "dismissed" | "open", {}>;
export type PackageReportStatus = (typeof PackageReportStatusSchema)[inferred];
export declare const PackageReportFinalActionSchema: import("arktype/internal/variants/string.ts").StringType<"revoke" | "none" | "quarantine", {}>;
export declare const PackageReportFinalActionSchema: import("arktype/internal/variants/string.ts").StringType<"none" | "quarantine" | "revoke", {}>;
export type PackageReportFinalAction = (typeof PackageReportFinalActionSchema)[inferred];
export declare const PackageReportListStatusSchema: import("arktype/internal/variants/string.ts").StringType<"open" | "confirmed" | "dismissed" | "all", {}>;
export declare const PackageReportListStatusSchema: import("arktype/internal/variants/string.ts").StringType<"all" | "confirmed" | "dismissed" | "open", {}>;
export type PackageReportListStatus = (typeof PackageReportListStatusSchema)[inferred];
export declare const PackageAppealStatusSchema: import("arktype/internal/variants/string.ts").StringType<"open" | "accepted" | "rejected", {}>;
export declare const PackageAppealStatusSchema: import("arktype/internal/variants/string.ts").StringType<"accepted" | "open" | "rejected", {}>;
export type PackageAppealStatus = (typeof PackageAppealStatusSchema)[inferred];
export declare const PackageAppealFinalActionSchema: import("arktype/internal/variants/string.ts").StringType<"none" | "approve", {}>;
export declare const PackageAppealFinalActionSchema: import("arktype/internal/variants/string.ts").StringType<"approve" | "none", {}>;
export type PackageAppealFinalAction = (typeof PackageAppealFinalActionSchema)[inferred];
export declare const PackageAppealListStatusSchema: import("arktype/internal/variants/string.ts").StringType<"open" | "all" | "accepted" | "rejected", {}>;
export declare const PackageAppealListStatusSchema: import("arktype/internal/variants/string.ts").StringType<"accepted" | "all" | "open" | "rejected", {}>;
export type PackageAppealListStatus = (typeof PackageAppealListStatusSchema)[inferred];
export declare const PackageOfficialMigrationPhaseSchema: import("arktype/internal/variants/string.ts").StringType<"blocked" | "published" | "planned" | "clawpack-ready" | "legacy-zip-only" | "metadata-ready" | "ready-for-openclaw", {}>;
export declare const PackageOfficialMigrationPhaseSchema: import("arktype/internal/variants/string.ts").StringType<"blocked" | "clawpack-ready" | "legacy-zip-only" | "metadata-ready" | "planned" | "published" | "ready-for-openclaw", {}>;
export type PackageOfficialMigrationPhase = (typeof PackageOfficialMigrationPhaseSchema)[inferred];
export declare const PackageOfficialMigrationListPhaseSchema: import("arktype/internal/variants/string.ts").StringType<"blocked" | "all" | "published" | "planned" | "clawpack-ready" | "legacy-zip-only" | "metadata-ready" | "ready-for-openclaw", {}>;
export declare const PackageOfficialMigrationListPhaseSchema: import("arktype/internal/variants/string.ts").StringType<"all" | "blocked" | "clawpack-ready" | "legacy-zip-only" | "metadata-ready" | "planned" | "published" | "ready-for-openclaw", {}>;
export type PackageOfficialMigrationListPhase = (typeof PackageOfficialMigrationListPhaseSchema)[inferred];
export declare const PackageArtifactSummarySchema: import("arktype/internal/variants/object.ts").ObjectType<{
kind: "legacy-zip" | "npm-pack";
@@ -128,22 +128,22 @@ export declare const PackagePublishArtifactSchema: import("arktype/internal/vari
export type PackagePublishArtifact = (typeof PackagePublishArtifactSchema)[inferred];
export declare const PackageVtAnalysisSchema: import("arktype/internal/variants/object.ts").ObjectType<{
status: string;
checkedAt: number;
verdict?: string | undefined;
analysis?: string | undefined;
source?: string | undefined;
checkedAt: number;
}, {}>;
export type PackageVtAnalysis = (typeof PackageVtAnalysisSchema)[inferred];
export declare const PackageSkillSpectorIssueSchema: import("arktype/internal/variants/object.ts").ObjectType<{
issueId: string;
severity: string;
explanation: string;
category?: string | undefined;
pattern?: string | undefined;
severity: string;
confidence?: number | undefined;
file?: string | undefined;
startLine?: number | undefined;
endLine?: number | undefined;
explanation: string;
remediation?: string | undefined;
finding?: string | undefined;
codeSnippet?: string | undefined;
@@ -151,28 +151,28 @@ export declare const PackageSkillSpectorIssueSchema: import("arktype/internal/va
export type PackageSkillSpectorIssue = (typeof PackageSkillSpectorIssueSchema)[inferred];
export declare const PackageSkillSpectorAnalysisSchema: import("arktype/internal/variants/object.ts").ObjectType<{
status: string;
score?: number | undefined;
severity?: string | undefined;
recommendation?: string | undefined;
issueCount: number;
issues: {
issueId: string;
severity: string;
explanation: string;
category?: string | undefined;
pattern?: string | undefined;
severity: string;
confidence?: number | undefined;
file?: string | undefined;
startLine?: number | undefined;
endLine?: number | undefined;
explanation: string;
remediation?: string | undefined;
finding?: string | undefined;
codeSnippet?: string | undefined;
}[];
checkedAt: number;
score?: number | undefined;
severity?: string | undefined;
recommendation?: string | undefined;
scannerVersion?: string | undefined;
summary?: string | undefined;
error?: string | undefined;
checkedAt: number;
}, {}>;
export type PackageSkillSpectorAnalysis = (typeof PackageSkillSpectorAnalysisSchema)[inferred];
export declare const PackageLlmAnalysisDimensionSchema: import("arktype/internal/variants/object.ts").ObjectType<{
@@ -184,7 +184,6 @@ export declare const PackageLlmAnalysisDimensionSchema: import("arktype/internal
export type PackageLlmAnalysisDimension = (typeof PackageLlmAnalysisDimensionSchema)[inferred];
export declare const PackageLlmAnalysisSchema: import("arktype/internal/variants/object.ts").ObjectType<{
status: string;
checkedAt: number;
verdict?: string | undefined;
confidence?: string | undefined;
summary?: string | undefined;
@@ -199,6 +198,7 @@ export declare const PackageLlmAnalysisSchema: import("arktype/internal/variants
agenticRiskFindings?: unknown[] | undefined;
riskSummary?: unknown;
model?: string | undefined;
checkedAt: number;
}, {}>;
export type PackageLlmAnalysis = (typeof PackageLlmAnalysisSchema)[inferred];
export declare const PackageStaticFindingSchema: import("arktype/internal/variants/object.ts").ObjectType<{
@@ -260,13 +260,13 @@ export declare function isPackageMultipartUploadTooLarge(input: PackageMultipart
export declare function getPackageMultipartSizeError(): string;
export declare const PackagePublishMetadataSchema: import("arktype/internal/variants/object.ts").ObjectType<{
name: string;
family: "skill" | "code-plugin" | "bundle-plugin";
version: string;
changelog: string;
displayName?: string | undefined;
ownerHandle?: string | undefined;
family: "bundle-plugin" | "code-plugin" | "skill";
version: string;
changelog: string;
manualOverrideReason?: string | undefined;
channel?: "official" | "community" | "private" | undefined;
channel?: "community" | "official" | "private" | undefined;
tags?: string[] | undefined;
categories?: string[] | undefined;
topics?: string[] | undefined;
@@ -287,33 +287,14 @@ export declare const PackagePublishMetadataSchema: import("arktype/internal/vari
}, {}>;
export type PackagePublishMetadata = (typeof PackagePublishMetadataSchema)[inferred];
export declare const ServerPackagePublishRequestSchema: import("arktype/internal/variants/object.ts").ObjectType<{
files: {
path: string;
size: number;
storageId: string;
sha256: string;
contentType?: string | undefined;
}[];
name: string;
family: "skill" | "code-plugin" | "bundle-plugin";
version: string;
changelog: string;
artifact?: {
kind: "npm-pack";
storageId: string;
sha256: string;
size: number;
format: "tgz";
npmIntegrity: string;
npmShasum: string;
npmTarballName: string;
npmUnpackedSize: number;
npmFileCount: number;
} | undefined;
displayName?: string | undefined;
ownerHandle?: string | undefined;
family: "bundle-plugin" | "code-plugin" | "skill";
version: string;
changelog: string;
manualOverrideReason?: string | undefined;
channel?: "official" | "community" | "private" | undefined;
channel?: "community" | "official" | "private" | undefined;
tags?: string[] | undefined;
categories?: string[] | undefined;
topics?: string[] | undefined;
@@ -331,24 +312,43 @@ export declare const ServerPackagePublishRequestSchema: import("arktype/internal
format?: string | undefined;
hostTargets?: string[] | undefined;
} | undefined;
artifact?: {
kind: "npm-pack";
storageId: string;
sha256: string;
size: number;
format: "tgz";
npmIntegrity: string;
npmShasum: string;
npmTarballName: string;
npmUnpackedSize: number;
npmFileCount: number;
} | undefined;
files: {
path: string;
size: number;
storageId: string;
sha256: string;
contentType?: string | undefined;
}[];
}, {}>;
export type ServerPackagePublishRequest = (typeof ServerPackagePublishRequestSchema)[inferred];
export declare const PackageListItemSchema: import("arktype/internal/variants/object.ts").ObjectType<{
name: string;
displayName: string;
family: "skill" | "code-plugin" | "bundle-plugin";
channel: "official" | "community" | "private";
isOfficial: boolean;
createdAt: number;
updatedAt: number;
family: "bundle-plugin" | "code-plugin" | "skill";
runtimeId?: string | null | undefined;
channel: "community" | "official" | "private";
isOfficial: boolean;
summary?: string | null | undefined;
icon?: string | null | undefined;
ownerHandle?: string | null | undefined;
createdAt: number;
updatedAt: number;
latestVersion?: string | null | undefined;
categories?: string[] | undefined;
topics?: string[] | undefined;
verificationTier?: "structural" | "source-linked" | "provenance-verified" | "rebuild-verified" | null | undefined;
verificationTier?: "provenance-verified" | "rebuild-verified" | "source-linked" | "structural" | null | undefined;
stats?: {
downloads: number;
installs: number;
@@ -361,19 +361,19 @@ export declare const ApiV1PackageListResponseSchema: import("arktype/internal/va
items: {
name: string;
displayName: string;
family: "skill" | "code-plugin" | "bundle-plugin";
channel: "official" | "community" | "private";
isOfficial: boolean;
createdAt: number;
updatedAt: number;
family: "bundle-plugin" | "code-plugin" | "skill";
runtimeId?: string | null | undefined;
channel: "community" | "official" | "private";
isOfficial: boolean;
summary?: string | null | undefined;
icon?: string | null | undefined;
ownerHandle?: string | null | undefined;
createdAt: number;
updatedAt: number;
latestVersion?: string | null | undefined;
categories?: string[] | undefined;
topics?: string[] | undefined;
verificationTier?: "structural" | "source-linked" | "provenance-verified" | "rebuild-verified" | null | undefined;
verificationTier?: "provenance-verified" | "rebuild-verified" | "source-linked" | "structural" | null | undefined;
stats?: {
downloads: number;
installs: number;
@@ -390,19 +390,19 @@ export declare const ApiV1PackageSearchResponseSchema: import("arktype/internal/
package: {
name: string;
displayName: string;
family: "skill" | "code-plugin" | "bundle-plugin";
channel: "official" | "community" | "private";
isOfficial: boolean;
createdAt: number;
updatedAt: number;
family: "bundle-plugin" | "code-plugin" | "skill";
runtimeId?: string | null | undefined;
channel: "community" | "official" | "private";
isOfficial: boolean;
summary?: string | null | undefined;
icon?: string | null | undefined;
ownerHandle?: string | null | undefined;
createdAt: number;
updatedAt: number;
latestVersion?: string | null | undefined;
categories?: string[] | undefined;
topics?: string[] | undefined;
verificationTier?: "structural" | "source-linked" | "provenance-verified" | "rebuild-verified" | null | undefined;
verificationTier?: "provenance-verified" | "rebuild-verified" | "source-linked" | "structural" | null | undefined;
stats?: {
downloads: number;
installs: number;
@@ -417,19 +417,19 @@ export declare const ApiV1PackageResponseSchema: import("arktype/internal/varian
package: {
name: string;
displayName: string;
family: "skill" | "code-plugin" | "bundle-plugin";
channel: "official" | "community" | "private";
isOfficial: boolean;
createdAt: number;
updatedAt: number;
tags: unknown;
family: "bundle-plugin" | "code-plugin" | "skill";
runtimeId?: string | null | undefined;
channel: "community" | "official" | "private";
isOfficial: boolean;
summary?: string | null | undefined;
icon?: string | null | undefined;
ownerHandle?: string | null | undefined;
createdAt: number;
updatedAt: number;
latestVersion?: string | null | undefined;
categories?: string[] | undefined;
topics?: string[] | undefined;
tags: unknown;
compatibility?: {
pluginApiRange?: string | undefined;
builtWithOpenClawVersion?: string | undefined;
@@ -438,23 +438,6 @@ export declare const ApiV1PackageResponseSchema: import("arktype/internal/varian
} | null | undefined;
pluginManifestSummary?: {
schemaVersion: number;
configFields: {
name: string;
required: boolean;
sensitive: boolean;
description?: string | undefined;
}[];
mcpServers: {
name: string;
}[];
bundledSkills: {
name: string;
rootPath: string;
skillMdPath: string;
sha256: string;
size: number;
description?: string | undefined;
}[];
compatibility?: {
pluginApiRange?: string | undefined;
builtWithOpenClawVersion?: string | undefined;
@@ -467,9 +450,26 @@ export declare const ApiV1PackageResponseSchema: import("arktype/internal/varian
version?: string | undefined;
family?: string | undefined;
} | undefined;
configFields: {
name: string;
description?: string | undefined;
required: boolean;
sensitive: boolean;
}[];
mcpServers: {
name: string;
}[];
bundledSkills: {
name: string;
description?: string | undefined;
rootPath: string;
skillMdPath: string;
sha256: string;
size: number;
}[];
} | null | undefined;
verification?: {
tier: "structural" | "source-linked" | "provenance-verified" | "rebuild-verified";
tier: "provenance-verified" | "rebuild-verified" | "source-linked" | "structural";
scope: "artifact-only" | "dependency-graph-aware";
summary?: string | undefined;
sourceRepo?: string | undefined;
@@ -478,7 +478,7 @@ export declare const ApiV1PackageResponseSchema: import("arktype/internal/varian
sourcePath?: string | undefined;
hasProvenance?: boolean | undefined;
trustedOpenClawPlugin?: boolean | undefined;
scanStatus?: "clean" | "suspicious" | "malicious" | "pending" | "not-run" | undefined;
scanStatus?: "clean" | "malicious" | "not-run" | "pending" | "suspicious" | undefined;
} | null | undefined;
artifact?: {
kind: "legacy-zip" | "npm-pack";
@@ -496,7 +496,7 @@ export declare const ApiV1PackageResponseSchema: import("arktype/internal/varian
packageName?: string | undefined;
version?: string | undefined;
} | null | undefined;
scanStatus?: "clean" | "suspicious" | "malicious" | "pending" | "not-run" | undefined;
scanStatus?: "clean" | "malicious" | "not-run" | "pending" | "suspicious" | undefined;
stats?: {
downloads: number;
installs: number;
@@ -525,14 +525,14 @@ export declare const ApiV1PackageVersionResponseSchema: import("arktype/internal
package: {
name: string;
displayName: string;
family: "skill" | "code-plugin" | "bundle-plugin";
family: "bundle-plugin" | "code-plugin" | "skill";
} | null;
version: {
version: string;
createdAt: number;
changelog: string;
files: unknown;
distTags?: string[] | undefined;
files: unknown;
compatibility?: {
pluginApiRange?: string | undefined;
builtWithOpenClawVersion?: string | undefined;
@@ -541,23 +541,6 @@ export declare const ApiV1PackageVersionResponseSchema: import("arktype/internal
} | null | undefined;
pluginManifestSummary?: {
schemaVersion: number;
configFields: {
name: string;
required: boolean;
sensitive: boolean;
description?: string | undefined;
}[];
mcpServers: {
name: string;
}[];
bundledSkills: {
name: string;
rootPath: string;
skillMdPath: string;
sha256: string;
size: number;
description?: string | undefined;
}[];
compatibility?: {
pluginApiRange?: string | undefined;
builtWithOpenClawVersion?: string | undefined;
@@ -570,9 +553,26 @@ export declare const ApiV1PackageVersionResponseSchema: import("arktype/internal
version?: string | undefined;
family?: string | undefined;
} | undefined;
configFields: {
name: string;
description?: string | undefined;
required: boolean;
sensitive: boolean;
}[];
mcpServers: {
name: string;
}[];
bundledSkills: {
name: string;
description?: string | undefined;
rootPath: string;
skillMdPath: string;
sha256: string;
size: number;
}[];
} | null | undefined;
verification?: {
tier: "structural" | "source-linked" | "provenance-verified" | "rebuild-verified";
tier: "provenance-verified" | "rebuild-verified" | "source-linked" | "structural";
scope: "artifact-only" | "dependency-graph-aware";
summary?: string | undefined;
sourceRepo?: string | undefined;
@@ -581,7 +581,7 @@ export declare const ApiV1PackageVersionResponseSchema: import("arktype/internal
sourcePath?: string | undefined;
hasProvenance?: boolean | undefined;
trustedOpenClawPlugin?: boolean | undefined;
scanStatus?: "clean" | "suspicious" | "malicious" | "pending" | "not-run" | undefined;
scanStatus?: "clean" | "malicious" | "not-run" | "pending" | "suspicious" | undefined;
} | null | undefined;
artifact?: {
kind: "legacy-zip" | "npm-pack";
@@ -602,39 +602,38 @@ export declare const ApiV1PackageVersionResponseSchema: import("arktype/internal
sha256hash?: string | null | undefined;
vtAnalysis?: {
status: string;
checkedAt: number;
verdict?: string | undefined;
analysis?: string | undefined;
source?: string | undefined;
checkedAt: number;
} | null | undefined;
skillSpectorAnalysis?: {
status: string;
score?: number | undefined;
severity?: string | undefined;
recommendation?: string | undefined;
issueCount: number;
issues: {
issueId: string;
severity: string;
explanation: string;
category?: string | undefined;
pattern?: string | undefined;
severity: string;
confidence?: number | undefined;
file?: string | undefined;
startLine?: number | undefined;
endLine?: number | undefined;
explanation: string;
remediation?: string | undefined;
finding?: string | undefined;
codeSnippet?: string | undefined;
}[];
checkedAt: number;
score?: number | undefined;
severity?: string | undefined;
recommendation?: string | undefined;
scannerVersion?: string | undefined;
summary?: string | undefined;
error?: string | undefined;
checkedAt: number;
} | null | undefined;
llmAnalysis?: {
status: string;
checkedAt: number;
verdict?: string | undefined;
confidence?: string | undefined;
summary?: string | undefined;
@@ -649,6 +648,7 @@ export declare const ApiV1PackageVersionResponseSchema: import("arktype/internal
agenticRiskFindings?: unknown[] | undefined;
riskSummary?: unknown;
model?: string | undefined;
checkedAt: number;
} | null | undefined;
staticScan?: {
status: string;
@@ -672,12 +672,11 @@ export declare const ApiV1PackageArtifactResponseSchema: import("arktype/interna
package: {
name: string;
displayName: string;
family: "skill" | "code-plugin" | "bundle-plugin";
family: "bundle-plugin" | "code-plugin" | "skill";
};
version: string;
artifact: {
kind: "legacy-zip" | "npm-pack";
downloadUrl: string;
sha256?: string | undefined;
size?: number | undefined;
format?: string | undefined;
@@ -686,6 +685,7 @@ export declare const ApiV1PackageArtifactResponseSchema: import("arktype/interna
npmTarballName?: string | undefined;
npmUnpackedSize?: number | undefined;
npmFileCount?: number | undefined;
downloadUrl: string;
tarballUrl?: string | undefined;
legacyDownloadUrl?: string | undefined;
source?: "clawhub" | undefined;
@@ -700,30 +700,30 @@ export declare const ApiV1PackageSecurityResponseSchema: import("arktype/interna
package: {
name: string;
displayName: string;
family: "skill" | "code-plugin" | "bundle-plugin";
family: "bundle-plugin" | "code-plugin" | "skill";
};
release: {
releaseId: string;
version: string;
createdAt: number;
artifactKind?: "legacy-zip" | "npm-pack" | null | undefined;
artifactSha256?: string | undefined;
npmIntegrity?: string | undefined;
npmShasum?: string | undefined;
npmTarballName?: string | undefined;
createdAt: number;
};
trust: {
scanStatus: "clean" | "suspicious" | "malicious" | "pending" | "not-run";
scanStatus: "clean" | "malicious" | "not-run" | "pending" | "suspicious";
moderationState?: "approved" | "quarantined" | "revoked" | null | undefined;
blockedFromDownload: boolean;
reasons: string[];
pending: boolean;
stale: boolean;
moderationState?: "approved" | "revoked" | "quarantined" | null | undefined;
};
}, {}>;
export type ApiV1PackageSecurityResponse = (typeof ApiV1PackageSecurityResponseSchema)[inferred];
export declare const PackageReleaseModerationRequestSchema: import("arktype/internal/variants/object.ts").ObjectType<{
state: "approved" | "revoked" | "quarantined";
state: "approved" | "quarantined" | "revoked";
reason: string;
}, {}>;
export type PackageReleaseModerationRequest = (typeof PackageReleaseModerationRequestSchema)[inferred];
@@ -742,9 +742,9 @@ export declare const ApiV1PackageReportResponseSchema: import("arktype/internal/
}, {}>;
export type ApiV1PackageReportResponse = (typeof ApiV1PackageReportResponseSchema)[inferred];
export declare const PackageReportTriageRequestSchema: import("arktype/internal/variants/object.ts").ObjectType<{
status: "open" | "confirmed" | "dismissed";
status: "confirmed" | "dismissed" | "open";
note?: string | undefined;
finalAction?: "revoke" | "none" | "quarantine" | undefined;
finalAction?: "none" | "quarantine" | "revoke" | undefined;
}, {}>;
export type PackageReportTriageRequest = (typeof PackageReportTriageRequestSchema)[inferred];
export declare const PackageAppealRequestSchema: import("arktype/internal/variants/object.ts").ObjectType<{
@@ -759,13 +759,13 @@ export declare const ApiV1PackageAppealResponseSchema: import("arktype/internal/
appealId: string;
packageId: string;
releaseId: string;
status: "open" | "accepted" | "rejected";
status: "accepted" | "open" | "rejected";
}, {}>;
export type ApiV1PackageAppealResponse = (typeof ApiV1PackageAppealResponseSchema)[inferred];
export declare const PackageAppealResolveRequestSchema: import("arktype/internal/variants/object.ts").ObjectType<{
status: "open" | "accepted" | "rejected";
status: "accepted" | "open" | "rejected";
note?: string | undefined;
finalAction?: "none" | "approve" | undefined;
finalAction?: "approve" | "none" | undefined;
}, {}>;
export type PackageAppealResolveRequest = (typeof PackageAppealResolveRequestSchema)[inferred];
export declare const ApiV1PackageAppealListResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
@@ -775,10 +775,10 @@ export declare const ApiV1PackageAppealListResponseSchema: import("arktype/inter
releaseId: string;
name: string;
displayName: string;
family: "skill" | "code-plugin" | "bundle-plugin";
family: "bundle-plugin" | "code-plugin" | "skill";
version: string;
message: string;
status: "open" | "accepted" | "rejected";
status: "accepted" | "open" | "rejected";
createdAt: number;
submitter: {
userId: string;
@@ -788,7 +788,7 @@ export declare const ApiV1PackageAppealListResponseSchema: import("arktype/inter
resolvedAt?: number | null | undefined;
resolvedBy?: string | null | undefined;
resolutionNote?: string | null | undefined;
actionTaken?: "none" | "approve" | null | undefined;
actionTaken?: "approve" | "none" | null | undefined;
}[];
nextCursor: string | null;
done: boolean;
@@ -799,31 +799,31 @@ export declare const ApiV1PackageAppealResolveResponseSchema: import("arktype/in
appealId: string;
packageId: string;
releaseId: string;
status: "open" | "accepted" | "rejected";
actionTaken?: "none" | "approve" | undefined;
status: "accepted" | "open" | "rejected";
actionTaken?: "approve" | "none" | undefined;
}, {}>;
export type ApiV1PackageAppealResolveResponse = (typeof ApiV1PackageAppealResolveResponseSchema)[inferred];
export declare const ApiV1PackageReportListResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
items: {
reportId: string;
packageId: string;
releaseId?: string | null | undefined;
name: string;
displayName: string;
family: "skill" | "code-plugin" | "bundle-plugin";
status: "open" | "confirmed" | "dismissed";
family: "bundle-plugin" | "code-plugin" | "skill";
version?: string | null | undefined;
reason?: string | null | undefined;
status: "confirmed" | "dismissed" | "open";
createdAt: number;
reporter: {
userId: string;
handle?: string | null | undefined;
displayName?: string | null | undefined;
};
releaseId?: string | null | undefined;
version?: string | null | undefined;
reason?: string | null | undefined;
triagedAt?: number | null | undefined;
triagedBy?: string | null | undefined;
triageNote?: string | null | undefined;
actionTaken?: "revoke" | "none" | "quarantine" | null | undefined;
actionTaken?: "none" | "quarantine" | "revoke" | null | undefined;
}[];
nextCursor: string | null;
done: boolean;
@@ -833,9 +833,9 @@ export declare const ApiV1PackageReportTriageResponseSchema: import("arktype/int
ok: true;
reportId: string;
packageId: string;
status: "open" | "confirmed" | "dismissed";
status: "confirmed" | "dismissed" | "open";
reportCount: number;
actionTaken?: "revoke" | "none" | "quarantine" | undefined;
actionTaken?: "none" | "quarantine" | "revoke" | undefined;
}, {}>;
export type ApiV1PackageReportTriageResponse = (typeof ApiV1PackageReportTriageResponseSchema)[inferred];
export declare const ApiV1PackageModerationStatusResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
@@ -843,30 +843,30 @@ export declare const ApiV1PackageModerationStatusResponseSchema: import("arktype
packageId: string;
name: string;
displayName: string;
family: "skill" | "code-plugin" | "bundle-plugin";
channel: "official" | "community" | "private";
family: "bundle-plugin" | "code-plugin" | "skill";
channel: "community" | "official" | "private";
isOfficial: boolean;
reportCount: number;
lastReportedAt?: number | null | undefined;
scanStatus?: "clean" | "suspicious" | "malicious" | "pending" | "not-run" | undefined;
scanStatus?: "clean" | "malicious" | "not-run" | "pending" | "suspicious" | undefined;
};
latestRelease: {
releaseId: string;
version: string;
scanStatus: "clean" | "suspicious" | "malicious" | "pending" | "not-run";
artifactKind?: "legacy-zip" | "npm-pack" | null | undefined;
scanStatus: "clean" | "malicious" | "not-run" | "pending" | "suspicious";
moderationState?: "approved" | "quarantined" | "revoked" | null | undefined;
moderationReason?: string | null | undefined;
blockedFromDownload: boolean;
reasons: string[];
createdAt: number;
artifactKind?: "legacy-zip" | "npm-pack" | null | undefined;
moderationState?: "approved" | "revoked" | "quarantined" | null | undefined;
moderationReason?: string | null | undefined;
} | null;
}, {}>;
export type ApiV1PackageModerationStatusResponse = (typeof ApiV1PackageModerationStatusResponseSchema)[inferred];
export declare const PackageReadinessCheckSchema: import("arktype/internal/variants/object.ts").ObjectType<{
id: string;
label: string;
status: "warn" | "fail" | "pass";
status: "fail" | "pass" | "warn";
message: string;
}, {}>;
export type PackageReadinessCheck = (typeof PackageReadinessCheckSchema)[inferred];
@@ -874,7 +874,7 @@ export declare const ApiV1PackageReadinessResponseSchema: import("arktype/intern
package: {
name: string;
displayName: string;
family: "skill" | "code-plugin" | "bundle-plugin";
family: "bundle-plugin" | "code-plugin" | "skill";
isOfficial: boolean;
latestVersion?: string | null | undefined;
};
@@ -882,7 +882,7 @@ export declare const ApiV1PackageReadinessResponseSchema: import("arktype/intern
checks: {
id: string;
label: string;
status: "warn" | "fail" | "pass";
status: "fail" | "pass" | "warn";
message: string;
}[];
blockers: string[];
@@ -898,31 +898,31 @@ export declare const ApiV1PackageTransferResponseSchema: import("arktype/interna
packageId: string;
name: string;
ownerUserId: string;
channel: "official" | "community" | "private";
isOfficial: boolean;
ownerPublisherId?: string | undefined;
channel: "community" | "official" | "private";
isOfficial: boolean;
}, {}>;
export type ApiV1PackageTransferResponse = (typeof ApiV1PackageTransferResponseSchema)[inferred];
export declare const PackageRepairNameRequestSchema: import("arktype/internal/variants/object.ts").ObjectType<{
nextName: string;
reason: string;
retireTarget?: boolean | undefined;
owner?: string | undefined;
reason: string;
dryRun?: boolean | undefined;
}, {}>;
export type PackageRepairNameRequest = (typeof PackageRepairNameRequestSchema)[inferred];
export declare const PackageRepairNamePackageSchema: import("arktype/internal/variants/object.ts").ObjectType<{
packageId: string;
name: string;
ownerUserId: string;
channel: "official" | "community" | "private";
runtimeId?: string | null | undefined;
ownerUserId: string;
ownerPublisherId?: string | null | undefined;
channel: "community" | "official" | "private";
softDeletedAt?: number | null | undefined;
}, {}>;
export type PackageRepairNamePackage = (typeof PackageRepairNamePackageSchema)[inferred];
export declare const PackageRepairNameOperationSchema: import("arktype/internal/variants/object.ts").ObjectType<{
action: "retire-target" | "rename-source" | "transfer-owner";
action: "rename-source" | "retire-target" | "transfer-owner";
packageId?: string | undefined;
from?: string | undefined;
to?: string | undefined;
@@ -935,29 +935,29 @@ export declare const ApiV1PackageRepairNameResponseSchema: import("arktype/inter
source: {
packageId: string;
name: string;
ownerUserId: string;
channel: "official" | "community" | "private";
runtimeId?: string | null | undefined;
ownerUserId: string;
ownerPublisherId?: string | null | undefined;
channel: "community" | "official" | "private";
softDeletedAt?: number | null | undefined;
};
target: {
packageId: string;
name: string;
ownerUserId: string;
channel: "official" | "community" | "private";
runtimeId?: string | null | undefined;
ownerUserId: string;
ownerPublisherId?: string | null | undefined;
channel: "community" | "official" | "private";
softDeletedAt?: number | null | undefined;
} | null;
retiredName?: string | null | undefined;
operations: {
action: "retire-target" | "rename-source" | "transfer-owner";
action: "rename-source" | "retire-target" | "transfer-owner";
packageId?: string | undefined;
from?: string | undefined;
to?: string | undefined;
owner?: string | undefined;
}[];
retiredName?: string | null | undefined;
}, {}>;
export type ApiV1PackageRepairNameResponse = (typeof ApiV1PackageRepairNameResponseSchema)[inferred];
export declare const PackageRepairRuntimeIdRequestSchema: import("arktype/internal/variants/object.ts").ObjectType<{
@@ -979,10 +979,10 @@ export declare const ApiV1PackageRepairRuntimeIdResponseSchema: import("arktype/
source: {
packageId: string;
name: string;
ownerUserId: string;
channel: "official" | "community" | "private";
runtimeId?: string | null | undefined;
ownerUserId: string;
ownerPublisherId?: string | null | undefined;
channel: "community" | "official" | "private";
softDeletedAt?: number | null | undefined;
};
operations: {
@@ -1000,7 +1000,7 @@ export declare const PackageOfficialMigrationUpsertRequestSchema: import("arktyp
sourceRepo?: string | undefined;
sourcePath?: string | undefined;
sourceCommit?: string | undefined;
phase?: "blocked" | "published" | "planned" | "clawpack-ready" | "legacy-zip-only" | "metadata-ready" | "ready-for-openclaw" | undefined;
phase?: "blocked" | "clawpack-ready" | "legacy-zip-only" | "metadata-ready" | "planned" | "published" | "ready-for-openclaw" | undefined;
blockers?: string[] | undefined;
hostTargetsComplete?: boolean | undefined;
scanClean?: boolean | undefined;
@@ -1013,20 +1013,20 @@ export declare const PackageOfficialMigrationItemSchema: import("arktype/interna
migrationId: string;
bundledPluginId: string;
packageName: string;
phase: "blocked" | "published" | "planned" | "clawpack-ready" | "legacy-zip-only" | "metadata-ready" | "ready-for-openclaw";
blockers: string[];
hostTargetsComplete: boolean;
scanClean: boolean;
moderationApproved: boolean;
runtimeBundlesReady: boolean;
createdAt: number;
updatedAt: number;
packageId?: string | null | undefined;
owner?: string | null | undefined;
sourceRepo?: string | null | undefined;
sourcePath?: string | null | undefined;
sourceCommit?: string | null | undefined;
phase: "blocked" | "clawpack-ready" | "legacy-zip-only" | "metadata-ready" | "planned" | "published" | "ready-for-openclaw";
blockers: string[];
hostTargetsComplete: boolean;
scanClean: boolean;
moderationApproved: boolean;
runtimeBundlesReady: boolean;
notes?: string | null | undefined;
createdAt: number;
updatedAt: number;
}, {}>;
export type PackageOfficialMigrationItem = (typeof PackageOfficialMigrationItemSchema)[inferred];
export declare const ApiV1PackageOfficialMigrationListResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
@@ -1034,20 +1034,20 @@ export declare const ApiV1PackageOfficialMigrationListResponseSchema: import("ar
migrationId: string;
bundledPluginId: string;
packageName: string;
phase: "blocked" | "published" | "planned" | "clawpack-ready" | "legacy-zip-only" | "metadata-ready" | "ready-for-openclaw";
blockers: string[];
hostTargetsComplete: boolean;
scanClean: boolean;
moderationApproved: boolean;
runtimeBundlesReady: boolean;
createdAt: number;
updatedAt: number;
packageId?: string | null | undefined;
owner?: string | null | undefined;
sourceRepo?: string | null | undefined;
sourcePath?: string | null | undefined;
sourceCommit?: string | null | undefined;
phase: "blocked" | "clawpack-ready" | "legacy-zip-only" | "metadata-ready" | "planned" | "published" | "ready-for-openclaw";
blockers: string[];
hostTargetsComplete: boolean;
scanClean: boolean;
moderationApproved: boolean;
runtimeBundlesReady: boolean;
notes?: string | null | undefined;
createdAt: number;
updatedAt: number;
}[];
nextCursor: string | null;
done: boolean;
@@ -1059,24 +1059,24 @@ export declare const ApiV1PackageOfficialMigrationResponseSchema: import("arktyp
migrationId: string;
bundledPluginId: string;
packageName: string;
phase: "blocked" | "published" | "planned" | "clawpack-ready" | "legacy-zip-only" | "metadata-ready" | "ready-for-openclaw";
blockers: string[];
hostTargetsComplete: boolean;
scanClean: boolean;
moderationApproved: boolean;
runtimeBundlesReady: boolean;
createdAt: number;
updatedAt: number;
packageId?: string | null | undefined;
owner?: string | null | undefined;
sourceRepo?: string | null | undefined;
sourcePath?: string | null | undefined;
sourceCommit?: string | null | undefined;
phase: "blocked" | "clawpack-ready" | "legacy-zip-only" | "metadata-ready" | "planned" | "published" | "ready-for-openclaw";
blockers: string[];
hostTargetsComplete: boolean;
scanClean: boolean;
moderationApproved: boolean;
runtimeBundlesReady: boolean;
notes?: string | null | undefined;
createdAt: number;
updatedAt: number;
};
}, {}>;
export type ApiV1PackageOfficialMigrationResponse = (typeof ApiV1PackageOfficialMigrationResponseSchema)[inferred];
export declare const PackageModerationQueueStatusSchema: import("arktype/internal/variants/string.ts").StringType<"open" | "blocked" | "all" | "manual", {}>;
export declare const PackageModerationQueueStatusSchema: import("arktype/internal/variants/string.ts").StringType<"all" | "blocked" | "manual" | "open", {}>;
export type PackageModerationQueueStatus = (typeof PackageModerationQueueStatusSchema)[inferred];
export declare const ApiV1PackageModerationQueueResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
items: {
@@ -1084,20 +1084,20 @@ export declare const ApiV1PackageModerationQueueResponseSchema: import("arktype/
releaseId: string;
name: string;
displayName: string;
family: "skill" | "code-plugin" | "bundle-plugin";
channel: "official" | "community" | "private";
family: "bundle-plugin" | "code-plugin" | "skill";
channel: "community" | "official" | "private";
isOfficial: boolean;
version: string;
createdAt: number;
scanStatus: "clean" | "suspicious" | "malicious" | "pending" | "not-run";
reportCount: number;
reasons: string[];
artifactKind?: "legacy-zip" | "npm-pack" | null | undefined;
moderationState?: "approved" | "revoked" | "quarantined" | null | undefined;
scanStatus: "clean" | "malicious" | "not-run" | "pending" | "suspicious";
moderationState?: "approved" | "quarantined" | "revoked" | null | undefined;
moderationReason?: string | null | undefined;
sourceRepo?: string | null | undefined;
sourceCommit?: string | null | undefined;
reportCount: number;
lastReportedAt?: number | null | undefined;
reasons: string[];
}[];
nextCursor: string | null;
done: boolean;
@@ -1107,7 +1107,7 @@ export declare const ApiV1PackageReleaseModerationResponseSchema: import("arktyp
ok: true;
packageId: string;
releaseId: string;
state: "approved" | "revoked" | "quarantined";
state: "approved" | "quarantined" | "revoked";
scanStatus: "clean" | "malicious";
}, {}>;
export type ApiV1PackageReleaseModerationResponse = (typeof ApiV1PackageReleaseModerationResponseSchema)[inferred];
@@ -1115,13 +1115,15 @@ export declare const ApiV1PackagePublishResponseSchema: import("arktype/internal
ok: true;
packageId: string;
releaseId: string;
publicationStatus?: "pending" | "published" | undefined;
attemptId?: string | undefined;
inspectorFindings?: {
findingKind: "error" | "warning";
code: string;
message: string;
severity?: string | undefined;
level?: string | undefined;
issueClass?: string | undefined;
message: string;
authorRemediation?: {
summary: string;
docsUrl?: string | undefined;
+2
View File
@@ -705,6 +705,8 @@ export const ApiV1PackagePublishResponseSchema = type({
ok: "true",
packageId: "string",
releaseId: "string",
publicationStatus: '"pending"|"published"?',
attemptId: "string?",
inspectorFindings: type({
findingKind: '"warning"|"error"',
code: "string",
File diff suppressed because one or more lines are too long
+9 -9
View File
@@ -10,17 +10,17 @@ export declare const PromotionsFeedEntrySchema: import("arktype/internal/variant
slug: string;
title: string;
blurb: string;
sponsor?: string | undefined;
startsAt: number;
endsAt: number;
provider?: string | undefined;
authChoiceId?: string | undefined;
pluginNames?: string[] | undefined;
models: {
modelRef: string;
alias?: string | undefined;
suggestedDefault?: boolean | undefined;
}[];
sponsor?: string | undefined;
provider?: string | undefined;
authChoiceId?: string | undefined;
pluginNames?: string[] | undefined;
signupUrl?: string | undefined;
docsUrl?: string | undefined;
launchPageUrl?: string | undefined;
@@ -32,27 +32,27 @@ export declare const PromotionsFeedSchema: import("arktype/internal/variants/obj
generatedAt: string;
sequence: number;
expiresAt: string;
description?: string | undefined;
entries: {
type: "promotion";
slug: string;
title: string;
blurb: string;
sponsor?: string | undefined;
startsAt: number;
endsAt: number;
provider?: string | undefined;
authChoiceId?: string | undefined;
pluginNames?: string[] | undefined;
models: {
modelRef: string;
alias?: string | undefined;
suggestedDefault?: boolean | undefined;
}[];
sponsor?: string | undefined;
provider?: string | undefined;
authChoiceId?: string | undefined;
pluginNames?: string[] | undefined;
signupUrl?: string | undefined;
docsUrl?: string | undefined;
launchPageUrl?: string | undefined;
}[];
description?: string | undefined;
}, {}>;
export type PromotionsFeed = (typeof PromotionsFeedSchema)[inferred];
/**
+94 -84
View File
@@ -34,11 +34,11 @@ export declare const ApiCliWhoamiResponseSchema: import("arktype/internal/varian
}, {}>;
export declare const ApiSearchResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
results: {
score: number;
slug?: string | undefined;
ownerHandle?: string | null | undefined;
displayName?: string | undefined;
version?: string | null | undefined;
score: number;
}[];
}, {}>;
export declare const ApiSkillMetaResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
@@ -74,18 +74,11 @@ export declare const PublishSourceSchema: import("arktype/internal/variants/obje
export declare const CliPublishRequestSchema: import("arktype/internal/variants/object.ts").ObjectType<{
slug: string;
displayName: string;
version: string;
changelog: string;
files: {
path: string;
size: number;
storageId: string;
sha256: string;
contentType?: string | undefined;
}[];
ownerHandle?: string | undefined;
sourceOwnerHandle?: string | undefined;
migrateOwner?: boolean | undefined;
version: string;
changelog: string;
acceptLicenseTerms?: boolean | undefined;
tags?: string[] | undefined;
categories?: string[] | undefined;
@@ -104,12 +97,24 @@ export declare const CliPublishRequestSchema: import("arktype/internal/variants/
ownerHandle?: string | undefined;
version?: string | undefined;
} | undefined;
files: {
path: string;
size: number;
storageId: string;
sha256: string;
contentType?: string | undefined;
}[];
}, {}>;
export type CliPublishRequest = (typeof CliPublishRequestSchema)[inferred];
export declare const ApiCliPublishResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
ok: true;
skillId: string;
versionId: string;
status?: "pending" | "published" | undefined;
slug?: string | undefined;
version?: string | undefined;
publicationStatus?: "pending" | "published" | undefined;
attemptId?: string | undefined;
}, {}>;
export declare const CliSkillDeleteRequestSchema: import("arktype/internal/variants/object.ts").ObjectType<{
slug: string;
@@ -150,7 +155,7 @@ export declare const ApiV1SkillInstallResolveResponseSchema: import("arktype/int
} | {
ok: false;
slug: string;
reason: "archive_version_missing" | "github_source_missing" | "github_upstream_removed" | "github_upstream_missing" | "github_upstream_unknown" | "github_verification_pending" | "github_scan_failed";
reason: "archive_version_missing" | "github_scan_failed" | "github_source_missing" | "github_upstream_missing" | "github_upstream_removed" | "github_upstream_unknown" | "github_verification_pending";
message: string;
status: number;
}, {}>;
@@ -181,7 +186,7 @@ export declare const ApiV1WhoamiResponseSchema: import("arktype/internal/variant
handle: string | null;
displayName?: string | null | undefined;
image?: string | null | undefined;
role?: "user" | "admin" | "moderator" | null | undefined;
role?: "admin" | "moderator" | "user" | null | undefined;
};
}, {}>;
export declare const ApiV1UserSearchResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
@@ -190,7 +195,7 @@ export declare const ApiV1UserSearchResponseSchema: import("arktype/internal/var
handle: string | null;
displayName?: string | null | undefined;
name?: string | null | undefined;
role?: "user" | "admin" | "moderator" | null | undefined;
role?: "admin" | "moderator" | "user" | null | undefined;
}[];
total: number;
}, {}>;
@@ -243,12 +248,12 @@ export declare const ApiV1StaffEmailSendResponseSchema: import("arktype/internal
export type ApiV1StaffEmailSendResponse = (typeof ApiV1StaffEmailSendResponseSchema)[inferred];
export declare const ApiV1SearchResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
results: {
score: number;
slug?: string | undefined;
ownerHandle?: string | null | undefined;
displayName?: string | undefined;
summary?: string | null | undefined;
version?: string | null | undefined;
score: number;
downloads?: number | undefined;
updatedAt?: number | undefined;
owner?: {
@@ -262,13 +267,13 @@ export declare const ApiV1SkillListResponseSchema: import("arktype/internal/vari
items: {
slug: string;
displayName: string;
summary?: string | null | undefined;
description?: string | null | undefined;
topics?: string[] | undefined;
tags: unknown;
stats: unknown;
createdAt: number;
updatedAt: number;
summary?: string | null | undefined;
description?: string | null | undefined;
topics?: string[] | undefined;
latestVersion?: {
version: string;
createdAt: number;
@@ -290,13 +295,13 @@ export declare const ApiV1SkillResponseSchema: import("arktype/internal/variants
skill: {
slug: string;
displayName: string;
summary?: string | null | undefined;
description?: string | null | undefined;
topics?: string[] | undefined;
tags: unknown;
stats: unknown;
createdAt: number;
updatedAt: number;
summary?: string | null | undefined;
description?: string | null | undefined;
topics?: string[] | undefined;
} | null;
latestVersion: {
version: string;
@@ -304,11 +309,6 @@ export declare const ApiV1SkillResponseSchema: import("arktype/internal/variants
changelog: string;
license?: "MIT-0" | null | undefined;
} | null;
owner: {
handle: string | null;
displayName?: string | null | undefined;
image?: string | null | undefined;
} | null;
metadata?: {
setup: {
key: string;
@@ -317,10 +317,15 @@ export declare const ApiV1SkillResponseSchema: import("arktype/internal/variants
os?: string[] | null | undefined;
systems?: string[] | null | undefined;
} | null | undefined;
owner: {
handle: string | null;
displayName?: string | null | undefined;
image?: string | null | undefined;
} | null;
moderation?: {
isSuspicious: boolean;
isMalwareBlocked: boolean;
verdict?: "clean" | "suspicious" | "malicious" | undefined;
verdict?: "clean" | "malicious" | "suspicious" | undefined;
reasonCodes?: string[] | undefined;
updatedAt?: number | null | undefined;
engineVersion?: string | null | undefined;
@@ -331,20 +336,20 @@ export declare const ApiV1SkillModerationResponseSchema: import("arktype/interna
moderation: {
isSuspicious: boolean;
isMalwareBlocked: boolean;
verdict: "clean" | "suspicious" | "malicious";
verdict: "clean" | "malicious" | "suspicious";
reasonCodes: string[];
updatedAt?: number | null | undefined;
engineVersion?: string | null | undefined;
summary?: string | null | undefined;
legacyReason?: string | null | undefined;
evidence: {
code: string;
severity: "info" | "warn" | "critical";
severity: "critical" | "info" | "warn";
file: string;
line: number;
message: string;
evidence: string;
}[];
updatedAt?: number | null | undefined;
engineVersion?: string | null | undefined;
summary?: string | null | undefined;
legacyReason?: string | null | undefined;
} | null;
}, {}>;
export declare const SkillVersionRevokeRequestSchema: import("arktype/internal/variants/object.ts").ObjectType<{
@@ -364,21 +369,21 @@ export declare const ApiV1SkillVersionRevokeResponseSchema: import("arktype/inte
skillHidden: boolean;
}, {}>;
export type ApiV1SkillVersionRevokeResponse = (typeof ApiV1SkillVersionRevokeResponseSchema)[inferred];
export declare const SkillReportStatusSchema: import("arktype/internal/variants/string.ts").StringType<"open" | "confirmed" | "dismissed", {}>;
export declare const SkillReportStatusSchema: import("arktype/internal/variants/string.ts").StringType<"confirmed" | "dismissed" | "open", {}>;
export type SkillReportStatus = (typeof SkillReportStatusSchema)[inferred];
export declare const SkillReportFinalActionSchema: import("arktype/internal/variants/string.ts").StringType<"none" | "hide", {}>;
export declare const SkillReportFinalActionSchema: import("arktype/internal/variants/string.ts").StringType<"hide" | "none", {}>;
export type SkillReportFinalAction = (typeof SkillReportFinalActionSchema)[inferred];
export declare const SkillReportListStatusSchema: import("arktype/internal/variants/string.ts").StringType<"open" | "confirmed" | "dismissed" | "all", {}>;
export declare const SkillReportListStatusSchema: import("arktype/internal/variants/string.ts").StringType<"all" | "confirmed" | "dismissed" | "open", {}>;
export type SkillReportListStatus = (typeof SkillReportListStatusSchema)[inferred];
export declare const SkillAppealStatusSchema: import("arktype/internal/variants/string.ts").StringType<"open" | "accepted" | "rejected", {}>;
export declare const SkillAppealStatusSchema: import("arktype/internal/variants/string.ts").StringType<"accepted" | "open" | "rejected", {}>;
export type SkillAppealStatus = (typeof SkillAppealStatusSchema)[inferred];
export declare const SkillAppealFinalActionSchema: import("arktype/internal/variants/string.ts").StringType<"none" | "restore", {}>;
export type SkillAppealFinalAction = (typeof SkillAppealFinalActionSchema)[inferred];
export declare const SkillAppealListStatusSchema: import("arktype/internal/variants/string.ts").StringType<"open" | "all" | "accepted" | "rejected", {}>;
export declare const SkillAppealListStatusSchema: import("arktype/internal/variants/string.ts").StringType<"accepted" | "all" | "open" | "rejected", {}>;
export type SkillAppealListStatus = (typeof SkillAppealListStatusSchema)[inferred];
export declare const SkillAppealRequestSchema: import("arktype/internal/variants/object.ts").ObjectType<{
message: string;
version?: string | undefined;
message: string;
}, {}>;
export type SkillAppealRequest = (typeof SkillAppealRequestSchema)[inferred];
export declare const ApiV1SkillReportResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
@@ -396,17 +401,17 @@ export declare const ApiV1SkillAppealResponseSchema: import("arktype/internal/va
alreadyOpen: boolean;
appealId: string;
skillId: string;
status: "open" | "accepted" | "rejected";
status: "accepted" | "open" | "rejected";
}, {}>;
export type ApiV1SkillAppealResponse = (typeof ApiV1SkillAppealResponseSchema)[inferred];
export declare const SkillReportTriageRequestSchema: import("arktype/internal/variants/object.ts").ObjectType<{
status: "open" | "confirmed" | "dismissed";
status: "confirmed" | "dismissed" | "open";
note?: string | undefined;
finalAction?: "none" | "hide" | undefined;
finalAction?: "hide" | "none" | undefined;
}, {}>;
export type SkillReportTriageRequest = (typeof SkillReportTriageRequestSchema)[inferred];
export declare const SkillAppealResolveRequestSchema: import("arktype/internal/variants/object.ts").ObjectType<{
status: "open" | "accepted" | "rejected";
status: "accepted" | "open" | "rejected";
note?: string | undefined;
finalAction?: "none" | "restore" | undefined;
}, {}>;
@@ -415,22 +420,22 @@ export declare const ApiV1SkillReportListResponseSchema: import("arktype/interna
items: {
reportId: string;
skillId: string;
skillVersionId?: string | null | undefined;
slug: string;
displayName: string;
status: "open" | "confirmed" | "dismissed";
version?: string | null | undefined;
reason?: string | null | undefined;
status: "confirmed" | "dismissed" | "open";
createdAt: number;
reporter: {
userId: string;
handle?: string | null | undefined;
displayName?: string | null | undefined;
};
skillVersionId?: string | null | undefined;
version?: string | null | undefined;
reason?: string | null | undefined;
triagedAt?: number | null | undefined;
triagedBy?: string | null | undefined;
triageNote?: string | null | undefined;
actionTaken?: "none" | "hide" | null | undefined;
actionTaken?: "hide" | "none" | null | undefined;
}[];
nextCursor: string | null;
done: boolean;
@@ -440,27 +445,27 @@ export declare const ApiV1SkillReportTriageResponseSchema: import("arktype/inter
ok: true;
reportId: string;
skillId: string;
status: "open" | "confirmed" | "dismissed";
status: "confirmed" | "dismissed" | "open";
reportCount: number;
actionTaken?: "none" | "hide" | undefined;
actionTaken?: "hide" | "none" | undefined;
}, {}>;
export type ApiV1SkillReportTriageResponse = (typeof ApiV1SkillReportTriageResponseSchema)[inferred];
export declare const ApiV1SkillAppealListResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
items: {
appealId: string;
skillId: string;
skillVersionId?: string | null | undefined;
slug: string;
displayName: string;
version?: string | null | undefined;
message: string;
status: "open" | "accepted" | "rejected";
status: "accepted" | "open" | "rejected";
createdAt: number;
submitter: {
userId: string;
handle?: string | null | undefined;
displayName?: string | null | undefined;
};
skillVersionId?: string | null | undefined;
version?: string | null | undefined;
resolvedAt?: number | null | undefined;
resolvedBy?: string | null | undefined;
resolutionNote?: string | null | undefined;
@@ -474,7 +479,7 @@ export declare const ApiV1SkillAppealResolveResponseSchema: import("arktype/inte
ok: true;
appealId: string;
skillId: string;
status: "open" | "accepted" | "rejected";
status: "accepted" | "open" | "rejected";
actionTaken?: "none" | "restore" | undefined;
}, {}>;
export type ApiV1SkillAppealResolveResponse = (typeof ApiV1SkillAppealResolveResponseSchema)[inferred];
@@ -492,12 +497,12 @@ export declare const ApiV1SkillRescanResponseSchema: import("arktype/internal/va
version: string;
skillId: string;
githubContentHash: string;
jobId?: string | undefined;
scheduled: boolean;
alreadyQueued: boolean;
jobId?: string | undefined;
}, {}>;
export type ApiV1SkillRescanResponse = (typeof ApiV1SkillRescanResponseSchema)[inferred];
export declare const ApiV1SkillScanStatusSchema: import("arktype/internal/variants/string.ts").StringType<"queued" | "running" | "succeeded" | "failed", {}>;
export declare const ApiV1SkillScanStatusSchema: import("arktype/internal/variants/string.ts").StringType<"failed" | "queued" | "running" | "succeeded", {}>;
export type ApiV1SkillScanStatus = (typeof ApiV1SkillScanStatusSchema)[inferred];
export declare const ApiV1SkillScanSourceSchema: import("arktype/internal/variants/object.ts").ObjectType<{
kind: "upload";
@@ -520,63 +525,63 @@ export declare const ApiV1SkillScanSubmitRequestSchema: import("arktype/internal
export type ApiV1SkillScanSubmitRequest = (typeof ApiV1SkillScanSubmitRequestSchema)[inferred];
export declare const ApiV1SkillScanQueueSchema: import("arktype/internal/variants/object.ts").ObjectType<{
queuedAhead: number;
queuedAheadIsEstimate?: boolean | undefined;
position: number | null;
running: number;
note: string;
queuedAheadIsEstimate?: boolean | undefined;
runningIsEstimate?: boolean | undefined;
note: string;
}, {}>;
export type ApiV1SkillScanQueue = (typeof ApiV1SkillScanQueueSchema)[inferred];
export declare const ApiV1SkillScanSubmitResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
ok: true;
scanId: string;
status: "queued" | "running" | "succeeded" | "failed";
sourceKind: "upload" | "published";
update: boolean;
jobId?: string | undefined;
status: "failed" | "queued" | "running" | "succeeded";
sourceKind: "published" | "upload";
update: boolean;
alreadyQueued?: boolean | undefined;
queue?: {
queuedAhead: number;
queuedAheadIsEstimate?: boolean | undefined;
position: number | null;
running: number;
note: string;
queuedAheadIsEstimate?: boolean | undefined;
runningIsEstimate?: boolean | undefined;
note: string;
} | undefined;
}, {}>;
export type ApiV1SkillScanSubmitResponse = (typeof ApiV1SkillScanSubmitResponseSchema)[inferred];
export declare const ApiV1SkillScanStatusResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
ok: true;
scanId: string;
status: "queued" | "running" | "succeeded" | "failed";
sourceKind: "upload" | "published";
update: boolean;
createdAt: number;
updatedAt: number;
jobId?: string | undefined;
status: "failed" | "queued" | "running" | "succeeded";
sourceKind: "published" | "upload";
update: boolean;
writtenBack?: boolean | undefined;
artifact?: unknown;
report?: unknown;
queue?: {
queuedAhead: number;
queuedAheadIsEstimate?: boolean | undefined;
position: number | null;
running: number;
note: string;
queuedAheadIsEstimate?: boolean | undefined;
runningIsEstimate?: boolean | undefined;
note: string;
} | undefined;
lastError?: string | undefined;
createdAt: number;
updatedAt: number;
completedAt?: number | undefined;
}, {}>;
export type ApiV1SkillScanStatusResponse = (typeof ApiV1SkillScanStatusResponseSchema)[inferred];
export declare const ApiV1SkillScanDownloadManifestSchema: import("arktype/internal/variants/object.ts").ObjectType<{
scanId: string;
sourceKind: "upload" | "published";
sourceKind: "published" | "upload";
update: boolean;
status: "queued" | "running" | "succeeded" | "failed";
status: "failed" | "queued" | "running" | "succeeded";
artifact?: unknown;
createdAt: number;
updatedAt: number;
artifact?: unknown;
completedAt?: number | undefined;
writtenBack?: boolean | undefined;
}, {}>;
@@ -685,12 +690,12 @@ export declare const ApiV1SkillVersionListResponseSchema: import("arktype/intern
version: string;
createdAt: number;
changelog: string;
changelogSource?: "user" | "auto" | null | undefined;
changelogSource?: "auto" | "user" | null | undefined;
}[];
nextCursor: string | null;
}, {}>;
export declare const SecurityStatusSchema: import("arktype/internal/variants/object.ts").ObjectType<{
status: "error" | "clean" | "suspicious" | "malicious" | "pending";
status: "clean" | "error" | "malicious" | "pending" | "suspicious";
hasWarnings: boolean;
checkedAt: number | null;
model: string | null;
@@ -700,11 +705,11 @@ export declare const ApiV1SkillVersionResponseSchema: import("arktype/internal/v
version: string;
createdAt: number;
changelog: string;
changelogSource?: "user" | "auto" | null | undefined;
changelogSource?: "auto" | "user" | null | undefined;
license?: "MIT-0" | null | undefined;
files?: unknown;
security?: {
status: "error" | "clean" | "suspicious" | "malicious" | "pending";
status: "clean" | "error" | "malicious" | "pending" | "suspicious";
hasWarnings: boolean;
checkedAt: number | null;
model: string | null;
@@ -735,7 +740,7 @@ export declare const ApiV1SkillVerifyResponseSchema: import("arktype/internal/va
publisherDisplayName: string | null;
publisherProfileUrl: string | null;
version: string;
resolvedFrom: "version" | "tag" | "latest";
resolvedFrom: "latest" | "tag" | "version";
tag: string | null;
createdAt: number;
card: unknown;
@@ -748,6 +753,11 @@ export declare const ApiV1PublishResponseSchema: import("arktype/internal/varian
ok: true;
skillId: string;
versionId: string;
status?: "pending" | "published" | undefined;
slug?: string | undefined;
version?: string | undefined;
publicationStatus?: "pending" | "published" | undefined;
attemptId?: string | undefined;
}, {}>;
export declare const ApiV1DeleteResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
ok: true;
@@ -784,8 +794,6 @@ export declare const ApiV1TransferListResponseSchema: import("arktype/internal/v
slug: string;
displayName: string;
};
requestedAt: number;
expiresAt: number;
fromUser?: {
_id: string;
handle: string | null;
@@ -797,11 +805,13 @@ export declare const ApiV1TransferListResponseSchema: import("arktype/internal/v
displayName: string | null;
} | undefined;
message?: string | undefined;
requestedAt: number;
expiresAt: number;
}[];
}, {}>;
export declare const ApiV1SetRoleResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
ok: true;
role: "user" | "admin" | "moderator";
role: "admin" | "moderator" | "user";
}, {}>;
export declare const ApiV1ReclassifyBanResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
ok: true;
@@ -823,8 +833,8 @@ export declare const ApiV1UnstarResponseSchema: import("arktype/internal/variant
alreadyUnstarred: boolean;
}, {}>;
export declare const SkillInstallSpecSchema: import("arktype/internal/variants/object.ts").ObjectType<{
kind: "brew" | "node" | "go" | "uv";
id?: string | undefined;
kind: "brew" | "go" | "node" | "uv";
label?: string | undefined;
bins?: string[] | undefined;
formula?: string | undefined;
@@ -859,7 +869,7 @@ export declare const EnvVarDeclarationSchema: import("arktype/internal/variants/
export type EnvVarDeclaration = (typeof EnvVarDeclarationSchema)[inferred];
export declare const DependencyDeclarationSchema: import("arktype/internal/variants/object.ts").ObjectType<{
name: string;
type: "other" | "brew" | "go" | "pip" | "npm" | "cargo" | "apt";
type: "apt" | "brew" | "cargo" | "go" | "npm" | "other" | "pip";
version?: string | undefined;
url?: string | undefined;
repository?: string | undefined;
@@ -887,8 +897,8 @@ export declare const ClawdisSkillMetadataSchema: import("arktype/internal/varian
config?: string[] | undefined;
} | undefined;
install?: {
kind: "brew" | "node" | "go" | "uv";
id?: string | undefined;
kind: "brew" | "go" | "node" | "uv";
label?: string | undefined;
bins?: string[] | undefined;
formula?: string | undefined;
@@ -912,7 +922,7 @@ export declare const ClawdisSkillMetadataSchema: import("arktype/internal/varian
}[] | undefined;
dependencies?: {
name: string;
type: "other" | "brew" | "go" | "pip" | "npm" | "cargo" | "apt";
type: "apt" | "brew" | "cargo" | "go" | "npm" | "other" | "pip";
version?: string | undefined;
url?: string | undefined;
repository?: string | undefined;
+10
View File
@@ -92,6 +92,11 @@ export const ApiCliPublishResponseSchema = type({
ok: "true",
skillId: "string",
versionId: "string",
status: '"pending"|"published"?',
slug: "string?",
version: "string?",
publicationStatus: '"pending"|"published"?',
attemptId: "string?",
});
export const CliSkillDeleteRequestSchema = type({
slug: "string",
@@ -665,6 +670,11 @@ export const ApiV1PublishResponseSchema = type({
ok: "true",
skillId: "string",
versionId: "string",
status: '"pending"|"published"?',
slug: "string?",
version: "string?",
publicationStatus: '"pending"|"published"?',
attemptId: "string?",
});
export const ApiV1DeleteResponseSchema = type({
ok: "true",
File diff suppressed because one or more lines are too long
+2
View File
@@ -893,6 +893,8 @@ export const ApiV1PackagePublishResponseSchema = type({
ok: "true",
packageId: "string",
releaseId: "string",
publicationStatus: '"pending"|"published"?',
attemptId: "string?",
inspectorFindings: type({
findingKind: '"warning"|"error"',
code: "string",
+23 -1
View File
@@ -3,7 +3,11 @@
import { describe, expect, it } from "vitest";
import { parseArk } from "./ark";
import { DocsLinks, openClawDocsUrl } from "./docsLinks";
import { getPackageScopeOwnerMismatch, inferPackageNameScope } from "./packages";
import {
ApiV1PackagePublishResponseSchema,
getPackageScopeOwnerMismatch,
inferPackageNameScope,
} from "./packages";
import {
ApiSearchResponseSchema,
ApiV1SkillInstallResolveResponseSchema,
@@ -75,6 +79,24 @@ describe("clawhub-schema", () => {
expect(payload.acceptLicenseTerms).toBe(true);
});
it("accepts pending package publish responses with legacy IDs", () => {
const response = parseArk(
ApiV1PackagePublishResponseSchema,
{
ok: true,
packageId: "packages:demo",
releaseId: "packageReleases:demo",
publicationStatus: "pending",
attemptId: "publishAttempts:demo",
},
"Package publish response",
);
expect(response.releaseId).toBe("packageReleases:demo");
expect(response.publicationStatus).toBe("pending");
expect(response.attemptId).toBe("publishAttempts:demo");
});
it("accepts publish payload with github source", () => {
const payload = parseArk(
CliPublishRequestSchema,
+10
View File
@@ -109,6 +109,11 @@ export const ApiCliPublishResponseSchema = type({
ok: "true",
skillId: "string",
versionId: "string",
status: '"pending"|"published"?',
slug: "string?",
version: "string?",
publicationStatus: '"pending"|"published"?',
attemptId: "string?",
});
export const CliSkillDeleteRequestSchema = type({
@@ -793,6 +798,11 @@ export const ApiV1PublishResponseSchema = type({
ok: "true",
skillId: "string",
versionId: "string",
status: '"pending"|"published"?',
slug: "string?",
version: "string?",
publicationStatus: '"pending"|"published"?',
attemptId: "string?",
});
export const ApiV1DeleteResponseSchema = type({
@@ -66,9 +66,10 @@ describe("pre-publication publish worker workflow", () => {
expect(job.strategy?.matrix?.shard).toEqual([0, 1]);
expect(job.strategy?.["max-parallel"]).toBe(2);
expect(job.env).toMatchObject({
CODEX_SECURITY_SCAN_TIMEOUT_MS: "${{ vars.CODEX_SECURITY_SCAN_TIMEOUT_MS || '240000' }}",
CONVEX_URL:
"${{ vars.CONVEX_URL || vars.VITE_CONVEX_URL || 'https://wry-manatee-359.convex.cloud' }}",
PREPUBLICATION_CLAWSCAN_TIMEOUT_MS:
"${{ vars.PREPUBLICATION_CLAWSCAN_TIMEOUT_MS || '240000' }}",
PREPUBLICATION_CHECK_LIMIT: "${{ inputs['batch-limit'] || '2' }}",
PREPUBLICATION_TRUFFLEHOG_IMAGE:
"${{ vars.PREPUBLICATION_TRUFFLEHOG_IMAGE || 'ghcr.io/trufflesecurity/trufflehog:3.95.6@sha256:96f8429082cb2d4ae73b1096dcdb2f5aa139881d97042b0c5e5fa226a392e056' }}",
@@ -76,15 +77,21 @@ describe("pre-publication publish worker workflow", () => {
expect(String(job.env?.PREPUBLICATION_TRUFFLEHOG_IMAGE)).toContain("@sha256:");
expect(job.env).not.toHaveProperty("OPENAI_API_KEY");
expect(job.env).not.toHaveProperty("SECURITY_SCAN_WORKER_TOKEN");
expect(job.env).not.toHaveProperty("CODEX_SECURITY_SCAN_TIMEOUT_MS");
const runStep = steps.find((step) => step.name === "Run pre-publication publish worker");
expect(runStep?.run).toContain("bun run publish:prepublication-worker");
expect(steps.find((step) => step.name === "Install ClawScan CLI")).toBeUndefined();
expect(steps.find((step) => step.name === "Install ClawScan CLI")?.run).toContain(
"npm install -g @openclaw/clawscan@0.1.2",
);
expect(steps.find((step) => step.name === "Install Codex CLI")).toBeUndefined();
expect(steps.find((step) => step.name === "Authenticate Codex CLI")).toBeUndefined();
expect(steps.find((step) => step.name === "Install SkillSpector")).toBeUndefined();
expect(JSON.stringify(job)).not.toContain("CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN");
expect(JSON.stringify(job)).not.toContain("clawscan --version");
expect(runStep?.env).toEqual({
OPENAI_API_KEY: "${{ secrets.OPENAI_API_KEY }}",
SECURITY_SCAN_WORKER_TOKEN: "${{ secrets.SECURITY_SCAN_WORKER_TOKEN }}",
VIRUSTOTAL_API_KEY: "${{ secrets.VT_API_KEY }}",
});
for (const step of steps) {
@@ -93,7 +100,10 @@ describe("pre-publication publish worker workflow", () => {
stepName === "Run pre-publication publish worker",
);
expect(stepUsesSecret(step, "OPENAI_API_KEY"), stepName).toBe(
stepName === "Authenticate Codex CLI" || stepName === "Run pre-publication publish worker",
stepName === "Run pre-publication publish worker",
);
expect(stepUsesSecret(step, "VT_API_KEY"), stepName).toBe(
stepName === "Run pre-publication publish worker",
);
}
});
@@ -1,5 +1,5 @@
/* @vitest-environment node */
import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -7,9 +7,9 @@ import type { Id } from "../../convex/_generated/dataModel";
import {
claimBatchDrainedQueue,
claimPrePublicationBatch,
configurePrePublicationCodexHome,
processPrePublicationBatch,
processPrePublicationAttempt,
runNativeClawScan,
resolveTruffleHogImage,
runNativeTruffleHog,
} from "./run-prepublication-worker";
@@ -56,18 +56,6 @@ describe("pre-publication worker", () => {
expect(claimBatchDrainedQueue(0, 6, 6)).toBe(false);
});
it("does not clear the Codex home configured by GitHub Actions login", () => {
const env = {
CI: "true",
GITHUB_ACTIONS: "true",
GITHUB_REPOSITORY: "openclaw/clawhub",
GITHUB_RUN_ID: "123",
} as NodeJS.ProcessEnv;
expect(configurePrePublicationCodexHome(env)).toBeUndefined();
expect(env).not.toHaveProperty("CODEX_HOME");
});
it("requires the TruffleHog image to be pinned by digest", () => {
expect(resolveTruffleHogImage()).toContain("@sha256:");
expect(() => resolveTruffleHogImage("ghcr.io/trufflesecurity/trufflehog:3.95.6")).toThrow(
@@ -75,7 +63,7 @@ describe("pre-publication worker", () => {
);
});
it("completes clean staged publishes after TruffleHog and ClawHub review pass", async () => {
it("completes clean staged publishes after TruffleHog and ClawScan pass", async () => {
const client = {
action: vi.fn().mockResolvedValue({ status: "finalized" }),
};
@@ -84,26 +72,30 @@ describe("pre-publication worker", () => {
status: "clean",
summary: "TruffleHog found no verified secrets.",
});
const runClawHubReview = vi.fn().mockResolvedValue({
llmAnalysis: {
const runClawScan = vi.fn().mockResolvedValue({
analysis: {
checkedAt: 123,
confidence: "high",
status: "clean",
summary: "ClawHub security review passed.",
summary: "ClawScan passed.",
verdict: "benign",
},
check: {
status: "clean",
summary: "ClawScan passed.",
},
});
await expect(
processPrePublicationAttempt(client, "worker-token", attempt, {
runClawHubReview,
runClawScan,
runTruffleHog,
writeWorkspace: vi.fn().mockResolvedValue(undefined),
}),
).resolves.toMatchObject({ completed: true });
expect(runTruffleHog).toHaveBeenCalledTimes(1);
expect(runClawHubReview).toHaveBeenCalledTimes(1);
expect(runClawScan).toHaveBeenCalledTimes(1);
expect(client.action).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
@@ -125,14 +117,19 @@ describe("pre-publication worker", () => {
await expect(
processPrePublicationAttempt(client, "worker-token", attempt, {
runClawHubReview: vi.fn().mockResolvedValue({
llmAnalysis: {
runClawScan: vi.fn().mockResolvedValue({
analysis: {
checkedAt: 123,
confidence: "high",
status: "suspicious",
summary: "The artifact needs moderator review.",
verdict: "suspicious",
},
check: {
status: "clean",
summary: "The artifact needs moderator review.",
redactedFindings: ["status=suspicious; verdict=suspicious"],
},
}),
runTruffleHog: vi.fn().mockResolvedValue({
status: "clean",
@@ -165,14 +162,19 @@ describe("pre-publication worker", () => {
await expect(
processPrePublicationAttempt(client, "worker-token", attempt, {
runClawHubReview: vi.fn().mockResolvedValue({
llmAnalysis: {
runClawScan: vi.fn().mockResolvedValue({
analysis: {
checkedAt: 123,
confidence: "high",
status: "malicious",
summary: "The artifact contains intentional credential exfiltration.",
verdict: "malicious",
},
check: {
status: "blocked",
summary: "The artifact contains intentional credential exfiltration.",
redactedFindings: ["status=malicious; verdict=malicious"],
},
}),
runTruffleHog: vi.fn().mockResolvedValue({
status: "clean",
@@ -241,7 +243,7 @@ describe("pre-publication worker", () => {
);
});
it("blocks secret-positive attempts without running ClawHub review", async () => {
it("blocks secret-positive attempts without running ClawScan", async () => {
const client = {
action: vi.fn().mockResolvedValue({ status: "blocked" }),
};
@@ -250,17 +252,17 @@ describe("pre-publication worker", () => {
summary: "TruffleHog found verified secret material.",
redactedFindings: ["GitHub token in filesystem"],
});
const runClawHubReview = vi.fn();
const runClawScan = vi.fn();
await expect(
processPrePublicationAttempt(client, "worker-token", attempt, {
runClawHubReview,
runClawScan,
runTruffleHog,
writeWorkspace: vi.fn().mockResolvedValue(undefined),
}),
).resolves.toMatchObject({ completed: true });
expect(runClawHubReview).not.toHaveBeenCalled();
expect(runClawScan).not.toHaveBeenCalled();
expect(client.action).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
@@ -288,7 +290,7 @@ describe("pre-publication worker", () => {
await expect(
processPrePublicationAttempt(client, "worker-token", attempt, {
runClawHubReview: vi.fn(),
runClawScan: vi.fn(),
runTruffleHog,
writeWorkspace: vi.fn().mockResolvedValue(undefined),
}),
@@ -311,7 +313,7 @@ describe("pre-publication worker", () => {
const client = {
action: vi.fn().mockResolvedValue({ status: "finalized" }),
};
const runClawHubReview = vi.fn();
const runClawScan = vi.fn();
const runTruffleHog = vi.fn();
const writeWorkspace = vi.fn();
@@ -321,7 +323,7 @@ describe("pre-publication worker", () => {
"worker-token",
{ ...attempt, status: "ready_to_finalize", files: [] },
{
runClawHubReview,
runClawScan,
runTruffleHog,
writeWorkspace,
},
@@ -330,7 +332,7 @@ describe("pre-publication worker", () => {
expect(writeWorkspace).not.toHaveBeenCalled();
expect(runTruffleHog).not.toHaveBeenCalled();
expect(runClawHubReview).not.toHaveBeenCalled();
expect(runClawScan).not.toHaveBeenCalled();
expect(client.action).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
@@ -341,7 +343,7 @@ describe("pre-publication worker", () => {
);
});
it("passes package ClawPack and manifest context into the ClawHub review job", async () => {
it("passes package ClawPack and manifest context into the ClawScan job", async () => {
const packageAttempt = {
...attempt,
kind: "package" as const,
@@ -367,14 +369,18 @@ describe("pre-publication worker", () => {
await expect(
processPrePublicationAttempt(client, "worker-token", packageAttempt, {
runClawHubReview: vi.fn().mockResolvedValue({
llmAnalysis: {
runClawScan: vi.fn().mockResolvedValue({
analysis: {
checkedAt: 123,
confidence: "high",
status: "clean",
summary: "ClawHub security review passed.",
summary: "ClawScan passed.",
verdict: "benign",
},
check: {
status: "clean",
summary: "ClawScan passed.",
},
}),
runTruffleHog: vi.fn().mockResolvedValue({
status: "clean",
@@ -415,7 +421,7 @@ describe("pre-publication worker", () => {
files: [{ ...attempt.files[0], url: null }],
},
{
runClawHubReview: vi.fn(),
runClawScan: vi.fn(),
runTruffleHog: vi.fn(),
writeWorkspace: vi.fn().mockResolvedValue(undefined),
},
@@ -437,6 +443,78 @@ describe("pre-publication worker", () => {
);
});
it("runs native ClawScan as the required non-shadow security gate", async () => {
const workspace = await tempDir();
await mkdir(join(workspace, "artifact"), { recursive: true });
await writeFile(join(workspace, "artifact", "SKILL.md"), "# Demo\n");
const fakeClawScan = join(workspace, "fake-clawscan");
await writeFile(
fakeClawScan,
`#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "$@" > "${workspace}/clawscan-args.txt"
output=""
while [ "$#" -gt 0 ]; do
if [ "$1" = "--output" ]; then
output="$2"
break
fi
shift
done
cat > "$output" <<'JSON'
{"schemaVersion":"clawscan-run-v1","profile":"clawhub","scanners":{"clawscan-static":{"status":"completed"},"skillspector":{"status":"completed"}},"judge":{"status":"completed","result":{"verdict":"benign","confidence":"high","summary":"Native ClawScan passed."}}}
JSON
`,
);
await chmod(fakeClawScan, 0o755);
const previousCommand = process.env.PREPUBLICATION_CLAWSCAN_COMMAND;
const previousSandbox = process.env.PREPUBLICATION_CLAWSCAN_SANDBOX;
process.env.PREPUBLICATION_CLAWSCAN_COMMAND = fakeClawScan;
delete process.env.PREPUBLICATION_CLAWSCAN_SANDBOX;
try {
await expect(
runNativeClawScan(
{
job: {
_id: String(attempt.attemptId),
attempts: 1,
hasMaliciousSignal: false,
leaseToken: attempt.claimId,
source: "pre-publication",
targetKind: "skillVersion",
waitForVtUntil: 0,
},
target: {},
},
workspace,
),
).resolves.toEqual(
expect.objectContaining({
analysis: expect.objectContaining({
status: "clean",
verdict: "benign",
}),
check: {
status: "clean",
summary: "Native ClawScan passed.",
},
}),
);
const args = await readFile(join(workspace, "clawscan-args.txt"), "utf8");
expect(args).toContain("./artifact");
expect(args).toContain("--profile\nclawhub");
expect(args).toContain("--output\n");
expect(args).not.toContain("--sandbox");
} finally {
if (previousCommand === undefined) delete process.env.PREPUBLICATION_CLAWSCAN_COMMAND;
else process.env.PREPUBLICATION_CLAWSCAN_COMMAND = previousCommand;
if (previousSandbox === undefined) delete process.env.PREPUBLICATION_CLAWSCAN_SANDBOX;
else process.env.PREPUBLICATION_CLAWSCAN_SANDBOX = previousSandbox;
}
});
it("maps TruffleHog verified-secret exit code to a blocked result", async () => {
const workspace = await tempDir();
await mkdir(join(workspace, "artifact"), { recursive: true });
+162 -60
View File
@@ -1,13 +1,11 @@
import { spawn } from "node:child_process";
import { mkdirSync } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { ConvexHttpClient } from "convex/browser";
import { api } from "../../convex/_generated/api";
import type { Id } from "../../convex/_generated/dataModel";
import { assertCodexWorkerExecutionAllowed, resolveCodexWorkerHome } from "../codex-worker-guard";
import { createWorkerLogger } from "../lib/workerLogger";
import {
maskKnownWorkerSecrets,
@@ -15,11 +13,7 @@ import {
redactWorkerPublicText,
} from "../lib/workerRedaction";
import {
runCodex,
runSkillSpector,
resolveSkillSpectorScanInputs,
type ClaimedJob,
type SkillSpectorAnalysis,
type StoredLlmAnalysis,
writeArtifactWorkspace,
} from "./run-codex-scan-worker";
@@ -69,11 +63,13 @@ type TruffleHogResult = WorkerCheckResult & {
exitCode?: number | null;
};
type ClawScanResult = {
check: WorkerCheckResult;
analysis?: StoredLlmAnalysis;
};
type ProcessAttemptDeps = {
runClawHubReview?: (
job: ClaimedJob,
workspace: string,
) => Promise<{ llmAnalysis: StoredLlmAnalysis; skillSpectorAnalysis?: SkillSpectorAnalysis }>;
runClawScan?: (job: ClaimedJob, workspace: string) => Promise<ClawScanResult>;
runTruffleHog?: (workspace: string) => Promise<TruffleHogResult>;
writeWorkspace?: (job: ClaimedJob, workspace: string) => Promise<void>;
};
@@ -86,13 +82,8 @@ const DEFAULT_TRUFFLEHOG_IMAGE =
const TRUFFLEHOG_SECRET_EXIT_CODE = 183;
const MAX_TRUFFLEHOG_FINDINGS = 10;
const MAX_PUBLIC_SUMMARY_CHARS = 600;
const LOCAL_CODEX_HOME = join(rootDir(), ".codex/runtime/codex-workers/prepublication");
const logger = createWorkerLogger({ name: "prepublication-worker" });
function rootDir() {
return resolve(new URL("../..", import.meta.url).pathname);
}
function parseArgs() {
const args = process.argv.slice(2);
const get = (name: string) => {
@@ -331,57 +322,178 @@ export async function runNativeTruffleHog(workspace: string): Promise<TruffleHog
};
}
export async function runNormalClawHubReview(job: ClaimedJob, workspace: string) {
const skillSpectorAnalysis = await runSkillSpectorIfApplicable(job, workspace);
const llmAnalysis = await runCodex(job, workspace, skillSpectorAnalysis, () => {});
return { llmAnalysis, skillSpectorAnalysis };
function clawScanTimeoutMs() {
const parsed = Number(process.env.PREPUBLICATION_CLAWSCAN_TIMEOUT_MS);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 240_000;
}
async function runSkillSpectorIfApplicable(job: ClaimedJob, workspace: string) {
const inputs = await resolveSkillSpectorScanInputs(workspace, job);
if (inputs.length === 0) return undefined;
return await runSkillSpector(workspace, inputs, () => {});
function clawScanCommand() {
return process.env.PREPUBLICATION_CLAWSCAN_COMMAND?.trim() || "clawscan";
}
function clawHubReviewCheckResult(llmAnalysis: StoredLlmAnalysis): WorkerCheckResult {
const status = (llmAnalysis.status || llmAnalysis.verdict || "").trim().toLowerCase();
const verdict = (llmAnalysis.verdict || "").trim().toLowerCase();
async function fileExists(path: string) {
return Boolean(await stat(path).catch(() => null));
}
async function resolveNativeClawScanTarget(workspace: string, job: ClaimedJob) {
if (job.job.targetKind === "packageRelease") {
const packageRoot = join(workspace, "artifact", "package");
if (await fileExists(join(packageRoot, "package.json"))) return "./artifact/package";
}
return "./artifact";
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
return value as Record<string, unknown>;
}
function readString(record: Record<string, unknown> | undefined, names: string[]) {
if (!record) return undefined;
for (const name of names) {
const value = record[name];
if (typeof value === "string" && value.trim()) return value.trim();
}
return undefined;
}
function verdictToStoredStatus(verdict: string | undefined): StoredLlmAnalysis["status"] {
const normalized = verdict?.trim().toLowerCase();
if (normalized === "benign" || normalized === "clean") return "clean";
if (normalized === "suspicious") return "suspicious";
if (normalized === "malicious") return "malicious";
return "pending";
}
function collectClawScanScannerFailures(scanners: Record<string, unknown> | undefined) {
if (!scanners) return [];
const failures: string[] = [];
for (const [scanner, value] of Object.entries(scanners)) {
const scannerRecord = asRecord(value);
const status = readString(scannerRecord, ["status"]) ?? "unknown";
if (status !== "completed") failures.push(`${scanner}=${status}`);
}
return failures;
}
function storedAnalysisFromClawScanArtifact(artifact: unknown): {
analysis?: StoredLlmAnalysis;
error?: string;
} {
const record = asRecord(artifact);
const judge = asRecord(record?.judge);
const result = asRecord(judge?.result);
const judgeStatus = readString(judge, ["status"]);
const scannerFailures = collectClawScanScannerFailures(asRecord(record?.scanners));
if (scannerFailures.length > 0) {
return { error: `ClawScan scanner did not complete: ${scannerFailures.join(", ")}` };
}
if (judgeStatus !== "completed") {
return { error: `ClawScan judge status was ${judgeStatus ?? "missing"}` };
}
const verdict = readString(result, ["verdict", "status"]);
if (!verdict) return { error: "ClawScan judge did not return a verdict" };
const dimensions = Array.isArray(result?.dimensions)
? (result.dimensions as StoredLlmAnalysis["dimensions"])
: undefined;
const confidence = readString(result, ["confidence"]);
const findings = readString(result, ["findings"]);
const guidance = readString(result, ["guidance"]);
const model = readString(result, ["model"]);
const summary = readString(result, ["summary"]);
return {
analysis: {
checkedAt: Date.now(),
status: verdictToStoredStatus(verdict),
verdict,
...(confidence ? { confidence } : {}),
...(dimensions ? { dimensions } : {}),
...(findings ? { findings } : {}),
...(guidance ? { guidance } : {}),
...(model ? { model } : {}),
...(summary ? { summary } : {}),
},
};
}
function clawScanCheckResult(analysis: StoredLlmAnalysis): WorkerCheckResult {
const status = (analysis.status || analysis.verdict || "").trim().toLowerCase();
const verdict = (analysis.verdict || "").trim().toLowerCase();
const normalizedVerdict = verdict || status;
const summary = publicText(
llmAnalysis.summary ??
llmAnalysis.findings ??
`ClawHub security review returned ${llmAnalysis.status}.`,
analysis.summary ?? analysis.findings ?? `ClawScan returned ${analysis.status}.`,
);
if (status === "clean" || normalizedVerdict === "benign") {
return {
status: "clean",
summary: summary || "ClawHub security review passed.",
summary: summary || "ClawScan passed.",
};
}
if (normalizedVerdict === "suspicious") {
return {
status: "clean",
summary: summary || "ClawHub security review requires user attention.",
redactedFindings: [
publicText(`status=${llmAnalysis.status}; verdict=${llmAnalysis.verdict}`),
],
summary: summary || "ClawScan returned suspicious review findings.",
redactedFindings: [publicText(`status=${analysis.status}; verdict=${analysis.verdict}`)],
};
}
if (normalizedVerdict !== "malicious") {
return {
status: "failed",
summary: summary || "ClawHub security review did not return a final verdict.",
redactedFindings: [
publicText(`status=${llmAnalysis.status}; verdict=${llmAnalysis.verdict}`),
],
summary: summary || "ClawScan did not return a final verdict.",
redactedFindings: [publicText(`status=${analysis.status}; verdict=${analysis.verdict}`)],
};
}
return {
status: "blocked",
summary:
summary ||
`ClawHub security review blocked the staged publish with status ${llmAnalysis.status}.`,
redactedFindings: [publicText(`status=${llmAnalysis.status}; verdict=${llmAnalysis.verdict}`)],
summary: summary || `ClawScan blocked the staged publish with status ${analysis.status}.`,
redactedFindings: [publicText(`status=${analysis.status}; verdict=${analysis.verdict}`)],
};
}
export async function runNativeClawScan(
job: ClaimedJob,
workspace: string,
): Promise<ClawScanResult> {
const artifactPath = join(workspace, "clawscan-result.json");
const target = await resolveNativeClawScanTarget(workspace, job);
const command = clawScanCommand();
const args = [target, "--profile", "clawhub", "--output", artifactPath];
const sandbox = process.env.PREPUBLICATION_CLAWSCAN_SANDBOX?.trim();
if (sandbox) {
args.push("--sandbox", sandbox);
const sandboxImage = process.env.PREPUBLICATION_CLAWSCAN_SANDBOX_IMAGE?.trim();
if (sandbox === "docker" && sandboxImage) args.push("--sandbox-image", sandboxImage);
}
const output = await runCommand(command, args, {
cwd: workspace,
timeoutMs: clawScanTimeoutMs(),
});
if (output.code !== 0) {
return {
check: {
status: "failed",
summary: publicText(
`ClawScan failed before returning a verdict: ${output.stderr || output.stdout}`,
),
},
};
}
const raw = await readFile(artifactPath, "utf8");
const parsed = storedAnalysisFromClawScanArtifact(JSON.parse(raw) as unknown);
if (parsed.error || !parsed.analysis) {
return {
check: {
status: "failed",
summary: publicText(parsed.error ?? "ClawScan did not return a usable result."),
},
};
}
return {
analysis: parsed.analysis,
check: clawScanCheckResult(parsed.analysis),
};
}
@@ -430,7 +542,7 @@ export async function processPrePublicationAttempt(
},
{
status: "clean",
summary: "Pre-publication ClawHub security review already passed.",
summary: "Pre-publication ClawScan already passed.",
},
);
logger.info(
@@ -461,7 +573,7 @@ export async function processPrePublicationAttempt(
const startedAt = Date.now();
const writeWorkspace = deps.writeWorkspace ?? writeArtifactWorkspace;
const runTruffleHog = deps.runTruffleHog ?? runNativeTruffleHog;
const runClawHubReview = deps.runClawHubReview ?? runNormalClawHubReview;
const runClawScan = deps.runClawScan ?? runNativeClawScan;
let truffleHogBlocked = false;
let completionStarted = false;
try {
@@ -473,7 +585,7 @@ export async function processPrePublicationAttempt(
completionStarted = true;
const result = await completeAttempt(client, token, attempt, trufflehog, {
status: "failed",
summary: "ClawHub security review skipped because TruffleHog blocked the artifact.",
summary: "ClawScan skipped because TruffleHog blocked the artifact.",
});
logger.info(
{
@@ -491,7 +603,7 @@ export async function processPrePublicationAttempt(
completionStarted = true;
const result = await completeAttempt(client, token, attempt, trufflehog, {
status: "failed",
summary: "ClawHub security review skipped because TruffleHog failed.",
summary: "ClawScan skipped because TruffleHog failed.",
});
return { completed: false, result };
}
@@ -499,9 +611,9 @@ export async function processPrePublicationAttempt(
let clawscan: WorkerCheckResult;
let clawscanAnalysis: StoredLlmAnalysis | undefined;
try {
const review = await runClawHubReview(job, workspace);
clawscanAnalysis = review.llmAnalysis;
clawscan = clawHubReviewCheckResult(review.llmAnalysis);
const review = await runClawScan(job, workspace);
clawscanAnalysis = review.analysis;
clawscan = review.check;
} catch (error) {
clawscan = {
status: "failed",
@@ -659,7 +771,6 @@ export function claimBatchDrainedQueue(
async function main() {
const { batchLimit, maxJobs, maxRuntimeMs } = parseArgs();
assertCodexWorkerExecutionAllowed(process.env);
maskKnownWorkerSecrets();
const convexUrl = process.env.CONVEX_URL ?? process.env.VITE_CONVEX_URL;
if (!convexUrl) throw new Error("CONVEX_URL or VITE_CONVEX_URL is required");
@@ -706,15 +817,6 @@ async function main() {
);
}
export function configurePrePublicationCodexHome(env: NodeJS.ProcessEnv = process.env) {
const codexHome = resolveCodexWorkerHome(env, LOCAL_CODEX_HOME);
if (!codexHome) return undefined;
env.CODEX_HOME = codexHome;
mkdirSync(codexHome, { recursive: true });
return codexHome;
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
configurePrePublicationCodexHome();
await main();
}
+10 -8
View File
@@ -6,7 +6,8 @@ import { getFunctionName } from "convex/server";
import { createElement } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { toastErrorMock } = vi.hoisted(() => ({
const { navigateMock, toastErrorMock } = vi.hoisted(() => ({
navigateMock: vi.fn(),
toastErrorMock: vi.fn(),
}));
@@ -15,6 +16,7 @@ vi.mock("@tanstack/react-router", () => ({
__config: config,
__path: path,
}),
useNavigate: () => navigateMock,
useSearch: () => useSearchMock(),
}));
@@ -103,6 +105,7 @@ describe("plugins publish route", () => {
useAuthStatusMock.mockReset();
useQueryMock.mockReset();
useSearchMock.mockReset();
navigateMock.mockReset();
toastErrorMock.mockReset();
useSearchMock.mockReturnValue({
@@ -834,7 +837,7 @@ describe("plugins publish route", () => {
expect(publishRelease).not.toHaveBeenCalled();
});
it("shows pending verification messaging after staged plugin publish", async () => {
it("redirects to the dashboard after a staged plugin publish is accepted", async () => {
publishRelease.mockResolvedValueOnce({
ok: true,
status: "pending",
@@ -876,12 +879,11 @@ describe("plugins publish route", () => {
fireEvent.click(screen.getByRole("button", { name: "Publish plugin" }));
expect(
await screen.findByText(/Running TruffleHog and ClawScan before public listing\./i),
).toBeTruthy();
expect(
screen.getByRole("button", { name: "Publish plugin" }).getAttribute("disabled"),
).not.toBeNull();
await waitFor(() => {
expect(navigateMock).toHaveBeenCalledWith({ to: "/dashboard" });
});
expect(screen.queryByText(/Running TruffleHog and ClawScan/i)).toBeNull();
expect(screen.queryByText("Publishing release...")).toBeNull();
});
it("warns when README references relative image paths but no source repo/commit is set", async () => {
+10 -6
View File
@@ -6,10 +6,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { api } from "../../convex/_generated/api";
import { Upload } from "../routes/skills/publish";
const navigateMock = vi.fn();
vi.mock("@tanstack/react-router", () => ({
Link: ({ children, to }: { children: ReactNode; to: string }) => <a href={to}>{children}</a>,
createFileRoute: () => (config: { component: unknown }) => config,
useNavigate: () => vi.fn(),
useNavigate: () => navigateMock,
useSearch: () => useSearchMock(),
}));
@@ -61,6 +63,7 @@ describe("Upload route", () => {
useQueryMock.mockReset();
useAuthStatusMock.mockReset();
useSearchMock.mockReset();
navigateMock.mockReset();
useSearchMock.mockReturnValue({ updateSlug: undefined, ownerHandle: undefined });
useActionCallCount = 0;
useAuthStatusMock.mockReturnValue({
@@ -967,7 +970,7 @@ describe("Upload route", () => {
expect(Object.hasOwn(args!, "icon")).toBe(false);
});
it("keeps publish disabled after staged skill publish is accepted", async () => {
it("redirects to the dashboard after a staged skill publish is accepted", async () => {
generateUploadUrl.mockResolvedValue("https://upload.local");
publishVersion.mockResolvedValueOnce({
status: "pending",
@@ -1004,10 +1007,11 @@ describe("Upload route", () => {
});
fireEvent.click(publishButton);
expect(
await screen.findByText(/Running TruffleHog and ClawScan before public listing\./i),
).toBeTruthy();
expect(publishButton.getAttribute("disabled")).not.toBeNull();
await waitFor(() => {
expect(navigateMock).toHaveBeenCalledWith({ to: "/dashboard" });
});
expect(screen.queryByText(/Running TruffleHog and ClawScan/i)).toBeNull();
expect(screen.queryByText("Publishing…")).toBeNull();
});
it("omits icon when republishing a skill that still has a stored legacy icon", async () => {
+4 -6
View File
@@ -1,4 +1,4 @@
import { createFileRoute, useSearch } from "@tanstack/react-router";
import { createFileRoute, useNavigate, useSearch } from "@tanstack/react-router";
import { DocsLinks, getPackageScopeOwnerMismatch, isPluginCategorySlug } from "clawhub-schema";
import { useAction, useMutation, useQuery } from "convex/react";
import { ExternalLink, Info, Lock } from "lucide-react";
@@ -189,6 +189,7 @@ function PluginPublishError({ message }: { message: string }) {
export function PublishPluginRoute() {
const search = useSearch({ from: "/plugins/publish" });
const navigate = useNavigate();
const { isAuthenticated, isLoading: isAuthLoading, me } = useAuthStatus();
const publishers = useQuery(api.publishers.listMine, me ? {} : "skip") as
| Array<PublisherOwnerMembership>
@@ -822,14 +823,12 @@ export function PublishPluginRoute() {
return;
}
setIsSubmitting(true);
setStatus("Uploading files...");
setError(null);
const uploaded = await buildPackageUploadEntries(files, {
generateUploadUrl,
hashFile,
uploadFile,
});
setStatus("Publishing release...");
const result = await publishRelease({
payload: {
name: name.trim(),
@@ -879,9 +878,8 @@ export function PublishPluginRoute() {
"status" in result &&
result.status === "pending"
) {
setStatus(
"Publish received. Running TruffleHog and ClawScan before public listing.",
);
setStatus(null);
void navigate({ to: "/dashboard" });
} else {
setStatus(
"Published. Pending security checks and verification before public listing.",
+1 -3
View File
@@ -721,7 +721,6 @@ export function Upload() {
return;
}
setIsSubmitting(true);
setStatus("Uploading files…");
try {
const uploaded = [] as Array<{
path: string;
@@ -749,7 +748,6 @@ export function Upload() {
});
}
setStatus("Publishing…");
const result = await publishVersion({
ownerHandle: ownerHandle || undefined,
sourceOwnerHandle:
@@ -779,8 +777,8 @@ export function Upload() {
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.");
void navigate({ to: "/dashboard" });
return;
}
const ownerParam = ownerHandle || me?.handle || (me?._id ? String(me._id) : "unknown");