mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
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:
@@ -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" \
|
||||
|
||||
@@ -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"
|
||||
Vendored
+4
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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">,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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">,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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[];
|
||||
|
||||
Vendored
+226
-224
@@ -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;
|
||||
|
||||
Vendored
+2
@@ -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",
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+9
-9
@@ -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];
|
||||
/**
|
||||
|
||||
Vendored
+94
-84
@@ -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;
|
||||
|
||||
Vendored
+10
@@ -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",
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user