Files
openclaw/test/scripts/plugin-npm-security-scan.test.ts
2026-08-20 23:41:42 -07:00

719 lines
26 KiB
TypeScript

import { execFileSync, spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { basename, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
assertCanonicalNpmPackageName,
assertCompleteScannerSummary,
buildPluginNpmSecurityScanReport,
constrainPluginNpmSecurityScanReport,
loadPluginNpmSecurityArtifacts,
listPluginNpmSecurityArtifacts,
listPublishablePluginPackages,
normalizePackedFindingPath,
resolveCandidatePluginPackageDir,
resolveReviewedSourceLayout,
scanPublishablePluginPackages,
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: {
artifactRoot?: string;
extensionId: string;
files: Record<string, string | Buffer>;
packageName: string;
version?: string;
}) {
const root = params.artifactRoot
? join(params.artifactRoot, "..")
: tempDirs.make("openclaw-plugin-npm-security-artifact-");
const artifactRoot = params.artifactRoot ?? join(root, "artifacts");
const packageRoot = join(root, `source-${params.extensionId}`);
const artifactDir = join(
artifactRoot,
`plugin-npm-security-package-${CANDIDATE_SHA}-${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({
artifactKind: "supplemental-inert-package-input",
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: {
artifactKind: "supplemental-inert-package-input" as const,
artifactDir,
candidateSha: CANDIDATE_SHA,
compressedBytes: readFileSync(tarballPath).byteLength,
expandedBytes: Object.values(params.files).reduce(
(total, value) => total + Buffer.byteLength(value),
0,
),
extensionId: params.extensionId,
packageDir: `extensions/${params.extensionId}`,
packageName: params.packageName,
packageVersion,
tarballPath,
tarballSha256,
toolingSha: TOOLING_SHA,
},
artifactRoot,
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 = 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",
"@openclaw/codex:dangerous-exec:src/node-cli-sessions.ts",
"@openclaw/opencode-provider:dangerous-exec:session-catalog.ts",
"@openclaw/opencode-provider:dangerous-exec:session-catalog.test.ts",
];
expect(resolveReviewedSourceLayout(current)?.id).toBe("current");
expect(resolveReviewedSourceLayout(frozenLegacy)?.id).toBe("frozen-legacy");
expect(resolveReviewedSourceLayout(frozenLegacy.slice(0, -1))).toBeUndefined();
expect(resolveReviewedSourceLayout([...current, frozenLegacy[0]!])).toBeUndefined();
expect(resolveReviewedSourceLayout([...current, current[0]!])).toBeUndefined();
});
it("scans checked-in malicious code without running candidate hooks or helpers", async () => {
const candidateRoot = tempDirs.make("openclaw-plugin-security-inert-pack-");
const artifactRoot = tempDirs.make("openclaw-plugin-security-inert-artifacts-");
const packageDir = join(candidateRoot, "extensions", "inert");
const executionMarkers = ["asset", "prepare", "prepack", "postpack", "replacement"].map(
(name) => join(candidateRoot, `${name}-ran`),
);
initGitRepo(candidateRoot);
mkdirSync(packageDir, { recursive: true });
const markerCommand = (marker: string) =>
`node -e "require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'ran')"`;
const maliciousCode = 'const { execSync } = require("node:child_process");\nexecSync("id");\n';
writeFileSync(
join(packageDir, "package.json"),
`${JSON.stringify({
files: [
"build-assets.mjs",
"index.js",
"openclaw.plugin.json",
"package.json",
"plugin-npm-security-scan.mjs",
],
name: "@openclaw/test-inert-pack",
openclaw: {
assetScripts: { build: markerCommand(executionMarkers[0]!) },
release: { publishToNpm: true },
},
scripts: {
postpack: markerCommand(executionMarkers[3]!),
prepack: markerCommand(executionMarkers[2]!),
prepare: markerCommand(executionMarkers[1]!),
},
version: "2026.8.1-beta.1",
})}\n`,
"utf8",
);
writeFileSync(
join(packageDir, "openclaw.plugin.json"),
`${JSON.stringify({ id: "inert" })}\n`,
"utf8",
);
writeFileSync(join(packageDir, "index.js"), maliciousCode, "utf8");
writeFileSync(
join(packageDir, "build-assets.mjs"),
`require("node:fs").writeFileSync(${JSON.stringify(executionMarkers[0])}, "ran");\n`,
"utf8",
);
writeFileSync(
join(packageDir, "plugin-npm-security-scan.mjs"),
`require("node:fs").writeFileSync(${JSON.stringify(executionMarkers[4])}, "ran");\n`,
"utf8",
);
execFileSync("git", ["-C", candidateRoot, "add", "."]);
execFileSync("git", ["-C", candidateRoot, "commit", "--quiet", "-m", "fixture"]);
const candidateSha = execFileSync("git", ["-C", candidateRoot, "rev-parse", "HEAD"], {
encoding: "utf8",
}).trim();
const outputDir = join(artifactRoot, `plugin-npm-security-package-${candidateSha}-inert`);
const toolingSha = execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim();
mkdirSync(outputDir, { recursive: true });
const result = spawnSync(
process.execPath,
[
"--import",
"tsx",
"scripts/plugin-npm-security-prepare.mts",
"prepare",
"--candidate-root",
candidateRoot,
"--candidate-sha",
candidateSha,
"--extension-id",
"inert",
"--output-dir",
outputDir,
"--package-dir",
"extensions/inert",
"--package-name",
"@openclaw/test-inert-pack",
"--tooling-sha",
toolingSha,
],
{
cwd: process.cwd(),
encoding: "utf8",
env: { ...process.env, NODE_ENV: "test" },
},
);
expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0);
for (const marker of executionMarkers) {
expect(existsSync(marker)).toBe(false);
}
const metadata = JSON.parse(
readFileSync(join(outputDir, "plugin-npm-security-artifact.json"), "utf8"),
) as { artifactKind?: unknown; tarballName?: unknown };
expect(metadata.artifactKind).toBe("supplemental-inert-package-input");
expect(typeof metadata.tarballName).toBe("string");
const expectedPackage = {
extensionId: "inert",
packageDir: "extensions/inert",
packageName: "@openclaw/test-inert-pack",
packageVersion: "2026.8.1-beta.1",
};
const loaded = loadPluginNpmSecurityArtifacts({
artifactRoot,
candidateSha,
expectedPackages: [expectedPackage],
toolingSha,
});
expect(loaded.ingestionErrors).toEqual([]);
const scanned = await scanPublishablePluginPackages(loaded.artifacts);
expect(scanned.scanErrors).toEqual([]);
expect(scanned.packageResults[0]?.unexpectedCriticalFindings).toContainEqual({
line: 2,
path: "index.js",
ruleId: "dangerous-exec",
});
});
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, candidate package escapes, and tarball symlinks", () => {
expect(() => assertCompleteScannerSummary("@openclaw/test", { truncated: true })).toThrow(
"security scan reached its file limit",
);
const candidateRoot = tempDirs.make("openclaw-plugin-npm-security-candidate-");
const outsideDir = tempDirs.make("openclaw-plugin-npm-security-outside-");
mkdirSync(join(candidateRoot, "extensions"), { recursive: true });
writeFileSync(
join(outsideDir, "package.json"),
`${JSON.stringify({ name: "@openclaw/escape", version: "1.0.0" })}\n`,
"utf8",
);
symlinkSync(outsideDir, join(candidateRoot, "extensions", "escape"));
expect(() => resolveCandidatePluginPackageDir(candidateRoot, "escape")).toThrow(
"package directory is not a real directory",
);
const outsideFile = join(outsideDir, "outside.ts");
writeFileSync(outsideFile, "export const value = 1;\n", "utf8");
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, ".."),
basename(artifact.packageRoot),
],
{ env: { ...process.env, COPYFILE_DISABLE: "1" } },
);
expect(() => stageScannerRelevantPluginTarballFiles(artifact.tarballPath)).toThrow();
});
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("finds malicious packed input 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([
oversized.artifact,
malicious.artifact,
]);
expect(existsSync(marker)).toBe(false);
expect(packageResults).toHaveLength(1);
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(oversized.artifact.tarballPath);
const report = buildPluginNpmSecurityScanReport({
candidateSha: CANDIDATE_SHA,
packageResults,
scanErrors,
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("unexpected entries");
});
it("retains valid package scans when a sibling artifact is malformed", async () => {
const artifactRoot = tempDirs.make("openclaw-plugin-npm-security-mixed-");
const valid = writePluginArtifact({
artifactRoot,
extensionId: "valid",
files: { "index.js": "export const value = 1;\n" },
packageName: "@openclaw/test-valid",
});
const malformed = writePluginArtifact({
artifactRoot,
extensionId: "malformed",
files: { "index.js": "export const value = 2;\n" },
packageName: "@openclaw/test-malformed",
});
writeFileSync(
join(malformed.artifact.artifactDir, "plugin-npm-security-artifact.json"),
"{not-json}\n",
"utf8",
);
const expectedPackages = [malformed.expectedPackage, valid.expectedPackage].toSorted(
(left, right) => (left.packageName < right.packageName ? -1 : 1),
);
const loaded = loadPluginNpmSecurityArtifacts({
artifactRoot,
candidateSha: CANDIDATE_SHA,
expectedPackages,
toolingSha: TOOLING_SHA,
});
expect(loaded.artifacts.map((artifact) => artifact.packageName)).toEqual([
"@openclaw/test-valid",
]);
expect(loaded.ingestionErrors).toEqual([
"@openclaw/test-malformed: Plugin security artifact metadata is not valid JSON.",
]);
const scanned = await scanPublishablePluginPackages(loaded.artifacts);
expect(scanned.scanErrors).toEqual([]);
expect(scanned.packageResults.map((result) => result.packageName)).toEqual([
"@openclaw/test-valid",
]);
});
it("retains valid package reports when a sibling is rejected before download", async () => {
const artifactRoot = tempDirs.make("openclaw-plugin-npm-security-pre-download-");
const valid = writePluginArtifact({
artifactRoot,
extensionId: "valid",
files: { "index.js": "export const value = 1;\n" },
packageName: "@openclaw/test-valid",
});
const rejectedPackage = {
extensionId: "oversized",
packageDir: "extensions/oversized",
packageName: "@openclaw/test-oversized",
packageVersion: "1.0.0",
} satisfies PublishablePluginPackage;
const preDownloadError =
"@openclaw/test-oversized: plugin security artifact exceeds the pre-download byte limit.";
const loaded = loadPluginNpmSecurityArtifacts({
artifactRoot,
candidateSha: CANDIDATE_SHA,
expectedPackages: [rejectedPackage, valid.expectedPackage].toSorted((left, right) =>
left.packageName.localeCompare(right.packageName),
),
rejectedPackageNames: [rejectedPackage.packageName],
toolingSha: TOOLING_SHA,
});
expect(loaded.ingestionErrors).toEqual([]);
expect(loaded.artifacts.map((artifact) => artifact.packageName)).toEqual([
valid.expectedPackage.packageName,
]);
const scanned = await scanPublishablePluginPackages(loaded.artifacts);
const report = buildPluginNpmSecurityScanReport({
candidateSha: CANDIDATE_SHA,
packageResults: scanned.packageResults,
scanErrors: [preDownloadError, ...scanned.scanErrors],
toolingSha: TOOLING_SHA,
});
expect(report.packages.map((plugin) => plugin.packageName)).toEqual([
valid.expectedPackage.packageName,
]);
expect(report.errors).toContain(preDownloadError);
expect(report.errors.join("\n")).not.toContain("plugin security artifact is missing");
});
it("bounds aggregate compressed and expanded artifact bytes deterministically", () => {
const artifactRoot = tempDirs.make("openclaw-plugin-npm-security-aggregate-");
const alpha = writePluginArtifact({
artifactRoot,
extensionId: "alpha",
files: { "alpha.js": Buffer.alloc(256, 1) },
packageName: "@openclaw/test-alpha",
});
const beta = writePluginArtifact({
artifactRoot,
extensionId: "beta",
files: { "beta.js": Buffer.alloc(256, 2) },
packageName: "@openclaw/test-beta",
});
const expectedPackages = [alpha.expectedPackage, beta.expectedPackage];
const baseline = loadPluginNpmSecurityArtifacts({
artifactRoot,
candidateSha: CANDIDATE_SHA,
expectedPackages,
toolingSha: TOOLING_SHA,
});
expect(baseline.ingestionErrors).toEqual([]);
const compressed = loadPluginNpmSecurityArtifacts({
artifactRoot,
candidateSha: CANDIDATE_SHA,
expectedPackages,
limits: { maxCompressedBytes: baseline.artifacts[0]!.compressedBytes },
toolingSha: TOOLING_SHA,
});
expect(compressed.artifacts.map((artifact) => artifact.packageName)).toEqual([
"@openclaw/test-alpha",
]);
expect(compressed.ingestionErrors).toEqual([
"@openclaw/test-beta: aggregate compressed-byte limit exceeded.",
]);
const expanded = loadPluginNpmSecurityArtifacts({
artifactRoot,
candidateSha: CANDIDATE_SHA,
expectedPackages,
limits: { maxExpandedBytes: baseline.artifacts[0]!.expandedBytes },
toolingSha: TOOLING_SHA,
});
expect(expanded.artifacts.map((artifact) => artifact.packageName)).toEqual([
"@openclaw/test-alpha",
]);
expect(expanded.ingestionErrors).toEqual([
"@openclaw/test-beta: aggregate expanded-byte limit exceeded.",
]);
});
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(report.scanScope).toBe("supplemental-inert-package-input");
expect(JSON.stringify(report)).toBe(
JSON.stringify(
buildPluginNpmSecurityScanReport({
candidateSha: CANDIDATE_SHA,
maxTotalFindings: 50,
packageResults: structuredClone(packageResults).toReversed(),
toolingSha: TOOLING_SHA,
}),
),
);
expect(constrainPluginNpmSecurityScanReport(report, 64).errors).toEqual([
"Plugin npm security scan report exceeded the byte limit.",
]);
});
it("writes sanitized exact-identity reports for timeout, heap, and RSS failures", () => {
const root = tempDirs.make("openclaw-plugin-npm-security-runner-");
const timeoutChild = join(root, "timeout.mjs");
const oomChild = join(root, "oom.mjs");
const rssChild = join(root, "rss.mjs");
writeFileSync(timeoutChild, "await new Promise(() => {});\n", "utf8");
writeFileSync(
oomChild,
"const values = [];\nwhile (true) values.push(new Array(100000).fill(Math.random()));\n",
"utf8",
);
writeFileSync(
rssChild,
"globalThis.value = Buffer.alloc(64 * 1024 * 1024, 1);\nsetInterval(() => {}, 1_000);\n",
"utf8",
);
for (const [label, child, timeoutMs, heapMb, rssMb, expectedError] of [
["timeout", timeoutChild, "25", "128", "1024", "timed out"],
["oom", oomChild, "10000", "16", "1024", "exceeded its process limit"],
["rss", rssChild, "10000", "128", "16", "exceeded its RSS limit"],
] 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: heapMb,
OPENCLAW_PLUGIN_SECURITY_RUNNER_RSS_MB: rssMb,
OPENCLAW_PLUGIN_SECURITY_RUNNER_TIMEOUT_MS: timeoutMs,
},
timeout: 15_000,
},
);
const report = JSON.parse(readFileSync(reportPath, "utf8")) as {
candidateSha: string;
errors: string[];
toolingSha: string;
};
expect(result.status).toBe(1);
expect(report).toMatchObject({
candidateSha: CANDIDATE_SHA,
toolingSha: TOOLING_SHA,
});
expect(report.errors).toContainEqual(expect.stringContaining(expectedError));
expect(`${result.stdout}${result.stderr}${JSON.stringify(report)}`).not.toContain(root);
}
}, 30_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,
),
);
});
});