From a9d0e6d2302bf6acea14ecc757ebaa80919d957a Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 20 Aug 2026 22:41:26 -0700 Subject: [PATCH] fix(release): bound plugin artifact downloads --- .agents/skills/openclaw-testing/SKILL.md | 7 +- .github/workflows/plugin-prerelease.yml | 52 ++++++- config/knip.config.ts | 1 + scripts/lib/plugin-npm-security-scan.mts | 107 +++++++++++++- scripts/plugin-npm-security-artifact-plan.mts | 87 ++++++++++++ scripts/plugin-npm-security-prepare.mts | 7 +- test/scripts/plugin-npm-security-scan.test.ts | 1 - .../plugin-prerelease-test-plan.test.ts | 133 +++++++++++++++++- 8 files changed, 382 insertions(+), 13 deletions(-) create mode 100644 scripts/plugin-npm-security-artifact-plan.mts diff --git a/.agents/skills/openclaw-testing/SKILL.md b/.agents/skills/openclaw-testing/SKILL.md index b43b159b2e63..de20a05d6723 100644 --- a/.agents/skills/openclaw-testing/SKILL.md +++ b/.agents/skills/openclaw-testing/SKILL.md @@ -343,9 +343,10 @@ all-plugin runtime time on release-only product coverage. `Plugin Prerelease` performs a supplemental scan of checked-in npm package input as inert data; it never runs candidate lifecycle, asset, build, install, or replacement scanner code. This scan does not approve post-build or publication -bytes. The publication workflow must independently scan its exact final artifact -and publish the same digest. Ingestion stays fail-slow so one malformed package -cannot hide other package reports. +bytes. A future publisher redesign must scan the exact final bytes and publish +that identical digest before this can become a publication gate; the current +publisher does not provide that guarantee. Ingestion stays fail-slow so one +malformed package cannot hide other package reports. Use one operator, one transition-only watcher, and at most one investigator for the current failed surface. Parent timeout or cancellation leaves adopted exact diff --git a/.github/workflows/plugin-prerelease.yml b/.github/workflows/plugin-prerelease.yml index 419b9f63f71f..871c105d385c 100644 --- a/.github/workflows/plugin-prerelease.yml +++ b/.github/workflows/plugin-prerelease.yml @@ -426,12 +426,60 @@ jobs: - name: Install trusted scanner dependencies run: pnpm install --frozen-lockfile --prefer-offline --ignore-scripts - - name: Download supplemental inert plugin inputs + - name: Bound supplemental inert plugin input downloads + id: artifact-download-plan + continue-on-error: true + env: + CANDIDATE_SHA: ${{ needs.resolve-candidate.outputs.checkout_revision }} + EXPECTED_PACKAGES_JSON: ${{ needs.plugin-npm-security-plan.outputs.packages_json }} + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + metadata="$RUNNER_TEMP/plugin-npm-security-artifact-api.json" + plan="$RUNNER_TEMP/plugin-npm-security-artifact-download-plan.json" + gh api --paginate --slurp \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2026-03-10" \ + "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/artifacts?per_page=100&direction=asc" \ + > "$metadata" + node --import tsx scripts/plugin-npm-security-artifact-plan.mts \ + --artifact-metadata-json "$metadata" \ + --candidate-sha "$CANDIDATE_SHA" \ + --expected-packages-json "$EXPECTED_PACKAGES_JSON" \ + --output "$plan" + artifact_ids="$(jq -r '[.artifacts[].id | tostring] | join(",")' "$plan")" + echo "artifact_ids=$artifact_ids" >> "$GITHUB_OUTPUT" + + # The pinned action validates server digests and downloads ID-bound artifacts + # in batches of five only after the trusted metadata plan enforces byte bounds. + - name: Download bounded supplemental inert plugin inputs + id: download-bounded-artifacts + if: steps.artifact-download-plan.outcome == 'success' && steps.artifact-download-plan.outputs.artifact_ids != '' continue-on-error: true uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - pattern: plugin-npm-security-package-${{ needs.resolve-candidate.outputs.checkout_revision }}-* + artifact-ids: ${{ steps.artifact-download-plan.outputs.artifact_ids }} + digest-mismatch: error + github-token: ${{ github.token }} path: ${{ runner.temp }}/plugin-npm-security-packages + repository: ${{ github.repository }} + run-id: ${{ github.run_id }} + + - name: Normalize single supplemental inert plugin input + if: steps.download-bounded-artifacts.outcome == 'success' + env: + ARTIFACT_ROOT: ${{ runner.temp }}/plugin-npm-security-packages + PLAN_PATH: ${{ runner.temp }}/plugin-npm-security-artifact-download-plan.json + shell: bash + run: | + set -euo pipefail + [[ "$(jq '.artifacts | length' "$PLAN_PATH")" == "1" ]] || exit 0 + artifact_name="$(jq -r '.artifacts[0].name' "$PLAN_PATH")" + staging="$RUNNER_TEMP/plugin-npm-security-single-artifact" + mkdir -p "$staging" "$ARTIFACT_ROOT/$artifact_name" + find "$ARTIFACT_ROOT" -mindepth 1 -maxdepth 1 -type f -exec mv {} "$staging/" \; + find "$staging" -mindepth 1 -maxdepth 1 -type f -exec mv {} "$ARTIFACT_ROOT/$artifact_name/" \; - name: Scan supplemental inert plugin inputs env: diff --git a/config/knip.config.ts b/config/knip.config.ts index 20bff4f28d1e..de6498ee8a85 100644 --- a/config/knip.config.ts +++ b/config/knip.config.ts @@ -78,6 +78,7 @@ const repositoryScriptEntries = [ "scripts/openclaw-release-clawhub-plan.ts!", "scripts/openclaw-release-clawhub-runtime-state.ts!", // Plugin Prerelease builds immutable package artifacts, then scans them in a bounded child. + "scripts/plugin-npm-security-artifact-plan.mts!", "scripts/plugin-npm-security-prepare.mts!", "scripts/plugin-npm-security-scan-runner.mjs!", "scripts/plugin-npm-security-scan.mts!", diff --git a/scripts/lib/plugin-npm-security-scan.mts b/scripts/lib/plugin-npm-security-scan.mts index eb3cf863bb84..9c6581623776 100644 --- a/scripts/lib/plugin-npm-security-scan.mts +++ b/scripts/lib/plugin-npm-security-scan.mts @@ -12,6 +12,7 @@ import { import { tmpdir } from "node:os"; import { basename, dirname, join, relative, resolve, sep } from "node:path"; import { promisify } from "node:util"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { isScannable, scanDirectoryWithSummary, @@ -81,6 +82,8 @@ export const MAX_PLUGIN_PACKAGE_MANIFEST_BYTES = 256 * 1024; export const MAX_PLUGIN_SCAN_FINDINGS_PER_PACKAGE = 10_000; export const MAX_PLUGIN_SCAN_TOTAL_FINDINGS = 50_000; export const MAX_PLUGIN_SCAN_REPORT_BYTES = 1024 * 1024; +export const MAX_PLUGIN_SECURITY_WORKFLOW_ARTIFACT_BYTES = 128 * 1024 * 1024; +export const MAX_PLUGIN_SECURITY_WORKFLOW_ARTIFACT_TOTAL_BYTES = 512 * 1024 * 1024; const MAX_PLUGIN_SECURITY_ARTIFACT_METADATA_BYTES = 64 * 1024; const MAX_PLUGIN_TARBALL_BYTES = 128 * 1024 * 1024; const MAX_PLUGIN_TARBALL_TOTAL_BYTES = 512 * 1024 * 1024; @@ -536,6 +539,104 @@ function artifactDirectoryName(candidateSha: string, extensionId: string): strin return `${PLUGIN_SECURITY_ARTIFACT_PREFIX}${candidateSha}-${extensionId}`; } +export type PluginNpmSecurityArtifactDownloadPlan = { + artifacts: Array<{ + digest: string; + id: number; + name: string; + sizeInBytes: number; + }>; + expectedArtifactCount: number; + totalBytes: number; +}; + +export function planPluginNpmSecurityArtifactDownloads(params: { + artifactPages: unknown; + candidateSha: string; + expectedPackages: unknown; +}): PluginNpmSecurityArtifactDownloadPlan { + if (!/^[0-9a-f]{40}$/u.test(params.candidateSha)) { + throw new Error("Plugin security artifact download candidate identity is invalid."); + } + const expectedPackages = parseExpectedPackages(params.expectedPackages); + const expectedNames = new Set( + expectedPackages.map((plugin) => + artifactDirectoryName(params.candidateSha, plugin.extensionId), + ), + ); + const expectedPrefix = `${PLUGIN_SECURITY_ARTIFACT_PREFIX}${params.candidateSha}-`; + if ( + !Array.isArray(params.artifactPages) || + params.artifactPages.length === 0 || + params.artifactPages.length > 100 + ) { + throw new Error("Plugin security artifact metadata pages are invalid."); + } + + const artifacts: PluginNpmSecurityArtifactDownloadPlan["artifacts"] = []; + const seenIds = new Set(); + const seenNames = new Set(); + let totalBytes = 0; + for (const page of params.artifactPages) { + if (!isRecord(page) || !Array.isArray(page.artifacts) || page.artifacts.length > 100) { + throw new Error("Plugin security artifact metadata page is invalid."); + } + for (const entry of page.artifacts) { + if (!isRecord(entry) || typeof entry.name !== "string") { + throw new Error("Plugin security artifact metadata entry is invalid."); + } + if (!entry.name.startsWith(expectedPrefix)) { + continue; + } + if (!expectedNames.has(entry.name)) { + throw new Error("Plugin security artifact metadata contains an unexpected package."); + } + if ( + !Number.isSafeInteger(entry.id) || + (entry.id as number) <= 0 || + !Number.isSafeInteger(entry.size_in_bytes) || + (entry.size_in_bytes as number) <= 0 || + entry.expired !== false || + typeof entry.digest !== "string" || + !/^sha256:[0-9a-f]{64}$/u.test(entry.digest) + ) { + throw new Error("Plugin security artifact metadata entry is invalid."); + } + const id = entry.id as number; + const sizeInBytes = entry.size_in_bytes as number; + if (seenIds.has(id) || seenNames.has(entry.name)) { + throw new Error("Plugin security artifact metadata contains a duplicate package."); + } + if (sizeInBytes > MAX_PLUGIN_SECURITY_WORKFLOW_ARTIFACT_BYTES) { + throw new Error("Plugin security artifact exceeds the pre-download byte limit."); + } + totalBytes += sizeInBytes; + if ( + !Number.isSafeInteger(totalBytes) || + totalBytes > MAX_PLUGIN_SECURITY_WORKFLOW_ARTIFACT_TOTAL_BYTES + ) { + throw new Error("Plugin security artifacts exceed the aggregate pre-download byte limit."); + } + seenIds.add(id); + seenNames.add(entry.name); + artifacts.push({ + digest: entry.digest, + id, + name: entry.name, + sizeInBytes, + }); + } + } + if (artifacts.length > MAX_PUBLISHABLE_PLUGIN_PACKAGES) { + throw new Error("Plugin security artifact metadata exceeds the package-count limit."); + } + return { + artifacts: artifacts.toSorted((left, right) => compareCodeUnits(left.name, right.name)), + expectedArtifactCount: expectedPackages.length, + totalBytes, + }; +} + function sanitizeArtifactIngestionError( expectedPackage: PublishablePluginPackage, error: unknown, @@ -754,7 +855,7 @@ export function assertCompleteScannerSummary( } } -async function scanPublishablePluginArtifact( +async function scanSupplementalInertPluginInput( plugin: PluginNpmSecurityArtifact, ): Promise { const reviewedCriticalFindings: string[] = []; @@ -768,7 +869,7 @@ async function scanPublishablePluginArtifact( staged.inspection.packageManifest.version !== plugin.packageVersion || staged.inspection.tarballSha256 !== plugin.tarballSha256 ) { - throw new Error(`${plugin.packageName}: publication artifact identity mismatch.`); + throw new Error(`${plugin.packageName}: supplemental inert package input identity mismatch.`); } for (const packedFile of staged.packedFiles) { expectedReviewedCriticalFindings.push( @@ -970,7 +1071,7 @@ export async function scanPublishablePluginPackages( plugin ? sanitizePackageScanError(plugin, error) : "Unknown package: package scan failed.", ); }, - tasks: packages.map((plugin) => () => scanPublishablePluginArtifact(plugin)), + tasks: packages.map((plugin) => () => scanSupplementalInertPluginInput(plugin)), }); return { packageResults: results.filter((result): result is ScanPackageResult => result !== undefined), diff --git a/scripts/plugin-npm-security-artifact-plan.mts b/scripts/plugin-npm-security-artifact-plan.mts new file mode 100644 index 000000000000..facae6066b0c --- /dev/null +++ b/scripts/plugin-npm-security-artifact-plan.mts @@ -0,0 +1,87 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { + planPluginNpmSecurityArtifactDownloads, + type PluginNpmSecurityArtifactDownloadPlan, +} from "./lib/plugin-npm-security-scan.mts"; +import { readBoundedRegularFile } from "./plugin-publication-artifact.mjs"; + +const MAX_ARTIFACT_METADATA_JSON_BYTES = 4 * 1024 * 1024; +const MAX_EXPECTED_PACKAGES_JSON_BYTES = 256 * 1024; + +type ParsedArgs = { + artifactMetadataPath: string; + candidateSha: string; + expectedPackages: unknown; + outputPath: string; +}; + +function parseArgs(argv: string[]): ParsedArgs { + const values = new Map(); + for (let index = 0; index < argv.length; index += 2) { + const name = argv[index]; + const value = argv[index + 1]; + if (!name?.startsWith("--") || value === undefined || values.has(name)) { + throw new Error(`Invalid plugin security artifact plan argument near ${name}.`); + } + values.set(name, value); + } + const artifactMetadataPath = values.get("--artifact-metadata-json") ?? ""; + const candidateSha = values.get("--candidate-sha") ?? ""; + const expectedPackagesJson = values.get("--expected-packages-json") ?? ""; + const outputPath = values.get("--output") ?? ""; + if ( + !artifactMetadataPath || + !/^[0-9a-f]{40}$/u.test(candidateSha) || + !expectedPackagesJson || + Buffer.byteLength(expectedPackagesJson, "utf8") > MAX_EXPECTED_PACKAGES_JSON_BYTES || + !outputPath + ) { + throw new Error("Plugin security artifact plan received an invalid identity or path."); + } + return { + artifactMetadataPath: resolve(artifactMetadataPath), + candidateSha, + expectedPackages: JSON.parse(expectedPackagesJson) as unknown, + outputPath: resolve(outputPath), + }; +} + +function writePlan(outputPath: string, plan: PluginNpmSecurityArtifactDownloadPlan): void { + mkdirSync(dirname(outputPath), { recursive: true }); + writeFileSync(outputPath, `${JSON.stringify(plan)}\n`, { + encoding: "utf8", + mode: 0o600, + }); +} + +function main(argv = process.argv.slice(2)): number { + const args = parseArgs(argv); + const artifactPages = JSON.parse( + readBoundedRegularFile(args.artifactMetadataPath, { + label: "Plugin security artifact API metadata", + maxBytes: MAX_ARTIFACT_METADATA_JSON_BYTES, + }).toString("utf8"), + ) as unknown; + const plan = planPluginNpmSecurityArtifactDownloads({ + artifactPages, + candidateSha: args.candidateSha, + expectedPackages: args.expectedPackages, + }); + writePlan(args.outputPath, plan); + console.log( + `Plugin security artifact download plan: ${plan.artifacts.length}/${plan.expectedArtifactCount} artifacts, ${plan.totalBytes} bytes.`, + ); + return 0; +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + try { + process.exitCode = main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + console.error("[plugin-npm-security-artifact-plan] FAILED (exit 1)"); + process.exitCode = 1; + } +} diff --git a/scripts/plugin-npm-security-prepare.mts b/scripts/plugin-npm-security-prepare.mts index 66d50e69fd74..0019e94d1622 100644 --- a/scripts/plugin-npm-security-prepare.mts +++ b/scripts/plugin-npm-security-prepare.mts @@ -48,7 +48,7 @@ function parseArgs(argv: string[]): ParsedArgs { const name = argv[index]; const value = argv[index + 1]; if (!name?.startsWith("--") || value === undefined || values.has(name)) { - throw new Error(`Invalid plugin npm security prepare argument near ${String(name)}.`); + throw new Error(`Invalid plugin npm security prepare argument near ${name}.`); } values.set(name, value); } @@ -166,8 +166,9 @@ async function preparePackage(args: ParsedArgs): Promise { mkdirSync(args.outputDir, { recursive: true }); } - // Supplemental qualification keeps candidate code inert. Publication builds - // and scans its final artifact separately before any registry mutation. + // This is supplemental inert checked-in npm input, never a final or + // publication artifact. Exact-byte scanning is a future publisher redesign, + // not a capability of this workflow. const npm = resolveNpmRunner({ env: { CI: "1", diff --git a/test/scripts/plugin-npm-security-scan.test.ts b/test/scripts/plugin-npm-security-scan.test.ts index 0c0853ee40c8..a74058458dbd 100644 --- a/test/scripts/plugin-npm-security-scan.test.ts +++ b/test/scripts/plugin-npm-security-scan.test.ts @@ -355,7 +355,6 @@ describe("scripts/lib/plugin-npm-security-scan.mts", () => { "package directory is not a real directory", ); - const packageDir = tempDirs.make("openclaw-plugin-npm-security-symlink-"); const outsideFile = join(outsideDir, "outside.ts"); writeFileSync(outsideFile, "export const value = 1;\n", "utf8"); diff --git a/test/scripts/plugin-prerelease-test-plan.test.ts b/test/scripts/plugin-prerelease-test-plan.test.ts index b436a4ef7531..3e93ffd56396 100644 --- a/test/scripts/plugin-prerelease-test-plan.test.ts +++ b/test/scripts/plugin-prerelease-test-plan.test.ts @@ -7,6 +7,12 @@ import { describe, expect, it } from "vitest"; import { parse } from "yaml"; import { findLaneByName } from "../../scripts/lib/docker-e2e-plan.mts"; import { BUNDLED_PLUGIN_INSTALL_UNINSTALL_SHARDS } from "../../scripts/lib/docker-e2e-scenarios.mts"; +import { + MAX_PLUGIN_SECURITY_WORKFLOW_ARTIFACT_BYTES, + MAX_PLUGIN_SECURITY_WORKFLOW_ARTIFACT_TOTAL_BYTES, + MAX_PUBLISHABLE_PLUGIN_PACKAGES, + planPluginNpmSecurityArtifactDownloads, +} from "../../scripts/lib/plugin-npm-security-scan.mts"; import { PLUGIN_PRERELEASE_REQUIRED_SURFACES, assertPluginPrereleaseTestPlanComplete, @@ -21,7 +27,10 @@ const CHECKOUT_V6 = "actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10"; const UPLOAD_ARTIFACT_V7 = "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a"; type WorkflowStep = { + "continue-on-error"?: boolean; env?: Record; + id?: string; + if?: string; name?: string; run?: string; uses?: string; @@ -282,6 +291,8 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => { const pluginWorkflow = readPluginPrereleaseWorkflow(); const pluginSource = readFileSync(".github/workflows/plugin-prerelease.yml", "utf8"); const securityPrepareSource = readFileSync("scripts/plugin-npm-security-prepare.mts", "utf8"); + const securityScannerSource = readFileSync("scripts/lib/plugin-npm-security-scan.mts", "utf8"); + const testingSkillSource = readFileSync(".agents/skills/openclaw-testing/SKILL.md", "utf8"); const resolver = pluginWorkflow.jobs["resolve-candidate"]; const securityPlan = pluginWorkflow.jobs["plugin-npm-security-plan"]; const securityPackage = pluginWorkflow.jobs["plugin-npm-security-package"]; @@ -302,6 +313,15 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => { const runSecurityScan = securityScan.steps.find( (step: WorkflowStep) => step.name === "Scan supplemental inert plugin inputs", ); + const artifactDownloadPlan = securityScan.steps.find( + (step: WorkflowStep) => step.name === "Bound supplemental inert plugin input downloads", + ); + const downloadArtifacts = securityScan.steps.find( + (step: WorkflowStep) => step.name === "Download bounded supplemental inert plugin inputs", + ); + const normalizeSingleArtifact = securityScan.steps.find( + (step: WorkflowStep) => step.name === "Normalize single supplemental inert plugin input", + ); const uploadReport = securityScan.steps.find( (step: WorkflowStep) => step.name === "Upload plugin npm security scan report", ); @@ -350,6 +370,35 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => { expect(runSecurityScan?.run).toContain("--artifact-root"); expect(runSecurityScan?.run).not.toContain("--candidate-root"); expect(runSecurityScan?.run).toContain('--candidate-sha "$CANDIDATE_SHA"'); + expect(artifactDownloadPlan).toMatchObject({ + "continue-on-error": true, + id: "artifact-download-plan", + }); + expect(artifactDownloadPlan?.run).toContain("gh api --paginate --slurp"); + expect(artifactDownloadPlan?.run).toContain("scripts/plugin-npm-security-artifact-plan.mts"); + expect(artifactDownloadPlan?.run).toContain('echo "artifact_ids=$artifact_ids"'); + expect(downloadArtifacts).toMatchObject({ + "continue-on-error": true, + id: "download-bounded-artifacts", + if: "steps.artifact-download-plan.outcome == 'success' && steps.artifact-download-plan.outputs.artifact_ids != ''", + uses: "actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c", + with: { + "artifact-ids": "${{ steps.artifact-download-plan.outputs.artifact_ids }}", + "digest-mismatch": "error", + "github-token": "${{ github.token }}", + path: "${{ runner.temp }}/plugin-npm-security-packages", + repository: "${{ github.repository }}", + "run-id": "${{ github.run_id }}", + }, + }); + expect(downloadArtifacts?.with).not.toHaveProperty("pattern"); + expect(normalizeSingleArtifact?.if).toBe( + "steps.download-bounded-artifacts.outcome == 'success'", + ); + expect(normalizeSingleArtifact?.run).toContain( + `[[ "$(jq '.artifacts | length' "$PLAN_PATH")" == "1" ]]`, + ); + expect(normalizeSingleArtifact?.run).toContain('"$ARTIFACT_ROOT/$artifact_name/"'); expect(uploadReport).toMatchObject({ if: "always()", uses: UPLOAD_ARTIFACT_V7, @@ -386,6 +435,19 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => { ); expect(securityPrepareSource).not.toContain("GITHUB_OUTPUT: process.env.GITHUB_OUTPUT"); expect(securityPrepareSource).toContain("resolveCandidatePluginPackageDir"); + expect(securityPrepareSource).toContain("supplemental inert checked-in npm input"); + expect(securityPrepareSource).toContain("future publisher redesign"); + expect(securityPrepareSource).not.toContain("scans its final artifact separately"); + expect(securityScannerSource).toContain("scanSupplementalInertPluginInput"); + expect(securityScannerSource).toContain("supplemental inert package input identity mismatch"); + expect(securityScannerSource).not.toContain("publication artifact identity mismatch"); + expect(testingSkillSource).toContain( + "A future publisher redesign must scan the exact final bytes", + ); + expect(testingSkillSource).toContain("the current\npublisher does not provide that guarantee"); + expect(testingSkillSource).not.toContain( + "The publication workflow must independently scan its exact final artifact", + ); expect(pluginNpmReleaseSource).toContain("plugin-publication-artifact.mjs verify"); expect(nodeShard.needs).toEqual(["resolve-candidate", "preflight"]); expect(runNodeShard?.run).toContain('spawnSync("pnpm", ["test", "--", ...configs]'); @@ -396,6 +458,72 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => { expect(pluginDispatch?.run).not.toContain("node_test_exclude_patterns_json"); }); + it("rejects oversized plugin security artifacts before the workflow download action", () => { + const candidateSha = "1".repeat(40); + const packageEntry = (index: number) => { + const id = `plugin-${String(index).padStart(3, "0")}`; + return { + extensionId: id, + packageDir: `extensions/${id}`, + packageName: `@openclaw/${id}`, + packageVersion: "1.0.0", + }; + }; + const artifactEntry = ( + plugin: ReturnType, + index: number, + sizeInBytes: number, + ) => ({ + digest: `sha256:${String(index + 1).padStart(64, "0")}`, + expired: false, + id: index + 1, + name: `plugin-npm-security-package-${candidateSha}-${plugin.extensionId}`, + size_in_bytes: sizeInBytes, + }); + + const onePackage = [packageEntry(0)]; + expect(() => + planPluginNpmSecurityArtifactDownloads({ + artifactPages: [ + { + artifacts: [ + artifactEntry(onePackage[0]!, 0, MAX_PLUGIN_SECURITY_WORKFLOW_ARTIFACT_BYTES + 1), + ], + }, + ], + candidateSha, + expectedPackages: onePackage, + }), + ).toThrow("pre-download byte limit"); + + const aggregatePackages = Array.from({ length: 5 }, (_, index) => packageEntry(index)); + const aggregateArtifactBytes = + Math.floor(MAX_PLUGIN_SECURITY_WORKFLOW_ARTIFACT_TOTAL_BYTES / aggregatePackages.length) + 1; + expect(() => + planPluginNpmSecurityArtifactDownloads({ + artifactPages: [ + { + artifacts: aggregatePackages.map((plugin, index) => + artifactEntry(plugin, index, aggregateArtifactBytes), + ), + }, + ], + candidateSha, + expectedPackages: aggregatePackages, + }), + ).toThrow("aggregate pre-download byte limit"); + + expect(() => + planPluginNpmSecurityArtifactDownloads({ + artifactPages: [{ artifacts: [] }], + candidateSha, + expectedPackages: Array.from({ length: MAX_PUBLISHABLE_PLUGIN_PACKAGES + 1 }, (_, index) => + packageEntry(index), + ), + }), + ).toThrow("Expected plugin package inventory is invalid"); + }); + it("keeps late candidate GITHUB_OUTPUT writes outside trusted candidate identity", () => { const pluginWorkflow = readPluginPrereleaseWorkflow(); const preflight = pluginWorkflow.jobs.preflight; @@ -427,7 +555,10 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => { readFileSync(githubOutput, "utf8") .trim() .split("\n") - .map((line) => line.split("=", 2)), + .map((line): [string, string] => { + const [key = "", value = ""] = line.split("=", 2); + return [key, value]; + }), ); expect(lateCandidateOutput.get("checkout_revision")).toBe("f".repeat(40));