Files
openclaw/src/plugin-sdk/package-contract-guardrails.test.ts
2026-03-29 23:21:24 -04:00

370 lines
12 KiB
TypeScript

import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
import { createRequire } from "node:module";
import os from "node:os";
import { dirname, join, relative, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import * as tar from "tar";
import { describe, expect, it } from "vitest";
import { pluginSdkEntrypoints } from "./entrypoints.js";
const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const REPO_ROOT = resolve(ROOT_DIR, "..");
const PUBLIC_CONTRACT_REFERENCE_FILES = [
"docs/plugins/architecture.md",
"src/plugin-sdk/subpaths.test.ts",
] as const;
const PLUGIN_SDK_SUBPATH_PATTERN = /openclaw\/plugin-sdk\/([a-z0-9][a-z0-9-]*)\b/g;
const NPM_PACK_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
const WINDOWS_UNSAFE_CMD_CHARS_RE = /[&|<>^%\r\n]/;
function collectPluginSdkPackageExports(): string[] {
const packageJson = JSON.parse(readFileSync(resolve(REPO_ROOT, "package.json"), "utf8")) as {
exports?: Record<string, unknown>;
};
const exports = packageJson.exports ?? {};
const subpaths: string[] = [];
for (const key of Object.keys(exports)) {
if (key === "./plugin-sdk") {
subpaths.push("index");
continue;
}
if (!key.startsWith("./plugin-sdk/")) {
continue;
}
subpaths.push(key.slice("./plugin-sdk/".length));
}
return subpaths.toSorted();
}
function collectPluginSdkSubpathReferences() {
const references: Array<{ file: string; subpath: string }> = [];
for (const file of PUBLIC_CONTRACT_REFERENCE_FILES) {
const source = readFileSync(resolve(REPO_ROOT, file), "utf8");
for (const match of source.matchAll(PLUGIN_SDK_SUBPATH_PATTERN)) {
const subpath = match[1];
if (!subpath) {
continue;
}
references.push({ file, subpath });
}
}
return references;
}
function readRootPackageJson(): {
dependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
} {
return JSON.parse(readFileSync(resolve(REPO_ROOT, "package.json"), "utf8")) as {
dependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
};
}
function readMatrixPackageJson(): {
dependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
openclaw?: {
releaseChecks?: {
rootDependencyMirrorAllowlist?: unknown;
};
};
} {
return JSON.parse(readFileSync(resolve(REPO_ROOT, "extensions/matrix/package.json"), "utf8")) as {
dependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
openclaw?: {
releaseChecks?: {
rootDependencyMirrorAllowlist?: unknown;
};
};
};
}
function collectRuntimeDependencySpecs(packageJson: {
dependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
}): Map<string, string> {
return new Map([
...Object.entries(packageJson.dependencies ?? {}),
...Object.entries(packageJson.optionalDependencies ?? {}),
]);
}
function createRootPackageRequire() {
return createRequire(pathToFileURL(resolve(REPO_ROOT, "package.json")).href);
}
function isNpmExecPath(value: string): boolean {
return /^npm(?:-cli)?(?:\.(?:c?js|cmd|exe))?$/.test(
value.split(/[\\/]/).at(-1)?.toLowerCase() ?? "",
);
}
function escapeForCmdExe(arg: string): string {
if (WINDOWS_UNSAFE_CMD_CHARS_RE.test(arg)) {
throw new Error(`unsafe Windows cmd.exe argument detected: ${JSON.stringify(arg)}`);
}
if (!arg.includes(" ") && !arg.includes('"')) {
return arg;
}
return `"${arg.replace(/"/g, '""')}"`;
}
function buildCmdExeCommandLine(command: string, args: string[]): string {
return [escapeForCmdExe(command), ...args.map(escapeForCmdExe)].join(" ");
}
type NpmCommandInvocation = {
command: string;
args: string[];
env?: NodeJS.ProcessEnv;
windowsVerbatimArguments?: boolean;
};
function resolveNpmCommandInvocation(npmArgs: string[]): NpmCommandInvocation {
const npmExecPath = process.env.npm_execpath;
if (typeof npmExecPath === "string" && npmExecPath.length > 0 && isNpmExecPath(npmExecPath)) {
return { command: process.execPath, args: [npmExecPath, ...npmArgs] };
}
if (process.platform !== "win32") {
return { command: "npm", args: npmArgs };
}
const nodeDir = dirname(process.execPath);
const npmCliCandidates = [
resolve(nodeDir, "../lib/node_modules/npm/bin/npm-cli.js"),
resolve(nodeDir, "node_modules/npm/bin/npm-cli.js"),
];
const npmCliPath = npmCliCandidates.find((candidate) => existsSync(candidate));
if (npmCliPath) {
return { command: process.execPath, args: [npmCliPath, ...npmArgs] };
}
const npmExePath = resolve(nodeDir, "npm.exe");
if (existsSync(npmExePath)) {
return { command: npmExePath, args: npmArgs };
}
const npmCmdPath = resolve(nodeDir, "npm.cmd");
if (existsSync(npmCmdPath)) {
return {
command: process.env.ComSpec ?? "cmd.exe",
args: ["/d", "/s", "/c", buildCmdExeCommandLine(npmCmdPath, npmArgs)],
windowsVerbatimArguments: true,
};
}
return {
command: process.env.ComSpec ?? "cmd.exe",
args: ["/d", "/s", "/c", buildCmdExeCommandLine("npm.cmd", npmArgs)],
windowsVerbatimArguments: true,
};
}
function packOpenClawToTempDir(packDir: string): string {
const invocation = resolveNpmCommandInvocation([
"pack",
"--ignore-scripts",
"--json",
"--pack-destination",
packDir,
]);
const result = spawnSync(invocation.command, invocation.args, {
cwd: REPO_ROOT,
encoding: "utf8",
env: {
...process.env,
...invocation.env,
COREPACK_ENABLE_DOWNLOAD_PROMPT: "0",
},
maxBuffer: NPM_PACK_MAX_BUFFER_BYTES,
stdio: ["ignore", "pipe", "pipe"],
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
});
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
throw new Error((result.stderr || result.stdout || "npm pack failed").trim());
}
const raw = result.stdout;
const parsed = JSON.parse(raw) as Array<{ filename?: string }>;
const filename = parsed[0]?.filename?.trim();
if (!filename) {
throw new Error(`npm pack did not return a filename: ${raw}`);
}
return join(packDir, filename);
}
async function readPackedRootPackageJson(archivePath: string): Promise<{
dependencies?: Record<string, string>;
}> {
const extractDir = mkdtempSync(join(os.tmpdir(), "openclaw-packed-root-package-json-"));
try {
await tar.x({
file: archivePath,
cwd: extractDir,
filter: (entryPath) => entryPath === "package/package.json",
strict: true,
});
return JSON.parse(readFileSync(join(extractDir, "package", "package.json"), "utf8")) as {
dependencies?: Record<string, string>;
};
} finally {
rmSync(extractDir, { recursive: true, force: true });
}
}
function readGeneratedFacadeTypeMap(): string {
return readFileSync(
resolve(REPO_ROOT, "src/generated/plugin-sdk-facade-type-map.generated.ts"),
"utf8",
);
}
function buildLegacyPluginSourceAlias(): string {
return ["openclaw", ["plugin", "source"].join("-")].join("/") + "/";
}
function collectExtensionFiles(dir: string): string[] {
const entries = readdirSync(dir, { withFileTypes: true });
const files: string[] = [];
for (const entry of entries) {
if (entry.name === "dist" || entry.name === "node_modules") {
continue;
}
const nextPath = join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...collectExtensionFiles(nextPath));
continue;
}
if (!entry.isFile() || !/\.(?:[cm]?ts|tsx|mts|cts)$/.test(entry.name)) {
continue;
}
files.push(nextPath);
}
return files;
}
function collectExtensionCoreImportLeaks(): Array<{ file: string; specifier: string }> {
const leaks: Array<{ file: string; specifier: string }> = [];
const importPattern = /\b(?:import|export)\b[\s\S]*?\bfrom\s*["']((?:\.\.\/)+src\/[^"']+)["']/g;
for (const file of collectExtensionFiles(resolve(REPO_ROOT, "extensions"))) {
const repoRelativePath = relative(REPO_ROOT, file).replaceAll("\\", "/");
if (
/(?:^|\/)(?:__tests__|tests|test-support)(?:\/|$)/.test(repoRelativePath) ||
/(?:^|\/)test-support\.[cm]?tsx?$/.test(repoRelativePath) ||
/\.test\.[cm]?tsx?$/.test(repoRelativePath)
) {
continue;
}
const extensionRootMatch = /^(.*?\/extensions\/[^/]+)/.exec(file.replaceAll("\\", "/"));
const extensionRoot = extensionRootMatch?.[1];
const source = readFileSync(file, "utf8");
for (const match of source.matchAll(importPattern)) {
const specifier = match[1];
if (!specifier) {
continue;
}
const resolvedSpecifier = resolve(dirname(file), specifier).replaceAll("\\", "/");
if (extensionRoot && resolvedSpecifier.startsWith(`${extensionRoot}/`)) {
continue;
}
leaks.push({
file: repoRelativePath,
specifier,
});
}
}
return leaks;
}
describe("plugin-sdk package contract guardrails", () => {
it("keeps package.json exports aligned with built plugin-sdk entrypoints", () => {
expect(collectPluginSdkPackageExports()).toEqual([...pluginSdkEntrypoints].toSorted());
});
it("keeps curated public plugin-sdk references on exported built subpaths", () => {
const entrypoints = new Set(pluginSdkEntrypoints);
const exports = new Set(collectPluginSdkPackageExports());
const failures: string[] = [];
for (const reference of collectPluginSdkSubpathReferences()) {
const missingFrom: string[] = [];
if (!entrypoints.has(reference.subpath)) {
missingFrom.push("scripts/lib/plugin-sdk-entrypoints.json");
}
if (!exports.has(reference.subpath)) {
missingFrom.push("package.json exports");
}
if (missingFrom.length === 0) {
continue;
}
failures.push(
`${reference.file} references openclaw/plugin-sdk/${reference.subpath}, but ${reference.subpath} is missing from ${missingFrom.join(" and ")}`,
);
}
expect(failures).toEqual([]);
});
it("mirrors matrix runtime deps needed by the bundled host graph", () => {
const rootRuntimeDeps = collectRuntimeDependencySpecs(readRootPackageJson());
const matrixPackageJson = readMatrixPackageJson();
const matrixRuntimeDeps = collectRuntimeDependencySpecs(matrixPackageJson);
const allowlist = matrixPackageJson.openclaw?.releaseChecks?.rootDependencyMirrorAllowlist;
expect(Array.isArray(allowlist)).toBe(true);
const matrixRootMirrorAllowlist = allowlist as string[];
expect(matrixRootMirrorAllowlist).toEqual(
expect.arrayContaining(["@matrix-org/matrix-sdk-crypto-wasm"]),
);
for (const dep of matrixRootMirrorAllowlist) {
expect(rootRuntimeDeps.get(dep)).toBe(matrixRuntimeDeps.get(dep));
}
});
it("resolves matrix crypto WASM from the root runtime surface", () => {
const rootRequire = createRootPackageRequire();
// Normalize filesystem separators so the package assertion stays portable.
const resolvedPath = rootRequire
.resolve("@matrix-org/matrix-sdk-crypto-wasm")
.replaceAll("\\", "/");
expect(resolvedPath).toContain("@matrix-org/matrix-sdk-crypto-wasm");
});
it("keeps matrix crypto WASM in the packed artifact manifest", async () => {
const tempRoot = mkdtempSync(join(os.tmpdir(), "openclaw-matrix-wasm-pack-"));
try {
const packDir = join(tempRoot, "pack");
mkdirSync(packDir, { recursive: true });
const archivePath = packOpenClawToTempDir(packDir);
const packedPackageJson = await readPackedRootPackageJson(archivePath);
const matrixPackageJson = readMatrixPackageJson();
expect(packedPackageJson.dependencies?.["@matrix-org/matrix-sdk-crypto-wasm"]).toBe(
matrixPackageJson.dependencies?.["@matrix-org/matrix-sdk-crypto-wasm"],
);
expect(packedPackageJson.dependencies?.["@openclaw/plugin-package-contract"]).toBeUndefined();
expect(packedPackageJson.dependencies?.["@aws-sdk/client-bedrock"]).toBeUndefined();
} finally {
rmSync(tempRoot, { recursive: true, force: true });
}
});
it("keeps generated facade types on package-style module specifiers", () => {
expect(readGeneratedFacadeTypeMap()).not.toContain("../../extensions/");
expect(readGeneratedFacadeTypeMap()).not.toContain(buildLegacyPluginSourceAlias());
});
it("keeps extension sources on public sdk or local package seams", () => {
expect(collectExtensionCoreImportLeaks()).toEqual([]);
});
});