fix(release): scan immutable plugin artifacts

This commit is contained in:
Vincent Koc
2026-08-20 20:47:06 -07:00
parent f33ee89256
commit 0fecd2ec46
11 changed files with 1648 additions and 357 deletions
+183 -34
View File
@@ -42,12 +42,55 @@ env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
resolve-candidate:
name: Resolve plugin prerelease candidate
runs-on: ubuntu-24.04
timeout-minutes: 5
outputs:
checkout_revision: ${{ steps.resolve.outputs.checkout_revision }}
steps:
- name: Checkout trusted workflow helper
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.sha }}
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Resolve and validate candidate
id: resolve
env:
EXPECTED_SHA: ${{ inputs.expected_sha }}
TARGET_REF: ${{ inputs.target_ref }}
shell: bash
run: |
set -euo pipefail
resolver_output="$RUNNER_TEMP/plugin-prerelease-resolved-ref"
bash scripts/github/resolve-openclaw-ref.sh \
--ref "$TARGET_REF" \
--expected-sha "$EXPECTED_SHA" \
--github-output "$resolver_output"
checkout_revision="$(sed -n 's/^sha=//p' "$resolver_output")"
[[ "$checkout_revision" =~ ^[0-9a-f]{40}$ ]] || {
echo "Resolved plugin prerelease candidate is not a full lowercase commit SHA." >&2
exit 1
}
timeout --signal=TERM --kill-after=10s 120s \
git fetch --no-tags --no-recurse-submodules --depth=1 origin "$checkout_revision"
fetched_revision="$(git rev-parse FETCH_HEAD)"
[[ "$fetched_revision" == "$checkout_revision" ]] || {
echo "Fetched plugin prerelease candidate differs from the resolved commit." >&2
exit 1
}
echo "checkout_revision=$checkout_revision" >> "$GITHUB_OUTPUT"
preflight:
name: Build plugin prerelease plan
needs: [resolve-candidate]
runs-on: ubuntu-24.04
timeout-minutes: 15
outputs:
checkout_revision: ${{ steps.manifest.outputs.checkout_revision }}
run_plugin_prerelease_suite: ${{ steps.manifest.outputs.run_plugin_prerelease_suite }}
run_plugin_prerelease_static: ${{ steps.manifest.outputs.run_plugin_prerelease_static }}
plugin_prerelease_static_matrix: ${{ steps.manifest.outputs.plugin_prerelease_static_matrix }}
@@ -61,7 +104,7 @@ jobs:
- name: Checkout target
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ inputs.target_ref }}
ref: ${{ needs.resolve-candidate.outputs.checkout_revision }}
fetch-depth: 1
fetch-tags: false
persist-credentials: false
@@ -83,26 +126,14 @@ jobs:
- name: Build plugin prerelease manifest
id: manifest
env:
EXPECTED_SHA: ${{ inputs.expected_sha }}
FULL_RELEASE_VALIDATION: ${{ inputs.full_release_validation && 'true' || 'false' }}
run: |
node --import tsx --input-type=module <<'EOF'
import { appendFileSync, existsSync } from "node:fs";
import { execFileSync } from "node:child_process";
const createMatrix = (include) => ({ include });
const outputPath = process.env.GITHUB_OUTPUT;
const checkoutRevision = execFileSync("git", ["rev-parse", "HEAD"], {
encoding: "utf8",
}).trim();
const expectedSha = (process.env.EXPECTED_SHA ?? "").trim();
const fullReleaseValidation = process.env.FULL_RELEASE_VALIDATION === "true";
if (expectedSha && expectedSha !== checkoutRevision) {
console.error(
`target_ref resolved to ${checkoutRevision}, expected ${expectedSha}`,
);
process.exit(1);
}
let pluginPrereleasePlan = { staticChecks: [], dockerLanes: [] };
let extensionShards = [];
@@ -227,7 +258,6 @@ jobs:
const runSuite = runStatic || runNode || runExtensions || runDocker;
const manifest = {
checkout_revision: checkoutRevision,
run_plugin_prerelease_suite: runSuite,
run_plugin_prerelease_static: runStatic,
plugin_prerelease_static_matrix: createMatrix(staticChecks),
@@ -248,13 +278,16 @@ jobs:
}
EOF
plugin-npm-security-scan:
plugin-npm-security-plan:
permissions:
contents: read
name: plugin-npm-security-scan
needs: [preflight]
name: Plan plugin npm security artifacts
needs: [resolve-candidate]
runs-on: ubuntu-24.04
timeout-minutes: 20
timeout-minutes: 10
outputs:
matrix: ${{ steps.plan.outputs.matrix }}
packages_json: ${{ steps.plan.outputs.packages_json }}
steps:
- name: Checkout trusted scanner tooling
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
@@ -268,7 +301,7 @@ jobs:
- name: Checkout candidate as inert data
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ needs.preflight.outputs.checkout_revision }}
ref: ${{ needs.resolve-candidate.outputs.checkout_revision }}
path: .release-candidate
fetch-depth: 1
fetch-tags: false
@@ -288,17 +321,132 @@ jobs:
- name: Install trusted scanner dependencies
run: pnpm install --frozen-lockfile --prefer-offline --ignore-scripts
- name: Scan candidate plugin packages
- name: Build trusted plugin package plan
id: plan
run: |
node --import tsx scripts/plugin-npm-security-prepare.mts plan \
--candidate-root .release-candidate \
--github-output "$GITHUB_OUTPUT"
plugin-npm-security-package:
permissions:
contents: read
name: Package ${{ matrix.package_name }}
needs: [resolve-candidate, plugin-npm-security-plan]
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 30
strategy:
fail-fast: false
max-parallel: 24
matrix: ${{ fromJson(needs.plugin-npm-security-plan.outputs.matrix) }}
steps:
- name: Checkout trusted packaging tooling
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.sha }}
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Checkout candidate package source
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ needs.resolve-candidate.outputs.checkout_revision }}
path: .release-candidate
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Setup trusted packaging environment
uses: ./.github/actions/setup-node-env
with:
node-version: "24.x"
install-bun: "false"
- name: Prepare immutable plugin tarball
env:
CANDIDATE_SHA: ${{ needs.preflight.outputs.checkout_revision }}
CANDIDATE_SHA: ${{ needs.resolve-candidate.outputs.checkout_revision }}
EXTENSION_ID: ${{ matrix.extension_id }}
PACKAGE_DIR: ${{ matrix.package_dir }}
PACKAGE_NAME: ${{ matrix.package_name }}
TOOLING_SHA: ${{ github.sha }}
shell: bash
run: |
set -euo pipefail
output_dir="$RUNNER_TEMP/plugin-npm-security-package"
node --import tsx scripts/plugin-npm-security-prepare.mts prepare \
--candidate-root .release-candidate \
--candidate-sha "$CANDIDATE_SHA" \
--extension-id "$EXTENSION_ID" \
--output-dir "$output_dir" \
--package-dir "$PACKAGE_DIR" \
--package-name "$PACKAGE_NAME" \
--tooling-sha "$TOOLING_SHA"
- name: Upload immutable plugin tarball
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: plugin-npm-security-package-${{ needs.resolve-candidate.outputs.checkout_revision }}-${{ matrix.extension_id }}
path: ${{ runner.temp }}/plugin-npm-security-package/
compression-level: 0
if-no-files-found: error
retention-days: 14
plugin-npm-security-scan:
permissions:
actions: read
contents: read
name: plugin-npm-security-scan
needs: [resolve-candidate, plugin-npm-security-plan, plugin-npm-security-package]
if: ${{ !cancelled() && always() && needs.resolve-candidate.result == 'success' && needs.plugin-npm-security-plan.result == 'success' }}
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- name: Checkout trusted scanner tooling
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.sha }}
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Setup trusted scanner TypeScript runtime
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "24.x"
- name: Setup trusted scanner pnpm
uses: ./.github/actions/setup-pnpm-store-cache
with:
node-version: "24.x"
- name: Install trusted scanner dependencies
run: pnpm install --frozen-lockfile --prefer-offline --ignore-scripts
- name: Download immutable plugin tarballs
continue-on-error: true
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: plugin-npm-security-package-${{ needs.resolve-candidate.outputs.checkout_revision }}-*
path: ${{ runner.temp }}/plugin-npm-security-packages
- name: Scan immutable plugin tarballs
env:
CANDIDATE_SHA: ${{ needs.resolve-candidate.outputs.checkout_revision }}
EXPECTED_PACKAGES_JSON: ${{ needs.plugin-npm-security-plan.outputs.packages_json }}
TOOLING_SHA: ${{ github.sha }}
shell: bash
run: |
set -euo pipefail
report="$RUNNER_TEMP/plugin-npm-security-scan.json"
node --import tsx scripts/plugin-npm-security-scan.mts \
--candidate-root .release-candidate \
mkdir -p "$RUNNER_TEMP/plugin-npm-security-packages"
node scripts/plugin-npm-security-scan-runner.mjs \
--artifact-root "$RUNNER_TEMP/plugin-npm-security-packages" \
--candidate-sha "$CANDIDATE_SHA" \
--expected-packages-json "$EXPECTED_PACKAGES_JSON" \
--tooling-sha "$TOOLING_SHA" \
--report "$report"
@@ -314,7 +462,7 @@ jobs:
permissions:
contents: read
name: ${{ matrix.check_name }}
needs: [preflight]
needs: [resolve-candidate, preflight]
if: needs.preflight.outputs.run_plugin_prerelease_static == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }}
timeout-minutes: 45
@@ -325,7 +473,7 @@ jobs:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ needs.preflight.outputs.checkout_revision }}
ref: ${{ needs.resolve-candidate.outputs.checkout_revision }}
fetch-depth: 1
fetch-tags: false
persist-credentials: false
@@ -350,7 +498,7 @@ jobs:
permissions:
contents: read
name: ${{ matrix.check_name }}
needs: [preflight]
needs: [resolve-candidate, preflight]
if: needs.preflight.outputs.run_plugin_prerelease_node == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (matrix.runner || 'ubuntu-24.04') }}
timeout-minutes: 60
@@ -361,7 +509,7 @@ jobs:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ needs.preflight.outputs.checkout_revision }}
ref: ${{ needs.resolve-candidate.outputs.checkout_revision }}
fetch-depth: 1
fetch-tags: false
persist-credentials: false
@@ -419,7 +567,7 @@ jobs:
permissions:
contents: read
name: ${{ matrix.check_name }}
needs: [preflight]
needs: [resolve-candidate, preflight]
if: needs.preflight.outputs.run_plugin_prerelease_extensions == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || matrix.runner }}
timeout-minutes: 60
@@ -430,7 +578,7 @@ jobs:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ needs.preflight.outputs.checkout_revision }}
ref: ${{ needs.resolve-candidate.outputs.checkout_revision }}
fetch-depth: 1
fetch-tags: false
persist-credentials: false
@@ -453,7 +601,7 @@ jobs:
permissions:
contents: read
name: plugin-prerelease-inspector
needs: [preflight]
needs: [resolve-candidate, preflight]
if: needs.preflight.outputs.run_plugin_prerelease_suite == 'true'
continue-on-error: true
runs-on: ubuntu-24.04
@@ -462,7 +610,7 @@ jobs:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ needs.preflight.outputs.checkout_revision }}
ref: ${{ needs.resolve-candidate.outputs.checkout_revision }}
fetch-depth: 1
fetch-tags: false
persist-credentials: false
@@ -630,7 +778,7 @@ jobs:
plugin-prerelease-docker-suite:
name: plugin-prerelease-docker-suite
needs: [preflight]
needs: [resolve-candidate, preflight]
if: ${{ inputs.full_release_validation && needs.preflight.outputs.run_plugin_prerelease_docker == 'true' }}
permissions:
actions: read
@@ -639,7 +787,7 @@ jobs:
pull-requests: read
uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
with:
ref: ${{ needs.preflight.outputs.checkout_revision }}
ref: ${{ needs.resolve-candidate.outputs.checkout_revision }}
include_repo_e2e: false
include_release_path_suites: false
include_openwebui: false
@@ -678,6 +826,7 @@ jobs:
contents: read
name: plugin-prerelease-suite
needs:
- resolve-candidate
- preflight
- plugin-npm-security-scan
- plugin-prerelease-static-shard
+3 -1
View File
@@ -77,7 +77,9 @@ const repositoryScriptEntries = [
"scripts/memory-index-manager.sync-repro.ts!",
"scripts/openclaw-release-clawhub-plan.ts!",
"scripts/openclaw-release-clawhub-runtime-state.ts!",
// Plugin Prerelease invokes this trusted scanner against an inert candidate checkout.
// Plugin Prerelease builds immutable package artifacts, then scans them in a bounded child.
"scripts/plugin-npm-security-prepare.mts!",
"scripts/plugin-npm-security-scan-runner.mjs!",
"scripts/plugin-npm-security-scan.mts!",
// Oxlint loads this JS plugin by path from config/oxlint/boundary-guards.json.
"scripts/oxlint-boundary-guards.mjs!",
+1
View File
@@ -2135,6 +2135,7 @@
"tsx": "4.23.1",
"unrun": "0.3.1",
"vite": "8.1.5",
"validate-npm-package-name": "7.0.2",
"vitest": "4.1.10"
},
"optionalDependencies": {
+3
View File
@@ -337,6 +337,9 @@ importers:
npm-packlist:
specifier: 10.0.4
version: 10.0.4
validate-npm-package-name:
specifier: 7.0.2
version: 7.0.2
oxfmt:
specifier: 0.60.0
version: 0.60.0
+441 -42
View File
@@ -4,12 +4,15 @@ import {
lstatSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
realpathSync,
rmSync,
writeFileSync,
} from "node:fs";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import {
@@ -18,10 +21,16 @@ import {
type SkillScanFinding,
} from "../../src/skills/security/scanner.js";
import { runTasksWithConcurrency } from "../../src/utils/run-with-concurrency.js";
import {
inspectPackageTarballBytes,
readBoundedRegularFile,
} from "../plugin-publication-artifact.mjs";
type PublishablePluginPackage = {
export type PublishablePluginPackage = {
extensionId: string;
packageDir: string;
packageName: string;
packageVersion: string;
};
type CriticalFindingRecord = {
@@ -30,14 +39,25 @@ type CriticalFindingRecord = {
ruleId: string;
};
type ScanPackageResult = {
export type ScanPackageResult = {
expectedReviewedCriticalFindings: string[];
packageName: string;
packageVersion: string;
packedFileCount: number;
reviewedCriticalFindings: string[];
scanFindingCount: number;
tarballSha256: string;
unexpectedCriticalFindings: CriticalFindingRecord[];
};
type PluginNpmSecurityArtifact = PublishablePluginPackage & {
artifactDir: string;
candidateSha: string;
tarballPath: string;
tarballSha256: string;
toolingSha: string;
};
export type PluginNpmSecurityScanReport = {
candidateSha: string;
errors: string[];
@@ -46,6 +66,7 @@ export type PluginNpmSecurityScanReport = {
schemaVersion: 1;
status: "pass" | "fail";
summary: {
findingCount: number;
packageCount: number;
reviewedCriticalFindingCount: number;
unexpectedCriticalFindingCount: number;
@@ -54,7 +75,18 @@ export type PluginNpmSecurityScanReport = {
};
const execFileAsync = promisify(execFile);
const MAX_PACKED_FILES_PER_PACKAGE = 50_000;
const require = createRequire(import.meta.url);
const validateNpmPackageName = require("validate-npm-package-name") as (name: unknown) => {
validForNewPackages: boolean;
};
export const MAX_PUBLISHABLE_PLUGIN_PACKAGES = 256;
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;
const MAX_PLUGIN_SECURITY_ARTIFACT_METADATA_BYTES = 64 * 1024;
const MAX_PLUGIN_TARBALL_BYTES = 128 * 1024 * 1024;
const MAX_PACKED_FILES_PER_PACKAGE = 20_000;
const MAX_PACKED_FILE_BYTES = 64 * 1024 * 1024;
const MAX_PACKED_TOTAL_BYTES_PER_PACKAGE = 256 * 1024 * 1024;
const MAX_SCANNABLE_FILES_PER_PACKAGE = 10_000;
@@ -141,8 +173,12 @@ function expandFindingCounts(counts: ReadonlyMap<string, number>): string[] {
return [...counts].flatMap(([key, count]) => Array.from({ length: count }, () => key));
}
function compareCodeUnits(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
function sortStrings(values: readonly string[]): string[] {
return [...values].toSorted((left, right) => left.localeCompare(right));
return [...values].toSorted(compareCodeUnits);
}
function arraysEqual(left: readonly string[], right: readonly string[]): boolean {
@@ -203,6 +239,7 @@ export async function collectNpmPackedFiles(
): Promise<string[]> {
const helperPath = limits.helperPath ?? PACKLIST_HELPER_PATH;
const maxOldSpaceMb = limits.maxOldSpaceMb ?? PACKLIST_HELPER_MAX_OLD_SPACE_MB;
const timeoutMs = limits.timeoutMs ?? PACKLIST_HELPER_TIMEOUT_MS;
try {
const { stdout } = await execFileAsync(
process.execPath,
@@ -217,12 +254,28 @@ export async function collectNpmPackedFiles(
},
killSignal: "SIGKILL",
maxBuffer: limits.maxBufferBytes ?? PACKLIST_HELPER_MAX_BUFFER_BYTES,
timeout: limits.timeoutMs ?? PACKLIST_HELPER_TIMEOUT_MS,
signal: AbortSignal.timeout(timeoutMs),
},
);
return parsePacklistFiles(stdout, packageName);
} catch {
throw new Error(`${packageName}: trusted packlist helper exceeded its resource limits.`);
} catch (error) {
const failure =
error && typeof error === "object"
? (error as { code?: unknown; killed?: unknown; signal?: unknown })
: {};
if (failure.code === "ABORT_ERR" || failure.code === "ETIMEDOUT") {
throw new Error(`${packageName}: trusted packlist helper timed out.`);
}
if (failure.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") {
throw new Error(`${packageName}: trusted packlist helper exceeded its output limit.`);
}
if (failure.killed === true || typeof failure.signal === "string") {
throw new Error(`${packageName}: trusted packlist helper exceeded its process limit.`);
}
if (typeof failure.code === "number") {
throw new Error(`${packageName}: trusted packlist helper failed.`);
}
throw new Error(`${packageName}: trusted packlist helper could not start.`);
}
}
@@ -363,8 +416,23 @@ async function gitOutput(rootDir: string, args: string[]): Promise<string> {
return stdout.trim();
}
async function listPublishablePluginPackages(
export function assertCanonicalNpmPackageName(packageName: unknown, label: string): string {
if (
typeof packageName !== "string" ||
packageName.trim() !== packageName ||
!validateNpmPackageName(packageName).validForNewPackages
) {
throw new Error(`${label}: publishable plugin has an invalid npm package name.`);
}
return packageName;
}
export async function listPublishablePluginPackages(
candidateDir: string,
limits: {
maxManifestBytes?: number;
maxPackageManifests?: number;
} = {},
): Promise<PublishablePluginPackage[]> {
const { stdout } = await execFileAsync(
"git",
@@ -375,8 +443,12 @@ async function listPublishablePluginPackages(
},
);
const packageFiles = stdout.split("\0").filter(Boolean).toSorted();
const maxPackageManifests = limits.maxPackageManifests ?? MAX_PUBLISHABLE_PLUGIN_PACKAGES;
if (packageFiles.length > maxPackageManifests) {
throw new Error("Candidate exceeds the plugin package-count limit.");
}
return packageFiles.flatMap((packageFile) => {
const publishablePackages = packageFiles.flatMap((packageFile) => {
const match = /^extensions\/([^/]+)\/package\.json$/u.exec(packageFile);
if (!match?.[1]) {
return [];
@@ -387,18 +459,294 @@ async function listPublishablePluginPackages(
if (!packageStat.isFile()) {
throw new Error(`${packageFile}: package manifest is not a regular file.`);
}
if (
packageStat.size === 0 ||
packageStat.size > (limits.maxManifestBytes ?? MAX_PLUGIN_PACKAGE_MANIFEST_BYTES)
) {
throw new Error(`${packageFile}: package manifest exceeds the byte limit.`);
}
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
name?: unknown;
version?: unknown;
openclaw?: { release?: { publishToNpm?: unknown } };
};
if (packageJson.openclaw?.release?.publishToNpm !== true) {
return [];
}
if (typeof packageJson.name !== "string" || !packageJson.name.trim()) {
throw new Error(`${packageFile}: publishable plugin is missing its package name.`);
const packageName = assertCanonicalNpmPackageName(packageJson.name, packageFile);
if (
typeof packageJson.version !== "string" ||
!packageJson.version ||
packageJson.version.trim() !== packageJson.version
) {
throw new Error(`${packageFile}: publishable plugin has an invalid package version.`);
}
return [{ packageDir, packageName: packageJson.name }];
return [
{
extensionId: match[1],
packageDir,
packageName,
packageVersion: packageJson.version,
},
];
});
const seenNames = new Set<string>();
for (const plugin of publishablePackages) {
if (seenNames.has(plugin.packageName)) {
throw new Error(`Candidate contains duplicate publishable package ${plugin.packageName}.`);
}
seenNames.add(plugin.packageName);
}
return publishablePackages.toSorted((left, right) =>
compareCodeUnits(left.packageName, right.packageName),
);
}
const PLUGIN_SECURITY_ARTIFACT_METADATA = "plugin-npm-security-artifact.json";
function parseExpectedPackages(value: unknown): PublishablePluginPackage[] {
if (!Array.isArray(value) || value.length > MAX_PUBLISHABLE_PLUGIN_PACKAGES) {
throw new Error("Expected plugin package inventory is invalid.");
}
const packages = value.map((entry, index) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
throw new Error(`Expected plugin package entry ${index} is invalid.`);
}
const candidate = entry as Record<string, unknown>;
const extensionId = candidate.extensionId;
const packageDir = candidate.packageDir;
const packageName = assertCanonicalNpmPackageName(
candidate.packageName,
`Expected plugin package entry ${index}`,
);
const packageVersion = candidate.packageVersion;
if (
typeof extensionId !== "string" ||
!/^[a-z0-9][a-z0-9._-]*$/u.test(extensionId) ||
packageDir !== `extensions/${extensionId}` ||
typeof packageVersion !== "string" ||
!packageVersion ||
packageVersion.trim() !== packageVersion
) {
throw new Error(`Expected plugin package entry ${index} is invalid.`);
}
return { extensionId, packageDir, packageName, packageVersion };
});
const sorted = packages.toSorted((left, right) =>
compareCodeUnits(left.packageName, right.packageName),
);
if (
new Set(sorted.map((plugin) => plugin.packageName)).size !== sorted.length ||
new Set(sorted.map((plugin) => plugin.extensionId)).size !== sorted.length ||
JSON.stringify(sorted) !== JSON.stringify(packages)
) {
throw new Error("Expected plugin package inventory must be unique and sorted.");
}
return sorted;
}
function readPluginSecurityArtifact(
artifactDir: string,
expectedCandidateSha: string,
expectedToolingSha: string,
): PluginNpmSecurityArtifact {
const metadataPath = join(artifactDir, PLUGIN_SECURITY_ARTIFACT_METADATA);
const metadataStat = lstatSync(metadataPath);
if (
!metadataStat.isFile() ||
metadataStat.size === 0 ||
metadataStat.size > MAX_PLUGIN_SECURITY_ARTIFACT_METADATA_BYTES
) {
throw new Error("Plugin security artifact metadata is outside the byte limit.");
}
const metadata = JSON.parse(readFileSync(metadataPath, "utf8")) as Record<string, unknown>;
const expectedKeys = [
"candidateSha",
"extensionId",
"packageDir",
"packageName",
"packageVersion",
"schemaVersion",
"tarballName",
"tarballSha256",
"toolingSha",
];
if (
metadata.schemaVersion !== 1 ||
JSON.stringify(Object.keys(metadata).toSorted()) !== JSON.stringify(expectedKeys)
) {
throw new Error("Plugin security artifact metadata has an invalid shape.");
}
const packageName = assertCanonicalNpmPackageName(
metadata.packageName,
"Plugin security artifact metadata",
);
const extensionId = metadata.extensionId;
const packageDir = metadata.packageDir;
const packageVersion = metadata.packageVersion;
const tarballName = metadata.tarballName;
const tarballSha256 = metadata.tarballSha256;
if (
metadata.candidateSha !== expectedCandidateSha ||
metadata.toolingSha !== expectedToolingSha ||
typeof extensionId !== "string" ||
!/^[a-z0-9][a-z0-9._-]*$/u.test(extensionId) ||
packageDir !== `extensions/${extensionId}` ||
typeof packageVersion !== "string" ||
!packageVersion ||
packageVersion.trim() !== packageVersion ||
typeof tarballName !== "string" ||
!/^[A-Za-z0-9][A-Za-z0-9._-]*\.tgz$/u.test(tarballName) ||
basename(tarballName) !== tarballName ||
typeof tarballSha256 !== "string" ||
!/^[0-9a-f]{64}$/u.test(tarballSha256)
) {
throw new Error("Plugin security artifact metadata identity is invalid.");
}
const artifactEntries = readdirSync(artifactDir, { withFileTypes: true });
if (
artifactEntries.length !== 2 ||
artifactEntries.some(
(entry) =>
!entry.isFile() ||
(entry.name !== PLUGIN_SECURITY_ARTIFACT_METADATA && entry.name !== tarballName),
)
) {
throw new Error("Plugin security artifact contains unexpected entries.");
}
const tarballPath = join(artifactDir, tarballName);
const tarballStat = lstatSync(tarballPath);
if (
!tarballStat.isFile() ||
tarballStat.size === 0 ||
tarballStat.size > MAX_PLUGIN_TARBALL_BYTES
) {
throw new Error(`${packageName}: plugin tarball is outside the byte limit.`);
}
return {
artifactDir,
candidateSha: expectedCandidateSha,
extensionId,
packageDir,
packageName,
packageVersion,
tarballPath,
tarballSha256,
toolingSha: expectedToolingSha,
};
}
export function listPluginNpmSecurityArtifacts(params: {
artifactRoot: string;
candidateSha: string;
expectedPackages: unknown;
toolingSha: string;
}): PluginNpmSecurityArtifact[] {
const expectedPackages = parseExpectedPackages(params.expectedPackages);
const artifactRoot = realpathSync(params.artifactRoot);
const entries = readdirSync(artifactRoot, { withFileTypes: true }).toSorted((left, right) =>
compareCodeUnits(left.name, right.name),
);
if (entries.length > MAX_PUBLISHABLE_PLUGIN_PACKAGES) {
throw new Error("Plugin security artifact set exceeds the package-count limit.");
}
const artifacts = entries.map((entry) => {
if (!entry.isDirectory() || entry.isSymbolicLink()) {
throw new Error("Plugin security artifact root contains a non-directory entry.");
}
return readPluginSecurityArtifact(
join(artifactRoot, entry.name),
params.candidateSha,
params.toolingSha,
);
});
const sorted = artifacts.toSorted((left, right) =>
compareCodeUnits(left.packageName, right.packageName),
);
if (new Set(sorted.map((plugin) => plugin.packageName)).size !== sorted.length) {
throw new Error("Plugin security artifact set contains duplicate package names.");
}
const observedPackages = sorted.map(
({ extensionId, packageDir, packageName, packageVersion }) => ({
extensionId,
packageDir,
packageName,
packageVersion,
}),
);
if (JSON.stringify(observedPackages) !== JSON.stringify(expectedPackages)) {
throw new Error("Plugin security artifact set does not match the trusted package plan.");
}
return sorted;
}
export function stageScannerRelevantPluginTarballFiles(tarballPath: string): {
fileCount: number;
inspection: {
inventory: Array<{ path: string; sizeBytes: number; type: string }>;
packageManifest: Record<string, unknown>;
tarballSha256: string;
};
packedFiles: string[];
stageDir: string;
totalBytes: number;
} {
const stageDir = mkdtempSync(join(tmpdir(), "openclaw-plugin-npm-scan-"));
let fileCount = 0;
let totalBytes = 0;
const packedFiles: string[] = [];
try {
const tarballBytes = readBoundedRegularFile(tarballPath, {
label: "Plugin security tarball",
maxBytes: MAX_PLUGIN_TARBALL_BYTES,
});
const inspection = inspectPackageTarballBytes(tarballBytes, {
maxArchiveBytes: MAX_PLUGIN_TARBALL_BYTES,
maxEntries: MAX_PACKED_FILES_PER_PACKAGE,
maxEntryBytes: MAX_PACKED_FILE_BYTES,
maxExpandedBytes: MAX_PACKED_TOTAL_BYTES_PER_PACKAGE,
maxPathBytes: 4 * 1024 * 1024,
maxTotalFileBytes: MAX_PACKED_TOTAL_BYTES_PER_PACKAGE,
onFile: ({ content, path }: { content: Uint8Array; path: string }) => {
if (!path.startsWith("package/")) {
throw new Error("Plugin tarball file escaped package/.");
}
const packedPath = path.slice("package/".length);
packedFiles.push(packedPath);
if (!isScannable(packedPath)) {
return;
}
if (content.byteLength > MAX_SCANNABLE_FILE_BYTES) {
throw new Error(`Packed scanner input exceeds the per-file byte limit: ${packedPath}`);
}
fileCount += 1;
totalBytes += content.byteLength;
if (fileCount > MAX_SCANNABLE_FILES_PER_PACKAGE) {
throw new Error("Packed scanner input exceeds the file-count limit.");
}
if (totalBytes > MAX_SCANNABLE_TOTAL_BYTES_PER_PACKAGE) {
throw new Error("Packed scanner input exceeds the total-byte limit.");
}
const target = join(stageDir, ...packedPath.split("/"));
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, content);
},
}) as {
inventory: Array<{ path: string; sizeBytes: number; type: string }>;
packageManifest: Record<string, unknown>;
tarballSha256: string;
};
return {
fileCount,
inspection,
packedFiles: packedFiles.toSorted(),
stageDir,
totalBytes,
};
} catch (error) {
rmSync(stageDir, { recursive: true, force: true });
throw error;
}
}
function findingRecord(stageDir: string, finding: SkillScanFinding): CriticalFindingRecord {
@@ -421,27 +769,37 @@ export function assertCompleteScannerSummary(
}
}
async function scanPublishablePluginPackage(
plugin: PublishablePluginPackage,
async function scanPublishablePluginArtifact(
plugin: PluginNpmSecurityArtifact,
): Promise<ScanPackageResult> {
const reviewedCriticalFindings: string[] = [];
const expectedReviewedCriticalFindings: string[] = [];
const unexpectedCriticalFindings: CriticalFindingRecord[] = [];
const packedFiles = await collectNpmPackedFiles(plugin.packageDir, plugin.packageName);
for (const packedFile of packedFiles) {
expectedReviewedCriticalFindings.push(
...expectedOptionalReviewedFindingsForPackedPath(plugin.packageName, packedFile),
);
}
const staged = stageScannerRelevantPackedFiles(plugin.packageDir, packedFiles);
let scanFindingCount = 0;
const staged = stageScannerRelevantPluginTarballFiles(plugin.tarballPath);
try {
if (
staged.inspection.packageManifest.name !== plugin.packageName ||
staged.inspection.packageManifest.version !== plugin.packageVersion ||
staged.inspection.tarballSha256 !== plugin.tarballSha256
) {
throw new Error(`${plugin.packageName}: immutable plugin tarball identity mismatch.`);
}
for (const packedFile of staged.packedFiles) {
expectedReviewedCriticalFindings.push(
...expectedOptionalReviewedFindingsForPackedPath(plugin.packageName, packedFile),
);
}
const summary = await scanDirectoryWithSummary(staged.stageDir, {
excludeTestFiles: false,
maxFileBytes: MAX_SCANNABLE_FILE_BYTES,
maxFiles: MAX_SCANNABLE_FILES_PER_PACKAGE,
});
assertCompleteScannerSummary(plugin.packageName, summary);
if (summary.findings.length > MAX_PLUGIN_SCAN_FINDINGS_PER_PACKAGE) {
throw new Error(`${plugin.packageName}: security scan exceeded the finding-count limit.`);
}
scanFindingCount = summary.findings.length;
if (summary.scannedFiles !== staged.fileCount) {
throw new Error(
`${plugin.packageName}: security scan processed ${summary.scannedFiles} of ${staged.fileCount} staged files.`,
@@ -466,10 +824,13 @@ async function scanPublishablePluginPackage(
return {
expectedReviewedCriticalFindings: sortStrings(expectedReviewedCriticalFindings),
packageName: plugin.packageName,
packedFileCount: staged.packedFileCount,
packageVersion: plugin.packageVersion,
packedFileCount: staged.inspection.inventory.filter((entry) => entry.type === "file").length,
reviewedCriticalFindings: sortStrings(reviewedCriticalFindings),
scanFindingCount,
tarballSha256: plugin.tarballSha256,
unexpectedCriticalFindings: unexpectedCriticalFindings.toSorted((left, right) =>
JSON.stringify(left).localeCompare(JSON.stringify(right)),
compareCodeUnits(JSON.stringify(left), JSON.stringify(right)),
),
};
}
@@ -485,15 +846,23 @@ function expectedRequiredFindingsForPackage(
export function buildPluginNpmSecurityScanReport(params: {
candidateSha: string;
maxTotalFindings?: number;
packageResults: ScanPackageResult[];
scanErrors?: readonly string[];
toolingSha: string;
}): PluginNpmSecurityScanReport {
const { candidateSha, packageResults, toolingSha } = params;
const allReviewedFindings = packageResults.flatMap((result) => result.reviewedCriticalFindings);
const totalFindingCount = packageResults.reduce(
(total, result) => total + result.scanFindingCount,
0,
);
const layout = resolveReviewedSourceLayout(allReviewedFindings);
const errors: string[] = sortStrings(params.scanErrors ?? []);
if (totalFindingCount > (params.maxTotalFindings ?? MAX_PLUGIN_SCAN_TOTAL_FINDINGS)) {
errors.push("Plugin npm security scan exceeded the total finding-count limit.");
}
if (!layout) {
errors.push("Reviewed critical findings do not match exactly one supported release layout.");
}
@@ -546,10 +915,10 @@ export function buildPluginNpmSecurityScanReport(params: {
expectedReviewedCriticalFindings: sortStrings(result.expectedReviewedCriticalFindings),
reviewedCriticalFindings: sortStrings(result.reviewedCriticalFindings),
unexpectedCriticalFindings: result.unexpectedCriticalFindings.toSorted((left, right) =>
JSON.stringify(left).localeCompare(JSON.stringify(right)),
compareCodeUnits(JSON.stringify(left), JSON.stringify(right)),
),
}))
.toSorted((left, right) => left.packageName.localeCompare(right.packageName));
.toSorted((left, right) => compareCodeUnits(left.packageName, right.packageName));
return {
candidateSha,
errors: sortStrings(errors),
@@ -558,6 +927,7 @@ export function buildPluginNpmSecurityScanReport(params: {
schemaVersion: 1,
status: errors.length === 0 ? "pass" : "fail",
summary: {
findingCount: totalFindingCount,
packageCount: packageResults.length,
reviewedCriticalFindingCount: allReviewedFindings.length,
unexpectedCriticalFindingCount,
@@ -566,7 +936,27 @@ export function buildPluginNpmSecurityScanReport(params: {
};
}
function sanitizePackageScanError(plugin: PublishablePluginPackage, error: unknown): string {
export function constrainPluginNpmSecurityScanReport(
report: PluginNpmSecurityScanReport,
maxBytes = MAX_PLUGIN_SCAN_REPORT_BYTES,
): PluginNpmSecurityScanReport {
const serializedBytes = Buffer.byteLength(`${JSON.stringify(report)}\n`, "utf8");
if (serializedBytes <= maxBytes) {
return report;
}
return {
candidateSha: report.candidateSha,
errors: ["Plugin npm security scan report exceeded the byte limit."],
layout: null,
packages: [],
schemaVersion: 1,
status: "fail",
summary: report.summary,
toolingSha: report.toolingSha,
};
}
function sanitizePackageScanError(plugin: PluginNpmSecurityArtifact, error: unknown): string {
let message = error instanceof Error ? error.message : "Unknown package scan failure.";
for (const [path, replacement] of [
[plugin.packageDir, "<candidate-package>"],
@@ -581,7 +971,7 @@ function sanitizePackageScanError(plugin: PublishablePluginPackage, error: unkno
}
export async function scanPublishablePluginPackages(
packages: readonly PublishablePluginPackage[],
packages: readonly PluginNpmSecurityArtifact[],
): Promise<{ packageResults: ScanPackageResult[]; scanErrors: string[] }> {
const scanErrors: string[] = [];
const { results } = await runTasksWithConcurrency({
@@ -593,7 +983,7 @@ export async function scanPublishablePluginPackages(
plugin ? sanitizePackageScanError(plugin, error) : "Unknown package: package scan failed.",
);
},
tasks: packages.map((plugin) => () => scanPublishablePluginPackage(plugin)),
tasks: packages.map((plugin) => () => scanPublishablePluginArtifact(plugin)),
});
return {
packageResults: results.filter((result): result is ScanPackageResult => result !== undefined),
@@ -602,21 +992,30 @@ export async function scanPublishablePluginPackages(
}
export async function runPluginNpmSecurityScan(params: {
candidateDir: string;
artifactRoot: string;
candidateSha: string;
expectedPackages: unknown;
toolingDir: string;
toolingSha: string;
}): Promise<PluginNpmSecurityScanReport> {
const candidateDir = realpathSync(params.candidateDir);
const toolingDir = realpathSync(params.toolingDir);
const [candidateSha, toolingSha, packages] = await Promise.all([
gitOutput(candidateDir, ["rev-parse", "HEAD"]),
gitOutput(toolingDir, ["rev-parse", "HEAD"]),
listPublishablePluginPackages(candidateDir),
]);
const { packageResults, scanErrors } = await scanPublishablePluginPackages(packages);
return buildPluginNpmSecurityScanReport({
candidateSha,
packageResults,
scanErrors,
const toolingSha = await gitOutput(toolingDir, ["rev-parse", "HEAD"]);
if (toolingSha !== params.toolingSha) {
throw new Error("Trusted scanner tooling checkout differs from the expected commit.");
}
const packages = listPluginNpmSecurityArtifacts({
artifactRoot: params.artifactRoot,
candidateSha: params.candidateSha,
expectedPackages: params.expectedPackages,
toolingSha,
});
const { packageResults, scanErrors } = await scanPublishablePluginPackages(packages);
return constrainPluginNpmSecurityScanReport(
buildPluginNpmSecurityScanReport({
candidateSha: params.candidateSha,
packageResults,
scanErrors,
toolingSha,
}),
);
}
+257
View File
@@ -0,0 +1,257 @@
import { execFileSync, spawnSync } from "node:child_process";
import {
appendFileSync,
existsSync,
mkdirSync,
readdirSync,
realpathSync,
writeFileSync,
} from "node:fs";
import { basename, join, relative, resolve, sep } from "node:path";
import { pathToFileURL } from "node:url";
import { resolveNpmJsonEntries } from "./lib/npm-json-output.mts";
import {
collectNpmPackedFiles,
listPublishablePluginPackages,
type PublishablePluginPackage,
} from "./lib/plugin-npm-security-scan.mts";
import {
inspectPackageTarballBytes,
readBoundedRegularFile,
} from "./plugin-publication-artifact.mjs";
const MAX_PACK_STDOUT_BYTES = 8 * 1024 * 1024;
const MAX_TARBALL_BYTES = 128 * 1024 * 1024;
const PACK_TIMEOUT_MS = 20 * 60 * 1000;
type ParsedArgs = {
candidateRoot: string;
candidateSha: string;
command: "plan" | "prepare";
extensionId: string;
githubOutput: string;
outputDir: string;
packageDir: string;
packageName: string;
toolingSha: string;
};
function parseArgs(argv: string[]): ParsedArgs {
const command = argv[0];
if (command !== "plan" && command !== "prepare") {
throw new Error("Expected plugin npm security prepare command: plan or prepare.");
}
const values = new Map<string, string>();
for (let index = 1; 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 npm security prepare argument near ${String(name)}.`);
}
values.set(name, value);
}
const candidateRoot = values.get("--candidate-root") ?? "";
if (!candidateRoot) {
throw new Error("--candidate-root is required.");
}
const parsed: ParsedArgs = {
candidateRoot: resolve(candidateRoot),
candidateSha: values.get("--candidate-sha") ?? "",
command,
extensionId: values.get("--extension-id") ?? "",
githubOutput: values.get("--github-output") ?? "",
outputDir: values.get("--output-dir") ?? "",
packageDir: values.get("--package-dir") ?? "",
packageName: values.get("--package-name") ?? "",
toolingSha: values.get("--tooling-sha") ?? "",
};
if (command === "plan") {
if (!parsed.githubOutput) {
throw new Error("plan requires --github-output.");
}
return parsed;
}
if (
!/^[0-9a-f]{40}$/u.test(parsed.candidateSha) ||
!/^[0-9a-f]{40}$/u.test(parsed.toolingSha) ||
!/^[a-z0-9][a-z0-9._-]*$/u.test(parsed.extensionId) ||
parsed.packageDir !== `extensions/${parsed.extensionId}` ||
!parsed.packageName ||
!parsed.outputDir
) {
throw new Error("prepare received an invalid package or commit identity.");
}
parsed.outputDir = resolve(parsed.outputDir);
return parsed;
}
function relativePackage(plugin: PublishablePluginPackage, candidateRoot: string) {
const packageDir = relative(candidateRoot, plugin.packageDir).split(sep).join("/");
if (packageDir !== `extensions/${plugin.extensionId}`) {
throw new Error(`${plugin.packageName}: package directory escaped the candidate checkout.`);
}
return {
extensionId: plugin.extensionId,
packageDir,
packageName: plugin.packageName,
packageVersion: plugin.packageVersion,
};
}
async function planPackages(args: ParsedArgs): Promise<void> {
const candidateRoot = realpathSync(args.candidateRoot);
const packages = (await listPublishablePluginPackages(candidateRoot)).map((plugin) =>
relativePackage(plugin, candidateRoot),
);
const matrix = {
include: packages.map((plugin) => ({
extension_id: plugin.extensionId,
package_dir: plugin.packageDir,
package_name: plugin.packageName,
})),
};
appendFileSync(args.githubOutput, `matrix=${JSON.stringify(matrix)}\n`, "utf8");
appendFileSync(args.githubOutput, `packages_json=${JSON.stringify(packages)}\n`, "utf8");
}
function gitSha(root: string): string {
return execFileSync("git", ["-C", root, "rev-parse", "HEAD"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
}
function parsePackOutput(stdout: string): Array<Record<string, unknown>> {
const raw = stdout.trim();
for (let index = raw.length - 1; index >= 0; index -= 1) {
if (raw[index] !== "[" && raw[index] !== "{") {
continue;
}
try {
const entries = resolveNpmJsonEntries(JSON.parse(raw.slice(index)));
if (entries.length > 0) {
return entries as Array<Record<string, unknown>>;
}
} catch {
// npm can print bundled dependency diagnostics before its JSON result.
}
}
throw new Error("Trusted plugin packaging did not emit npm pack JSON.");
}
async function preparePackage(args: ParsedArgs): Promise<void> {
const toolingRoot = realpathSync(process.cwd());
const candidateRoot = realpathSync(args.candidateRoot);
if (gitSha(toolingRoot) !== args.toolingSha || gitSha(candidateRoot) !== args.candidateSha) {
throw new Error("Plugin packaging checkout identity differs from the trusted plan.");
}
const packages = await listPublishablePluginPackages(candidateRoot);
const selected = packages.find(
(plugin) =>
plugin.extensionId === args.extensionId &&
plugin.packageName === args.packageName &&
relativePackage(plugin, candidateRoot).packageDir === args.packageDir,
);
if (!selected) {
throw new Error("Selected plugin package is absent from the trusted package plan.");
}
await collectNpmPackedFiles(selected.packageDir, selected.packageName);
if (existsSync(args.outputDir)) {
if (readdirSync(args.outputDir).length !== 0) {
throw new Error("Plugin security artifact output directory must be empty.");
}
} else {
mkdirSync(args.outputDir, { recursive: true });
}
const result = spawnSync(
"bash",
[
join(toolingRoot, "scripts/plugin-npm-publish.sh"),
"--repo-root",
candidateRoot,
"--pack",
args.packageDir,
],
{
cwd: toolingRoot,
encoding: "utf8",
env: {
...process.env,
OPENCLAW_PLUGIN_NPM_PACK_OUTPUT_DIR: args.outputDir,
},
killSignal: "SIGKILL",
maxBuffer: MAX_PACK_STDOUT_BYTES,
stdio: ["ignore", "pipe", "inherit"],
timeout: PACK_TIMEOUT_MS,
},
);
if (result.status !== 0 || result.signal || result.error) {
throw new Error(`${selected.packageName}: trusted plugin packaging failed.`);
}
const packEntries = parsePackOutput(result.stdout);
if (packEntries.length !== 1) {
throw new Error(`${selected.packageName}: npm pack returned an invalid result count.`);
}
const tarballName = packEntries[0]?.filename;
if (
typeof tarballName !== "string" ||
!/^[A-Za-z0-9][A-Za-z0-9._-]*\.tgz$/u.test(tarballName) ||
basename(tarballName) !== tarballName
) {
throw new Error(`${selected.packageName}: npm pack returned an unsafe tarball name.`);
}
const tarballPath = join(args.outputDir, tarballName);
const tarballBytes = readBoundedRegularFile(tarballPath, {
label: "Prepared plugin tarball",
maxBytes: MAX_TARBALL_BYTES,
});
const inspection = inspectPackageTarballBytes(tarballBytes, {
maxArchiveBytes: MAX_TARBALL_BYTES,
});
if (
inspection.packageManifest.name !== selected.packageName ||
inspection.packageManifest.version !== selected.packageVersion
) {
throw new Error(`${selected.packageName}: prepared tarball identity mismatch.`);
}
const artifactEntries = readdirSync(args.outputDir);
if (artifactEntries.length !== 1 || artifactEntries[0] !== tarballName) {
throw new Error(`${selected.packageName}: packaging produced unexpected artifact files.`);
}
const metadata = {
candidateSha: args.candidateSha,
extensionId: selected.extensionId,
packageDir: args.packageDir,
packageName: selected.packageName,
packageVersion: selected.packageVersion,
schemaVersion: 1,
tarballName,
tarballSha256: inspection.tarballSha256,
toolingSha: args.toolingSha,
};
writeFileSync(
join(args.outputDir, "plugin-npm-security-artifact.json"),
`${JSON.stringify(metadata, null, 2)}\n`,
{ encoding: "utf8", mode: 0o600 },
);
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
if (args.command === "plan") {
await planPackages(args);
} else {
await preparePackage(args);
}
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
try {
await main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}
+212
View File
@@ -0,0 +1,212 @@
import { spawn } from "node:child_process";
import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const DEFAULT_HEAP_MB = 768;
const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
const MAX_CAPTURE_BYTES = 512 * 1024;
const MAX_REPORT_BYTES = 1024 * 1024;
const SCANNER_PATH = fileURLToPath(new URL("./plugin-npm-security-scan.mts", import.meta.url));
function parseArgs(argv) {
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 npm security runner argument near ${String(name)}.`);
}
values.set(name, value);
}
const artifactRoot = values.get("--artifact-root") ?? "";
const candidateSha = values.get("--candidate-sha") ?? "";
const report = values.get("--report") ?? "";
const toolingSha = values.get("--tooling-sha") ?? "";
if (
!artifactRoot ||
!/^[0-9a-f]{40}$/u.test(candidateSha) ||
!report ||
!/^[0-9a-f]{40}$/u.test(toolingSha)
) {
throw new Error("Plugin npm security runner received an invalid identity or path.");
}
return {
artifactRoot: path.resolve(artifactRoot),
candidateSha,
report: path.resolve(report),
toolingSha,
};
}
function testOverride(name, fallback) {
if (process.env.NODE_ENV !== "test") {
return fallback;
}
return process.env[name] || fallback;
}
function boundedAppend(current, chunk) {
if (current.length >= MAX_CAPTURE_BYTES) {
return current;
}
return Buffer.concat([current, chunk]).subarray(0, MAX_CAPTURE_BYTES);
}
function sanitizeOutput(value, args) {
let output = value.toString("utf8");
for (const [source, replacement] of [
[args.artifactRoot, "<artifacts>"],
[path.dirname(args.report), "<report-dir>"],
[process.cwd(), "<tooling>"],
]) {
output = output.replaceAll(source, replacement);
}
return output
.replaceAll(/\/(?:private\/)?tmp\/openclaw-plugin-npm-scan-[^/\s:]+/gu, "<scanner-stage>")
.replaceAll(/(^|[\s:(])\/[^ \t\n\r:,)\]}]+/gu, "$1<path>");
}
function compactFailureReport(args, category) {
return {
candidateSha: args.candidateSha,
errors: [`Plugin npm security scanner ${category}.`],
layout: null,
packages: [],
schemaVersion: 1,
status: "fail",
summary: {
findingCount: 0,
packageCount: 0,
reviewedCriticalFindingCount: 0,
unexpectedCriticalFindingCount: 0,
},
toolingSha: args.toolingSha,
};
}
function writeFailureReport(args, category) {
mkdirSync(path.dirname(args.report), { recursive: true });
writeFileSync(args.report, `${JSON.stringify(compactFailureReport(args, category))}\n`, {
encoding: "utf8",
mode: 0o600,
});
}
function existingReportStatus(args) {
if (!existsSync(args.report)) {
return null;
}
const stat = lstatSync(args.report);
if (!stat.isFile() || stat.size === 0 || stat.size > MAX_REPORT_BYTES) {
return null;
}
try {
const report = JSON.parse(readFileSync(args.report, "utf8"));
const valid =
report?.candidateSha === args.candidateSha &&
Array.isArray(report?.errors) &&
Array.isArray(report?.packages) &&
(report?.status === "pass" || report?.status === "fail") &&
typeof report?.summary === "object" &&
report?.toolingSha === args.toolingSha &&
report?.schemaVersion === 1;
return valid ? report.status : null;
} catch {
return null;
}
}
async function run(argv) {
const args = parseArgs(argv);
const scannerPath = testOverride("OPENCLAW_PLUGIN_SECURITY_RUNNER_CHILD", SCANNER_PATH);
const heapMb = Number(testOverride("OPENCLAW_PLUGIN_SECURITY_RUNNER_HEAP_MB", DEFAULT_HEAP_MB));
const timeoutMs = Number(
testOverride("OPENCLAW_PLUGIN_SECURITY_RUNNER_TIMEOUT_MS", DEFAULT_TIMEOUT_MS),
);
if (
!Number.isSafeInteger(heapMb) ||
heapMb < 16 ||
heapMb > 4096 ||
!Number.isSafeInteger(timeoutMs) ||
timeoutMs < 10 ||
timeoutMs > DEFAULT_TIMEOUT_MS
) {
throw new Error("Plugin npm security runner limits are invalid.");
}
let stdout = Buffer.alloc(0);
let stderr = Buffer.alloc(0);
let timedOut = false;
const child = spawn(
process.execPath,
[`--max-old-space-size=${heapMb}`, "--import", "tsx", scannerPath, ...argv],
{
cwd: process.cwd(),
detached: process.platform !== "win32",
env: {
CI: "1",
HOME: process.env.HOME,
NODE_ENV: process.env.NODE_ENV,
PATH: process.env.PATH,
},
stdio: ["ignore", "pipe", "pipe"],
},
);
child.stdout.on("data", (chunk) => {
stdout = boundedAppend(stdout, chunk);
});
child.stderr.on("data", (chunk) => {
stderr = boundedAppend(stderr, chunk);
});
const timer = setTimeout(() => {
timedOut = true;
if (child.pid) {
try {
process.kill(process.platform === "win32" ? child.pid : -child.pid, "SIGKILL");
} catch {}
}
}, timeoutMs);
const result = await new Promise((resolve) => {
child.on("error", (error) => resolve({ error, status: null }));
child.on("close", (status, signal) => resolve({ error: undefined, signal, status }));
});
clearTimeout(timer);
const safeStdout = sanitizeOutput(stdout, args);
const safeStderr = sanitizeOutput(stderr, args);
if (safeStdout) {
process.stdout.write(safeStdout);
}
if (safeStderr) {
process.stderr.write(safeStderr);
}
if (timedOut) {
writeFailureReport(args, "timed out");
return 1;
}
if (result.error) {
writeFailureReport(args, "could not start");
return 1;
}
const reportStatus = existingReportStatus(args);
if (!reportStatus) {
writeFailureReport(
args,
result.signal
? "exceeded its process limit"
: existsSync(args.report)
? "wrote an invalid report"
: "did not write a report",
);
return 1;
}
return result.status === 0 && reportStatus === "pass" ? 0 : 1;
}
try {
process.exitCode = await run(process.argv.slice(2));
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
+74 -67
View File
@@ -2,102 +2,122 @@ import { mkdir, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import {
constrainPluginNpmSecurityScanReport,
MAX_PUBLISHABLE_PLUGIN_PACKAGES,
runPluginNpmSecurityScan,
type PluginNpmSecurityScanReport,
} from "./lib/plugin-npm-security-scan.mts";
function parseArgs(argv: string[]): {
candidateRoot: string;
const MAX_EXPECTED_PACKAGES_JSON_BYTES = 256 * 1024;
type ParsedArgs = {
artifactRoot: string;
candidateSha: string;
expectedPackages: unknown;
outputPath: string;
toolingSha: string;
} {
let candidateRoot = "";
let candidateSha = "";
let toolingSha = "";
let outputPath = "";
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--candidate-root") {
candidateRoot = argv[index + 1] ?? "";
index += 1;
} else if (arg === "--candidate-sha") {
candidateSha = argv[index + 1] ?? "";
index += 1;
} else if (arg === "--tooling-sha") {
toolingSha = argv[index + 1] ?? "";
index += 1;
} else if (arg === "--report") {
outputPath = argv[index + 1] ?? "";
index += 1;
} else {
throw new Error(`Unknown argument: ${arg}`);
};
function parseArgs(argv: string[]): ParsedArgs {
const values = new Map<string, string>();
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 npm security scan argument near ${String(name)}.`);
}
values.set(name, value);
}
if (!candidateRoot) {
throw new Error("--candidate-root is required");
const artifactRoot = values.get("--artifact-root") ?? "";
const candidateSha = values.get("--candidate-sha") ?? "";
const expectedPackagesJson = values.get("--expected-packages-json") ?? "";
const outputPath = values.get("--report") ?? "";
const toolingSha = values.get("--tooling-sha") ?? "";
if (!artifactRoot) {
throw new Error("--artifact-root is required.");
}
if (!/^[0-9a-f]{40}$/u.test(candidateSha)) {
throw new Error("--candidate-sha must be a full lowercase commit SHA");
throw new Error("--candidate-sha must be a full lowercase commit SHA.");
}
if (!/^[0-9a-f]{40}$/u.test(toolingSha)) {
throw new Error("--tooling-sha must be a full lowercase commit SHA");
throw new Error("--tooling-sha must be a full lowercase commit SHA.");
}
if (
!expectedPackagesJson ||
Buffer.byteLength(expectedPackagesJson, "utf8") > MAX_EXPECTED_PACKAGES_JSON_BYTES
) {
throw new Error("--expected-packages-json is outside the byte limit.");
}
if (!outputPath) {
throw new Error("--report is required");
throw new Error("--report is required.");
}
const expectedPackages = JSON.parse(expectedPackagesJson) as unknown;
if (
!Array.isArray(expectedPackages) ||
expectedPackages.length > MAX_PUBLISHABLE_PLUGIN_PACKAGES
) {
throw new Error("--expected-packages-json is not a bounded package inventory.");
}
return {
candidateRoot: resolve(candidateRoot),
artifactRoot: resolve(artifactRoot),
candidateSha,
expectedPackages,
outputPath: resolve(outputPath),
toolingSha,
};
}
async function writeReport(outputPath: string, report: PluginNpmSecurityScanReport): Promise<void> {
const constrained = constrainPluginNpmSecurityScanReport(report);
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
await writeFile(outputPath, `${JSON.stringify(constrained)}\n`, "utf8");
}
function sanitizeErrorMessage(
error: unknown,
args: ReturnType<typeof parseArgs> | undefined,
): string {
function sanitizeErrorMessage(error: unknown, args: ParsedArgs | undefined): string {
let message = error instanceof Error ? error.message : String(error);
for (const [path, replacement] of [
[args?.candidateRoot, "<candidate>"],
[args?.artifactRoot, "<artifacts>"],
[args?.outputPath ? dirname(args.outputPath) : undefined, "<report-dir>"],
[process.cwd(), "<tooling>"],
] as const) {
if (path) {
message = message.replaceAll(path, replacement);
}
}
return message.replaceAll(
/\/(?:private\/)?tmp\/openclaw-plugin-npm-scan-[^/\s:]+/gu,
"<scanner-stage>",
);
return message
.replaceAll(/\/(?:private\/)?tmp\/openclaw-plugin-npm-scan-[^/\s:]+/gu, "<scanner-stage>")
.replaceAll(/(^|[\s:(])\/[^ \t\n\r:,)\]}]+/gu, "$1<path>");
}
function failureReport(args: ParsedArgs, message: string): PluginNpmSecurityScanReport {
return {
candidateSha: args.candidateSha,
errors: [message],
layout: null,
packages: [],
schemaVersion: 1,
status: "fail",
summary: {
findingCount: 0,
packageCount: 0,
reviewedCriticalFindingCount: 0,
unexpectedCriticalFindingCount: 0,
},
toolingSha: args.toolingSha,
};
}
async function main(argv = process.argv.slice(2)): Promise<number> {
let args: ReturnType<typeof parseArgs> | undefined;
let args: ParsedArgs | undefined;
try {
args = parseArgs(argv);
const report = await runPluginNpmSecurityScan({
candidateDir: args.candidateRoot,
artifactRoot: args.artifactRoot,
candidateSha: args.candidateSha,
expectedPackages: args.expectedPackages,
toolingDir: process.cwd(),
toolingSha: args.toolingSha,
});
if (report.candidateSha !== args.candidateSha) {
report.errors.push(
`Candidate checkout resolved to ${report.candidateSha}, expected ${args.candidateSha}.`,
);
report.status = "fail";
}
if (report.toolingSha !== args.toolingSha) {
report.errors.push(
`Tooling checkout resolved to ${report.toolingSha}, expected ${args.toolingSha}.`,
);
report.status = "fail";
}
await writeReport(args.outputPath, report);
console.log(
`Plugin npm security scan ${report.status}: ${report.summary.packageCount} packages, layout=${report.layout ?? "unknown"}, candidate=${report.candidateSha}, tooling=${report.toolingSha}`,
@@ -110,20 +130,7 @@ async function main(argv = process.argv.slice(2)): Promise<number> {
const message = sanitizeErrorMessage(error, args);
console.error(`Plugin npm security scan failed: ${message}`);
if (args) {
await writeReport(args.outputPath, {
candidateSha: args.candidateSha,
errors: [message],
layout: null,
packages: [],
schemaVersion: 1,
status: "fail",
summary: {
packageCount: 0,
reviewedCriticalFindingCount: 0,
unexpectedCriticalFindingCount: 0,
},
toolingSha: args.toolingSha,
});
await writeReport(args.outputPath, failureReport(args, message));
}
return 1;
}
+5
View File
@@ -442,6 +442,10 @@ export function inspectPackageTarballBytes(inputBytes, options = {}) {
if (!(inputBytes instanceof Uint8Array)) {
throw new Error("Plugin tarball bytes must be a Uint8Array.");
}
const onFile = options.onFile;
if (onFile !== undefined && typeof onFile !== "function") {
throw new Error("Plugin tarball onFile option must be a function.");
}
const tarballBytes = Buffer.from(inputBytes.buffer, inputBytes.byteOffset, inputBytes.byteLength);
const limits = normalizeTarInspectionOptions(options);
if (tarballBytes.length === 0 || tarballBytes.length > limits.maxArchiveBytes) {
@@ -585,6 +589,7 @@ export function inspectPackageTarballBytes(inputBytes, options = {}) {
type: "file",
};
inventory.push(entry);
onFile?.({ content, path: safePath });
if (safePath === "package/package.json") {
if (content.length === 0 || content.length > MAX_MANIFEST_BYTES) {
throw new Error(
+371 -192
View File
@@ -1,32 +1,167 @@
import { spawnSync } from "node:child_process";
import { existsSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
import { execFileSync, spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
assertCanonicalNpmPackageName,
assertCompleteScannerSummary,
buildPluginNpmSecurityScanReport,
collectNpmPackedFiles,
constrainPluginNpmSecurityScanReport,
listPluginNpmSecurityArtifacts,
listPublishablePluginPackages,
normalizePackedFindingPath,
parsePacklistFiles,
resolveReviewedSourceLayout,
runPluginNpmSecurityScan,
scanPublishablePluginPackages,
stageScannerRelevantPackedFiles,
stageScannerRelevantPluginTarballFiles,
type PublishablePluginPackage,
type ScanPackageResult,
} from "../../scripts/lib/plugin-npm-security-scan.mts";
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
const CANDIDATE_SHA = "1".repeat(40);
const TOOLING_SHA = "2".repeat(40);
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
function initGitRepo(root: string): void {
execFileSync("git", ["init", "--quiet", root]);
execFileSync("git", ["-C", root, "config", "user.email", "test@example.invalid"]);
execFileSync("git", ["-C", root, "config", "user.name", "OpenClaw Test"]);
}
function writePublishableManifest(
root: string,
extensionId: string,
packageName: string,
extra: Record<string, unknown> = {},
): void {
const packageDir = join(root, "extensions", extensionId);
mkdirSync(packageDir, { recursive: true });
writeFileSync(
join(packageDir, "package.json"),
`${JSON.stringify({
name: packageName,
openclaw: { release: { publishToNpm: true } },
version: "1.0.0",
...extra,
})}\n`,
"utf8",
);
execFileSync("git", ["-C", root, "add", `extensions/${extensionId}/package.json`]);
}
function writePluginArtifact(params: {
extensionId: string;
files: Record<string, string | Buffer>;
packageName: string;
version?: string;
}) {
const root = tempDirs.make("openclaw-plugin-npm-security-artifact-");
const packageRoot = join(root, "source");
const artifactDir = join(root, "artifacts", params.extensionId);
const packageVersion = params.version ?? "1.0.0";
mkdirSync(packageRoot, { recursive: true });
mkdirSync(artifactDir, { recursive: true });
writeFileSync(
join(packageRoot, "package.json"),
`${JSON.stringify({ name: params.packageName, version: packageVersion })}\n`,
"utf8",
);
writeFileSync(
join(packageRoot, "openclaw.plugin.json"),
`${JSON.stringify({ id: params.extensionId })}\n`,
"utf8",
);
for (const [relativePath, content] of Object.entries(params.files)) {
const filePath = join(packageRoot, relativePath);
mkdirSync(join(filePath, ".."), { recursive: true });
writeFileSync(filePath, content);
}
const packOutput = execFileSync(
"npm",
["pack", "--json", "--ignore-scripts", "--pack-destination", artifactDir],
{ cwd: packageRoot, encoding: "utf8" },
);
const packEntries = JSON.parse(packOutput) as Array<{ filename?: unknown }>;
const tarballName = packEntries[0]?.filename;
if (typeof tarballName !== "string") {
throw new Error("npm pack fixture did not return a tarball filename");
}
const tarballPath = join(artifactDir, tarballName);
const tarballSha256 = createHash("sha256").update(readFileSync(tarballPath)).digest("hex");
writeFileSync(
join(artifactDir, "plugin-npm-security-artifact.json"),
`${JSON.stringify({
candidateSha: CANDIDATE_SHA,
extensionId: params.extensionId,
packageDir: `extensions/${params.extensionId}`,
packageName: params.packageName,
packageVersion,
schemaVersion: 1,
tarballName,
tarballSha256,
toolingSha: TOOLING_SHA,
})}\n`,
"utf8",
);
return {
artifact: {
artifactDir,
candidateSha: CANDIDATE_SHA,
extensionId: params.extensionId,
packageDir: `extensions/${params.extensionId}`,
packageName: params.packageName,
packageVersion,
tarballPath,
tarballSha256,
toolingSha: TOOLING_SHA,
},
artifactRoot: join(root, "artifacts"),
expectedPackage: {
extensionId: params.extensionId,
packageDir: `extensions/${params.extensionId}`,
packageName: params.packageName,
packageVersion,
} satisfies PublishablePluginPackage,
packageRoot,
tarballPath,
};
}
function currentLayoutFindings(): string[] {
return [
"@openclaw/codex:dangerous-exec:src/app-server/sandbox-exec-server/sandbox-child.ts",
"@openclaw/codex:dangerous-exec:src/app-server/transport-process-containment.ts",
...Array.from(
{ length: 13 },
() => "@openclaw/codex:dangerous-exec:src/app-server/transport.process.test.ts",
),
];
}
function syntheticResult(
packageName: string,
overrides: Partial<ScanPackageResult> = {},
): ScanPackageResult {
return {
expectedReviewedCriticalFindings: [],
packageName,
packageVersion: "1.0.0",
packedFileCount: 1,
reviewedCriticalFindings: [],
scanFindingCount: 0,
tarballSha256: "a".repeat(64),
unexpectedCriticalFindings: [],
...overrides,
};
}
describe("scripts/lib/plugin-npm-security-scan.mts", () => {
it("accepts only the complete current and frozen-legacy source layouts", () => {
const current = [
"@openclaw/codex:dangerous-exec:src/app-server/sandbox-exec-server/sandbox-child.ts",
"@openclaw/codex:dangerous-exec:src/app-server/transport-process-containment.ts",
...Array.from(
{ length: 13 },
() => "@openclaw/codex:dangerous-exec:src/app-server/transport.process.test.ts",
),
];
const current = currentLayoutFindings();
const frozenLegacy = [
"@openclaw/codex:dangerous-exec:src/app-server/sandbox-exec-server/http.ts",
"@openclaw/codex:dangerous-exec:src/app-server/sandbox-exec-server/processes.ts",
@@ -42,7 +177,7 @@ describe("scripts/lib/plugin-npm-security-scan.mts", () => {
expect(resolveReviewedSourceLayout([...current, current[0]!])).toBeUndefined();
});
it("collects candidate package files without running lifecycle scripts", async () => {
it("collects package files without running lifecycle or candidate replacement helpers", async () => {
const packageDir = tempDirs.make("openclaw-plugin-npm-security-pack-");
const replacementMarker = join(packageDir, "replacement-helper-ran");
const lifecycleMarkers = ["prepare", "prepack", "postpack"].map((name) =>
@@ -50,37 +185,34 @@ describe("scripts/lib/plugin-npm-security-scan.mts", () => {
);
writeFileSync(
join(packageDir, "package.json"),
`${JSON.stringify(
{
name: "@openclaw/test-inert-package",
version: "1.0.0",
scripts: {
prepare: `node -e "require('node:fs').writeFileSync(${JSON.stringify(
lifecycleMarkers[0],
)}, 'ran')"`,
prepack: `node -e "require('node:fs').writeFileSync(${JSON.stringify(
lifecycleMarkers[1],
)}, 'ran')"`,
postpack: `node -e "require('node:fs').writeFileSync(${JSON.stringify(
lifecycleMarkers[2],
)}, 'ran')"`,
},
},
null,
2,
)}\n`,
`${JSON.stringify({
name: "@openclaw/test-inert-package",
scripts: Object.fromEntries(
["prepare", "prepack", "postpack"].map((name, index) => [
name,
`node -e "require('node:fs').writeFileSync(${JSON.stringify(lifecycleMarkers[index])}, 'ran')"`,
]),
),
version: "1.0.0",
})}\n`,
"utf8",
);
writeFileSync(join(packageDir, "index.js"), "export const value = 1;\n", "utf8");
writeFileSync(
join(packageDir, "plugin-npm-pack-files.mjs"),
`import { writeFileSync } from "node:fs";\nwriteFileSync(${JSON.stringify(
replacementMarker,
)}, "ran");\n`,
`import { writeFileSync } from "node:fs";\nwriteFileSync(${JSON.stringify(replacementMarker)}, "ran");\n`,
"utf8",
);
const packedFiles = await collectNpmPackedFiles(packageDir, "@openclaw/test-inert-package");
const trustedHelper = join(tempDirs.make("openclaw-plugin-npm-security-helper-"), "helper.mjs");
writeFileSync(
trustedHelper,
'process.stdout.write(JSON.stringify(["index.js", "package.json", "plugin-npm-pack-files.mjs"]));\n',
"utf8",
);
const packedFiles = await collectNpmPackedFiles(packageDir, "@openclaw/test-inert-package", {
helperPath: trustedHelper,
});
expect(packedFiles).toContain("index.js");
expect(packedFiles).toContain("package.json");
@@ -112,40 +244,60 @@ describe("scripts/lib/plugin-npm-security-scan.mts", () => {
).toThrow("duplicate path");
expect(() =>
parsePacklistFiles(
JSON.stringify(Array.from({ length: 50_001 }, (_, index) => `file-${index}.js`)),
JSON.stringify(Array.from({ length: 20_001 }, (_, index) => `file-${index}.js`)),
"@openclaw/test",
),
).toThrow("file-count limit");
});
it("fails closed when the trusted packlist helper exceeds process limits", async () => {
it("preserves bounded packlist helper failure categories", async () => {
const root = tempDirs.make("openclaw-plugin-packlist-helper-limits-");
const packageDir = tempDirs.make("openclaw-plugin-packlist-helper-package-");
const timeoutHelper = join(root, "timeout.mjs");
const oomHelper = join(root, "oom.mjs");
const failedHelper = join(root, "failed.mjs");
writeFileSync(timeoutHelper, "await new Promise(() => {});\n", "utf8");
writeFileSync(
oomHelper,
"const values = [];\nwhile (true) values.push(new Array(100000).fill(Math.random()));\n",
"utf8",
);
writeFileSync(failedHelper, "process.exit(7);\n", "utf8");
await expect(
collectNpmPackedFiles(packageDir, "@openclaw/test-timeout", {
helperPath: timeoutHelper,
timeoutMs: 25,
}),
).rejects.toThrow("trusted packlist helper exceeded its resource limits");
).rejects.toThrow("trusted packlist helper timed out");
await expect(
collectNpmPackedFiles(packageDir, "@openclaw/test-oom", {
helperPath: oomHelper,
maxOldSpaceMb: 16,
timeoutMs: 10_000,
collectNpmPackedFiles(packageDir, "@openclaw/test-failed", {
helperPath: failedHelper,
}),
).rejects.toThrow("trusted packlist helper exceeded its resource limits");
}, 15_000);
).rejects.toThrow("trusted packlist helper failed");
});
it("fails closed on truncated scans and packed-file path escapes", () => {
it("bounds manifests and rejects noncanonical or duplicate package identities", async () => {
expect(() => assertCanonicalNpmPackageName("OpenClaw/Bad", "fixture")).toThrow(
"invalid npm package name",
);
const duplicateRoot = tempDirs.make("openclaw-plugin-npm-security-duplicates-");
initGitRepo(duplicateRoot);
writePublishableManifest(duplicateRoot, "one", "@openclaw/duplicate");
writePublishableManifest(duplicateRoot, "two", "@openclaw/duplicate");
await expect(listPublishablePluginPackages(duplicateRoot)).rejects.toThrow(
"duplicate publishable package",
);
await expect(
listPublishablePluginPackages(duplicateRoot, { maxPackageManifests: 1 }),
).rejects.toThrow("package-count limit");
const manifestRoot = tempDirs.make("openclaw-plugin-npm-security-manifest-");
initGitRepo(manifestRoot);
writePublishableManifest(manifestRoot, "large", "@openclaw/large", {
description: "x".repeat(1024),
});
await expect(
listPublishablePluginPackages(manifestRoot, { maxManifestBytes: 256 }),
).rejects.toThrow("manifest exceeds the byte limit");
});
it("fails closed on truncated scans, source escapes, and tarball symlinks", () => {
expect(() => assertCompleteScannerSummary("@openclaw/test", { truncated: true })).toThrow(
"security scan reached its file limit",
);
@@ -160,182 +312,209 @@ describe("scripts/lib/plugin-npm-security-scan.mts", () => {
const outsideFile = join(outsideDir, "outside.ts");
writeFileSync(outsideFile, "export const value = 1;\n", "utf8");
symlinkSync(outsideFile, join(packageDir, "escape.ts"));
expect(() => stageScannerRelevantPackedFiles(packageDir, ["escape.ts"])).toThrow(
"not a regular file",
);
const oversizeDir = tempDirs.make("openclaw-plugin-npm-security-oversize-");
writeFileSync(join(oversizeDir, "oversize.ts"), Buffer.alloc(1024 * 1024 + 1));
expect(() => stageScannerRelevantPackedFiles(oversizeDir, ["oversize.ts"])).toThrow(
"per-file byte limit",
const artifact = writePluginArtifact({
extensionId: "symlink",
files: { "index.js": "export const value = 1;\n" },
packageName: "@openclaw/test-symlink",
});
symlinkSync(outsideFile, join(artifact.packageRoot, "escape.ts"));
execFileSync(
"tar",
["-czf", artifact.tarballPath, "-C", join(artifact.packageRoot, ".."), "source"],
{ env: { ...process.env, COPYFILE_DISABLE: "1" } },
);
const boundedDir = tempDirs.make("openclaw-plugin-npm-security-bounds-");
writeFileSync(join(boundedDir, "one.ts"), "1", "utf8");
writeFileSync(join(boundedDir, "two.ts"), "22", "utf8");
expect(() =>
stageScannerRelevantPackedFiles(boundedDir, ["one.ts", "two.ts"], {
maxPackedFileBytes: 10,
maxPackedFiles: 10,
maxPackedTotalBytes: 10,
maxFileBytes: 10,
maxFiles: 1,
maxTotalBytes: 10,
}),
).toThrow("file-count limit");
expect(() =>
stageScannerRelevantPackedFiles(boundedDir, ["two.ts"], {
maxPackedFileBytes: 10,
maxPackedFiles: 10,
maxPackedTotalBytes: 10,
maxFileBytes: 10,
maxFiles: 10,
maxTotalBytes: 1,
}),
).toThrow("total-byte limit");
const assetDir = tempDirs.make("openclaw-plugin-npm-security-asset-bounds-");
writeFileSync(join(assetDir, "asset.bin"), Buffer.alloc(11));
expect(() =>
stageScannerRelevantPackedFiles(assetDir, ["asset.bin"], {
maxPackedFileBytes: 20,
maxPackedFiles: 10,
maxPackedTotalBytes: 10,
maxFileBytes: 10,
maxFiles: 10,
maxTotalBytes: 10,
}),
).toThrow("Packed input exceeds the total-byte limit");
expect(() => stageScannerRelevantPluginTarballFiles(artifact.tarballPath)).toThrow();
});
it("normalizes only exact bundler hash filenames", () => {
it("accounts for all packed bytes and scans only exact bundler hash filenames", () => {
const artifact = writePluginArtifact({
extensionId: "packed",
files: {
"asset.bin": Buffer.alloc(128),
"dist/service-BaCqPs_5.js": "export const value = 1;\n",
"dist/service-malware.js": "export const value = 2;\n",
},
packageName: "@openclaw/test-packed",
});
const staged = stageScannerRelevantPluginTarballFiles(artifact.tarballPath);
try {
expect(staged.inspection.inventory.map((entry) => entry.path)).toContain("package/asset.bin");
expect(staged.packedFiles).toContain("asset.bin");
} finally {
rmSync(staged.stageDir, { force: true, recursive: true });
}
expect(normalizePackedFindingPath("dist/service-BaCqPs_5.js")).toBe("dist/service-<hash>.js");
expect(normalizePackedFindingPath("dist/service-malware.js")).toBe("dist/service-malware.js");
});
it("retains expected SHAs and redacts candidate paths in failure reports", () => {
const root = tempDirs.make("openclaw-plugin-npm-security-failure-");
const candidateRoot = join(root, "missing-candidate");
const reportPath = join(root, "report.json");
const candidateSha = "1".repeat(40);
const toolingSha = "2".repeat(40);
const result = spawnSync(
process.execPath,
[
"--import",
"tsx",
"scripts/plugin-npm-security-scan.mts",
"--candidate-root",
candidateRoot,
"--candidate-sha",
candidateSha,
"--tooling-sha",
toolingSha,
"--report",
reportPath,
],
{ cwd: process.cwd(), encoding: "utf8" },
);
const report = JSON.parse(readFileSync(reportPath, "utf8")) as {
candidateSha: string;
toolingSha: string;
};
expect(result.status).toBe(1);
expect(report.candidateSha).toBe(candidateSha);
expect(report.toolingSha).toBe(toolingSha);
expect(JSON.stringify(report)).not.toContain(candidateRoot);
expect(result.stderr).not.toContain(candidateRoot);
});
it("finds malicious packed code and retains it when another package fails", async () => {
const maliciousDir = tempDirs.make("openclaw-plugin-npm-security-malicious-");
writeFileSync(
join(maliciousDir, "package.json"),
`${JSON.stringify({
files: ["index.js"],
name: "@openclaw/test-malicious",
version: "1.0.0",
})}\n`,
"utf8",
);
writeFileSync(
join(maliciousDir, "index.js"),
`const { execSync } = require("node:child_process");\nexecSync("id");\n`,
"utf8",
);
const oversizedDir = tempDirs.make("openclaw-plugin-npm-security-failing-");
writeFileSync(
join(oversizedDir, "package.json"),
`${JSON.stringify({
files: ["oversized.js"],
name: "@openclaw/test-oversized",
version: "1.0.0",
})}\n`,
"utf8",
);
writeFileSync(join(oversizedDir, "oversized.js"), Buffer.alloc(1024 * 1024 + 1));
it("finds malicious final-artifact code, ignores candidate scanner replacements, and fails slow", async () => {
const marker = join(tempDirs.make("openclaw-plugin-npm-security-marker-"), "ran");
const malicious = writePluginArtifact({
extensionId: "malicious",
files: {
"index.js": `const { execSync } = require("node:child_process");\nexecSync("id");\n`,
"scripts/plugin-npm-security-scan.mts": `require("node:fs").writeFileSync(${JSON.stringify(marker)}, "ran");\n`,
},
packageName: "@openclaw/test-malicious",
});
const oversized = writePluginArtifact({
extensionId: "oversized",
files: { "oversized.js": Buffer.alloc(1024 * 1024 + 1) },
packageName: "@openclaw/test-oversized",
});
const { packageResults, scanErrors } = await scanPublishablePluginPackages([
{ packageDir: oversizedDir, packageName: "@openclaw/test-oversized" },
{ packageDir: maliciousDir, packageName: "@openclaw/test-malicious" },
oversized.artifact,
malicious.artifact,
]);
expect(existsSync(marker)).toBe(false);
expect(packageResults).toHaveLength(1);
expect(packageResults[0]?.unexpectedCriticalFindings).toEqual([
{
line: 2,
path: "index.js",
ruleId: "dangerous-exec",
},
]);
expect(packageResults[0]?.unexpectedCriticalFindings).toContainEqual({
line: 2,
path: "index.js",
ruleId: "dangerous-exec",
});
expect(scanErrors).toHaveLength(1);
expect(scanErrors[0]).toContain("@openclaw/test-oversized");
expect(scanErrors[0]).not.toContain(oversizedDir);
expect(scanErrors[0]).not.toContain(oversized.artifact.tarballPath);
const report = buildPluginNpmSecurityScanReport({
candidateSha: "1".repeat(40),
candidateSha: CANDIDATE_SHA,
packageResults,
scanErrors,
toolingSha: "2".repeat(40),
toolingSha: TOOLING_SHA,
});
expect(report.status).toBe("fail");
expect(report.errors).toContainEqual(expect.stringContaining("unexpected critical findings"));
expect(report.errors).toContainEqual(expect.stringContaining("package scan failed"));
expect(JSON.stringify(report)).not.toContain("execSync");
});
it("validates immutable artifact identity and exact package plans", () => {
const artifact = writePluginArtifact({
extensionId: "identity",
files: { "index.js": "export const value = 1;\n" },
packageName: "@openclaw/test-identity",
});
expect(
listPluginNpmSecurityArtifacts({
artifactRoot: artifact.artifactRoot,
candidateSha: CANDIDATE_SHA,
expectedPackages: [artifact.expectedPackage],
toolingSha: TOOLING_SHA,
}).map((entry) => entry.packageName),
).toEqual(["@openclaw/test-identity"]);
expect(() =>
listPluginNpmSecurityArtifacts({
artifactRoot: artifact.artifactRoot,
candidateSha: CANDIDATE_SHA,
expectedPackages: [],
toolingSha: TOOLING_SHA,
}),
).toThrow("does not match the trusted package plan");
});
it("caps total findings and emits byte-identical bounded reports", () => {
const packageResults = [
syntheticResult("@openclaw/codex", {
reviewedCriticalFindings: currentLayoutFindings(),
scanFindingCount: 51,
}),
];
const report = buildPluginNpmSecurityScanReport({
candidateSha: CANDIDATE_SHA,
maxTotalFindings: 50,
packageResults,
toolingSha: TOOLING_SHA,
});
expect(report.errors).toContain(
"Plugin npm security scan exceeded the total finding-count limit.",
);
expect(JSON.stringify(report)).toBe(
JSON.stringify(
buildPluginNpmSecurityScanReport({
candidateSha: report.candidateSha,
candidateSha: CANDIDATE_SHA,
maxTotalFindings: 50,
packageResults: structuredClone(packageResults).reverse(),
scanErrors: structuredClone(scanErrors).reverse(),
toolingSha: report.toolingSha,
toolingSha: TOOLING_SHA,
}),
),
);
expect(constrainPluginNpmSecurityScanReport(report, 64).errors).toEqual([
"Plugin npm security scan report exceeded the byte limit.",
]);
});
it("scans the complete current-root publishable plugin inventory", async () => {
const report = await runPluginNpmSecurityScan({
candidateDir: process.cwd(),
toolingDir: process.cwd(),
});
it("writes sanitized exact-identity reports when the bounded scanner times out or OOMs", () => {
const root = tempDirs.make("openclaw-plugin-npm-security-runner-");
const timeoutChild = join(root, "timeout.mjs");
const oomChild = join(root, "oom.mjs");
writeFileSync(timeoutChild, "await new Promise(() => {});\n", "utf8");
writeFileSync(
oomChild,
"const values = [];\nwhile (true) values.push(new Array(100000).fill(Math.random()));\n",
"utf8",
);
for (const [label, child, timeoutMs] of [
["timeout", timeoutChild, "25"],
["oom", oomChild, "10000"],
] as const) {
const reportPath = join(root, `${label}.json`);
const result = spawnSync(
process.execPath,
[
"scripts/plugin-npm-security-scan-runner.mjs",
"--artifact-root",
join(root, "artifacts"),
"--candidate-sha",
CANDIDATE_SHA,
"--expected-packages-json",
"[]",
"--tooling-sha",
TOOLING_SHA,
"--report",
reportPath,
],
{
cwd: process.cwd(),
encoding: "utf8",
env: {
...process.env,
NODE_ENV: "test",
OPENCLAW_PLUGIN_SECURITY_RUNNER_CHILD: child,
OPENCLAW_PLUGIN_SECURITY_RUNNER_HEAP_MB: "16",
OPENCLAW_PLUGIN_SECURITY_RUNNER_TIMEOUT_MS: timeoutMs,
},
timeout: 15_000,
},
);
const report = JSON.parse(readFileSync(reportPath, "utf8")) as {
candidateSha: string;
toolingSha: string;
};
expect(result.status).toBe(1);
expect(report).toMatchObject({
candidateSha: CANDIDATE_SHA,
toolingSha: TOOLING_SHA,
});
expect(`${result.stdout}${result.stderr}${JSON.stringify(report)}`).not.toContain(root);
}
}, 30_000);
expect(report).toMatchObject({
layout: "current",
status: "pass",
summary: {
unexpectedCriticalFindingCount: 0,
},
});
expect(report.summary.packageCount).toBe(report.packages.length);
expect(report.summary.packageCount).toBeGreaterThan(0);
expect(
report.packages
.find((entry) => entry.packageName === "@openclaw/acpx")
?.reviewedCriticalFindings.some((finding) => finding.endsWith(".test.ts")),
).toBe(true);
}, 120_000);
it("retains the complete current-root publishable plugin inventory contract", async () => {
const packages = await listPublishablePluginPackages(process.cwd());
expect(packages.length).toBeGreaterThan(0);
expect(packages.map((plugin) => plugin.packageName)).toContain("@openclaw/acpx");
expect(new Set(packages.map((plugin) => plugin.packageName)).size).toBe(packages.length);
expect(packages).toEqual(
packages.toSorted((left, right) =>
left.packageName < right.packageName ? -1 : left.packageName > right.packageName ? 1 : 0,
),
);
});
});
@@ -1,6 +1,8 @@
// Plugin Prerelease Test Plan tests cover plugin prerelease test plan script behavior.
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { parse } from "yaml";
import { findLaneByName } from "../../scripts/lib/docker-e2e-plan.mts";
@@ -279,19 +281,25 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => {
it("keeps the trusted security scanner outside the candidate test process", () => {
const pluginWorkflow = readPluginPrereleaseWorkflow();
const pluginSource = readFileSync(".github/workflows/plugin-prerelease.yml", "utf8");
const resolver = pluginWorkflow.jobs["resolve-candidate"];
const securityPlan = pluginWorkflow.jobs["plugin-npm-security-plan"];
const securityPackage = pluginWorkflow.jobs["plugin-npm-security-package"];
const securityScan = pluginWorkflow.jobs["plugin-npm-security-scan"];
const nodeShard = pluginWorkflow.jobs["plugin-prerelease-node-shard"];
const trustedCheckout = securityScan.steps.find(
(step: WorkflowStep) => step.name === "Checkout trusted scanner tooling",
);
const candidateCheckout = securityScan.steps.find(
const candidateCheckout = securityPlan.steps.find(
(step: WorkflowStep) => step.name === "Checkout candidate as inert data",
);
const packageCandidateCheckout = securityPackage.steps.find(
(step: WorkflowStep) => step.name === "Checkout candidate package source",
);
const installDependencies = securityScan.steps.find(
(step: WorkflowStep) => step.name === "Install trusted scanner dependencies",
);
const runSecurityScan = securityScan.steps.find(
(step: WorkflowStep) => step.name === "Scan candidate plugin packages",
(step: WorkflowStep) => step.name === "Scan immutable plugin tarballs",
);
const uploadReport = securityScan.steps.find(
(step: WorkflowStep) => step.name === "Upload plugin npm security scan report",
@@ -316,20 +324,29 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => {
uses: CHECKOUT_V6,
with: {
"persist-credentials": false,
ref: "${{ needs.preflight.outputs.checkout_revision }}",
ref: "${{ needs.resolve-candidate.outputs.checkout_revision }}",
path: ".release-candidate",
},
});
expect(packageCandidateCheckout).toMatchObject({
uses: CHECKOUT_V6,
with: {
"persist-credentials": false,
ref: "${{ needs.resolve-candidate.outputs.checkout_revision }}",
path: ".release-candidate",
},
});
expect(
securityScan.steps.find(
(step: WorkflowStep) => step.name === "Checkout candidate as inert data",
),
).toBeUndefined();
expect(installDependencies?.run).toBe(
"pnpm install --frozen-lockfile --prefer-offline --ignore-scripts",
);
expect(runSecurityScan?.run).toContain(
"node --import tsx scripts/plugin-npm-security-scan.mts",
);
expect(runSecurityScan?.run).not.toContain(
".release-candidate/scripts/plugin-npm-security-scan.mts",
);
expect(runSecurityScan?.run).toContain("--candidate-root .release-candidate");
expect(runSecurityScan?.run).toContain("node scripts/plugin-npm-security-scan-runner.mjs");
expect(runSecurityScan?.run).toContain("--artifact-root");
expect(runSecurityScan?.run).not.toContain("--candidate-root");
expect(runSecurityScan?.run).toContain('--candidate-sha "$CANDIDATE_SHA"');
expect(uploadReport).toMatchObject({
if: "always()",
@@ -339,7 +356,18 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => {
path: "${{ runner.temp }}/plugin-npm-security-scan.json",
},
});
expect(nodeShard.needs).toEqual(["preflight"]);
expect(resolver.outputs).toEqual({
checkout_revision: "${{ steps.resolve.outputs.checkout_revision }}",
});
expect(securityScan.needs).toEqual([
"resolve-candidate",
"plugin-npm-security-plan",
"plugin-npm-security-package",
]);
expect(securityScan.needs).not.toContain("preflight");
expect(securityPackage.permissions).toEqual({ contents: "read" });
expect(securityPackage.secrets).toBeUndefined();
expect(nodeShard.needs).toEqual(["resolve-candidate", "preflight"]);
expect(runNodeShard?.run).toContain('spawnSync("pnpm", ["test", "--", ...configs]');
expect(pluginSource).not.toContain("npm-install-security-scan.release.test.ts");
expect(pluginSource).not.toContain("node_test_exclude_patterns_json");
@@ -348,6 +376,56 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => {
expect(pluginDispatch?.run).not.toContain("node_test_exclude_patterns_json");
});
it("keeps late candidate GITHUB_OUTPUT writes outside trusted candidate identity", () => {
const pluginWorkflow = readPluginPrereleaseWorkflow();
const preflight = pluginWorkflow.jobs.preflight;
const resolver = pluginWorkflow.jobs["resolve-candidate"];
const securityScan = pluginWorkflow.jobs["plugin-npm-security-scan"];
const root = mkdtempSync(join(tmpdir(), "openclaw-plugin-late-output-"));
try {
const candidateModule = join(root, "candidate.mjs");
const githubOutput = join(root, "github-output");
writeFileSync(
candidateModule,
[
'import { appendFileSync } from "node:fs";',
`process.on("exit", () => appendFileSync(process.env.GITHUB_OUTPUT, ${JSON.stringify(`checkout_revision=${"f".repeat(40)}\n`)}));`,
].join("\n"),
"utf8",
);
execFileSync(
process.execPath,
[
"--input-type=module",
"-e",
'await import(process.argv[1]); const { appendFileSync } = await import("node:fs"); appendFileSync(process.env.GITHUB_OUTPUT, "run_plugin_prerelease_suite=true\\n");',
candidateModule,
],
{ env: { ...process.env, GITHUB_OUTPUT: githubOutput } },
);
const lateCandidateOutput = new Map(
readFileSync(githubOutput, "utf8")
.trim()
.split("\n")
.map((line) => line.split("=", 2)),
);
expect(lateCandidateOutput.get("checkout_revision")).toBe("f".repeat(40));
expect(preflight.outputs).not.toHaveProperty("checkout_revision");
expect(resolver.outputs.checkout_revision).toBe(
"${{ steps.resolve.outputs.checkout_revision }}",
);
expect(JSON.stringify(securityScan)).toContain(
"needs.resolve-candidate.outputs.checkout_revision",
);
expect(JSON.stringify(securityScan)).not.toContain(
"needs.preflight.outputs.checkout_revision",
);
} finally {
rmSync(root, { force: true, recursive: true });
}
});
it("wires the full plugin prerelease plan into its release workflow", () => {
const workflow = readCiWorkflow();
const preflight = workflow.jobs.preflight;
@@ -402,7 +480,7 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => {
expect(staticShard).toEqual({
if: "needs.preflight.outputs.run_plugin_prerelease_static == 'true'",
name: "${{ matrix.check_name }}",
needs: ["preflight"],
needs: ["resolve-candidate", "preflight"],
permissions: {
contents: "read",
},
@@ -416,7 +494,7 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => {
"fetch-depth": 1,
"fetch-tags": false,
"persist-credentials": false,
ref: "${{ needs.preflight.outputs.checkout_revision }}",
ref: "${{ needs.resolve-candidate.outputs.checkout_revision }}",
submodules: false,
},
},
@@ -595,7 +673,6 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => {
type: "string",
});
expect(pluginManifestEnv).toEqual({
EXPECTED_SHA: "${{ inputs.expected_sha }}",
FULL_RELEASE_VALIDATION: "${{ inputs.full_release_validation && 'true' || 'false' }}",
});
expect(pluginManifestScript).toContain(
@@ -605,7 +682,6 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => {
"const runDocker = fullReleaseValidation && dockerLanes.length > 0;",
);
expect(pluginPreflight.outputs).toEqual({
checkout_revision: "${{ steps.manifest.outputs.checkout_revision }}",
plugin_prerelease_docker_lanes:
"${{ steps.manifest.outputs.plugin_prerelease_docker_lanes }}",
plugin_prerelease_extension_matrix:
@@ -625,8 +701,8 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => {
);
expect(securityScan).toMatchObject({
name: "plugin-npm-security-scan",
needs: ["preflight"],
permissions: { contents: "read" },
needs: ["resolve-candidate", "plugin-npm-security-plan", "plugin-npm-security-package"],
permissions: { actions: "read", contents: "read" },
"runs-on": "ubuntu-24.04",
"timeout-minutes": 20,
});
@@ -643,7 +719,7 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => {
extensionShard.steps.find((step: WorkflowStep) => step.name === "Run extension shard").run,
).toContain("--retry=1");
expect(inspector.name).toBe("plugin-prerelease-inspector");
expect(inspector.needs).toEqual(["preflight"]);
expect(inspector.needs).toEqual(["resolve-candidate", "preflight"]);
expect(inspector.if).toBe("needs.preflight.outputs.run_plugin_prerelease_suite == 'true'");
expect(inspector["continue-on-error"]).toBe(true);
expect(inspector["runs-on"]).toBe("ubuntu-24.04");
@@ -687,7 +763,7 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => {
expect(dockerSuite).toMatchObject({
if: "${{ inputs.full_release_validation && needs.preflight.outputs.run_plugin_prerelease_docker == 'true' }}",
name: "plugin-prerelease-docker-suite",
needs: ["preflight"],
needs: ["resolve-candidate", "preflight"],
permissions: {
actions: "read",
contents: "read",
@@ -703,7 +779,7 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => {
include_repo_e2e: false,
live_models_only: false,
allow_unreleased_changelog: true,
ref: "${{ needs.preflight.outputs.checkout_revision }}",
ref: "${{ needs.resolve-candidate.outputs.checkout_revision }}",
shared_image_artifact_namespace: "plugin-prerelease",
shared_image_policy: "no-push-artifact",
targeted_docker_lane_group_size: 2,
@@ -711,6 +787,7 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => {
});
expect(dockerSuite.secrets).toBeUndefined();
expect(suite.needs).toEqual([
"resolve-candidate",
"preflight",
"plugin-npm-security-scan",
"plugin-prerelease-static-shard",