mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:30:43 +00:00
fix(plugins): repair managed npm openclaw peers
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>
This commit is contained in:
committed by
GitHub
parent
8cc762daff
commit
8e533490ab
@@ -59,6 +59,8 @@ Docs: https://docs.openclaw.ai
|
||||
- Plugins/update: treat official externalized bundled npm migrations and ClawHub-to-npm fallbacks as trusted source-linked installs, so prerelease-only official plugin packages can migrate from bundled builds without being rejected as unsafe prerelease resolutions. Thanks @vincentkoc.
|
||||
- Plugins/update: move ClawHub-preferred externalized plugin installs back to ClawHub after an earlier npm fallback once the ClawHub package becomes available. Thanks @vincentkoc.
|
||||
- Plugins/update: clean stale bundled load paths for already-externalized pinned npm and ClawHub plugin installs, so release-channel sync does not leave removed bundled paths ahead of the installed external package. Thanks @vincentkoc.
|
||||
- Plugins/update: repair stale managed npm-root `openclaw` peer packages before plugin installs, so beta-channel official plugin updates are not downgraded by old core package-lock state. Thanks @vincentkoc.
|
||||
- Plugins/install: reassert managed npm plugin `openclaw` peer links after shared-root npm installs, updates, and uninstalls, so mutating one plugin does not leave previously installed SDK-using plugins unable to resolve `openclaw/plugin-sdk/*`.
|
||||
- Plugins/update: make package upgrades swap pnpm/npm-prefix installs cleanly, keep legacy plugin install runtime chunks working, and on the beta channel fall back default-line npm plugins to default/latest when plugin beta releases are missing or fail install validation. Thanks @vincentkoc and @joshavant.
|
||||
- Plugins/active-memory: skip session-store channel entries that contain `:` when resolving the recall subagent's channel, so QQ c2c agent IDs (e.g. `c2c:10D4F7C2…`) and other scoped conversation IDs do not reach bundled-plugin `dirName` validation and crash the recall run. The same guard already applied to explicit `channelId` params (#76704); this extends it to store-derived channels. (#77396) Thanks @hclsys.
|
||||
- Sandbox/Windows: accept drive-absolute Docker bind sources while keeping sandbox blocked-path and allowed-root policy comparisons Windows-case-insensitive. (#42174) Thanks @6607changchun.
|
||||
|
||||
@@ -51,6 +51,14 @@ the plugin package. OpenClaw scans the managed npm root before trusting the
|
||||
install and uses npm to remove npm-managed packages during uninstall, so hoisted
|
||||
runtime dependencies stay inside the managed cleanup boundary.
|
||||
|
||||
Plugins that import `openclaw/plugin-sdk/*` declare `openclaw` as a peer
|
||||
dependency. OpenClaw does not let npm install a separate registry copy of the
|
||||
host package into the managed root, because stale host packages can affect npm
|
||||
peer resolution during later plugin installs. Instead, after npm finishes
|
||||
mutating the shared root during install, update, or uninstall, OpenClaw reasserts
|
||||
plugin-local `node_modules/openclaw` links for installed packages that declare
|
||||
the host peer.
|
||||
|
||||
git installs clone or refresh the repository, then run:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -9,7 +9,9 @@ const NPM_CONFIG_KEYS_TO_RESET = new Set([
|
||||
"npm_config_include_workspace_root",
|
||||
"npm_config_ignore_scripts",
|
||||
"npm_config_location",
|
||||
"npm_config_legacy_peer_deps",
|
||||
"npm_config_prefix",
|
||||
"npm_config_strict_peer_deps",
|
||||
"npm_config_workspace",
|
||||
"npm_config_workspaces",
|
||||
]);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
repairManagedNpmRootOpenClawPeer,
|
||||
removeManagedNpmRootDependency,
|
||||
readManagedNpmRootInstalledDependency,
|
||||
resolveManagedNpmRootDependencySpec,
|
||||
@@ -11,6 +12,15 @@ import {
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
const successfulSpawn = {
|
||||
code: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
signal: null,
|
||||
killed: false,
|
||||
termination: "exit" as const,
|
||||
};
|
||||
|
||||
async function makeTempRoot(): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-npm-managed-root-"));
|
||||
tempDirs.push(dir);
|
||||
@@ -183,4 +193,128 @@ describe("managed npm root", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("repairs stale managed openclaw peer state without dropping plugin packages", async () => {
|
||||
const npmRoot = await makeTempRoot();
|
||||
await fs.mkdir(path.join(npmRoot, "node_modules", "openclaw"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(npmRoot, "package.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
private: true,
|
||||
dependencies: {
|
||||
openclaw: "2026.5.4",
|
||||
"@openclaw/discord": "2026.5.4",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(npmRoot, "package-lock.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
"": {
|
||||
dependencies: {
|
||||
openclaw: "2026.5.4",
|
||||
"@openclaw/discord": "2026.5.4",
|
||||
},
|
||||
},
|
||||
"node_modules/openclaw": {
|
||||
version: "2026.5.4",
|
||||
},
|
||||
"node_modules/@openclaw/discord": {
|
||||
version: "2026.5.4",
|
||||
},
|
||||
},
|
||||
dependencies: {
|
||||
openclaw: {
|
||||
version: "2026.5.4",
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(npmRoot, "node_modules", "openclaw", "package.json"),
|
||||
`${JSON.stringify({ name: "openclaw", version: "2026.5.4" })}\n`,
|
||||
);
|
||||
await fs.mkdir(path.join(npmRoot, "node_modules", ".bin"), { recursive: true });
|
||||
await fs.writeFile(path.join(npmRoot, "node_modules", ".bin", "openclaw"), "shim");
|
||||
await fs.writeFile(path.join(npmRoot, "node_modules", ".bin", "openclaw.cmd"), "cmd shim");
|
||||
await fs.writeFile(path.join(npmRoot, "node_modules", ".bin", "openclaw.ps1"), "ps1 shim");
|
||||
await fs.writeFile(
|
||||
path.join(npmRoot, "node_modules", ".package-lock.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
"node_modules/openclaw": {
|
||||
version: "2026.5.4",
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
|
||||
const runCommand = vi.fn().mockResolvedValue(successfulSpawn);
|
||||
await expect(repairManagedNpmRootOpenClawPeer({ npmRoot, runCommand })).resolves.toBe(true);
|
||||
expect(runCommand).toHaveBeenCalledWith(
|
||||
[
|
||||
"npm",
|
||||
"uninstall",
|
||||
"--loglevel=error",
|
||||
"--ignore-scripts",
|
||||
"--no-audit",
|
||||
"--no-fund",
|
||||
"--prefix",
|
||||
".",
|
||||
"openclaw",
|
||||
],
|
||||
expect.objectContaining({
|
||||
cwd: npmRoot,
|
||||
}),
|
||||
);
|
||||
|
||||
const manifest = JSON.parse(await fs.readFile(path.join(npmRoot, "package.json"), "utf8")) as {
|
||||
dependencies?: Record<string, string>;
|
||||
};
|
||||
expect(manifest.dependencies).toEqual({
|
||||
"@openclaw/discord": "2026.5.4",
|
||||
});
|
||||
const lockfile = JSON.parse(
|
||||
await fs.readFile(path.join(npmRoot, "package-lock.json"), "utf8"),
|
||||
) as {
|
||||
packages?: Record<string, { dependencies?: Record<string, string>; version?: string }>;
|
||||
dependencies?: Record<string, unknown>;
|
||||
};
|
||||
expect(lockfile.packages?.[""]?.dependencies).toEqual({
|
||||
"@openclaw/discord": "2026.5.4",
|
||||
});
|
||||
expect(lockfile.packages?.["node_modules/openclaw"]).toBeUndefined();
|
||||
expect(lockfile.packages?.["node_modules/@openclaw/discord"]?.version).toBe("2026.5.4");
|
||||
expect(lockfile.dependencies?.openclaw).toBeUndefined();
|
||||
await expect(fs.lstat(path.join(npmRoot, "node_modules", "openclaw"))).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
for (const binName of ["openclaw", "openclaw.cmd", "openclaw.ps1"]) {
|
||||
await expect(
|
||||
fs.lstat(path.join(npmRoot, "node_modules", ".bin", binName)),
|
||||
).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
}
|
||||
await expect(
|
||||
fs.lstat(path.join(npmRoot, "node_modules", ".package-lock.json")),
|
||||
).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import type { NpmSpecResolution } from "./install-source-utils.js";
|
||||
import { readJson, readJsonIfExists, writeJson } from "./json-files.js";
|
||||
import type { ParsedRegistryNpmSpec } from "./npm-registry-spec.js";
|
||||
import { createSafeNpmInstallEnv } from "./safe-package-install.js";
|
||||
|
||||
type ManagedNpmRootManifest = {
|
||||
private?: boolean;
|
||||
@@ -16,6 +18,18 @@ export type ManagedNpmRootInstalledDependency = {
|
||||
resolved?: string;
|
||||
};
|
||||
|
||||
type ManagedNpmRootLockfile = {
|
||||
packages?: Record<string, unknown>;
|
||||
dependencies?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type ManagedNpmRootLogger = {
|
||||
warn?: (message: string) => void;
|
||||
};
|
||||
|
||||
type ManagedNpmRootRunCommand = typeof runCommandWithTimeout;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -69,6 +83,168 @@ export async function upsertManagedNpmRootDependency(params: {
|
||||
await writeJson(manifestPath, next, { trailingNewline: true });
|
||||
}
|
||||
|
||||
export async function repairManagedNpmRootOpenClawPeer(params: {
|
||||
npmRoot: string;
|
||||
timeoutMs?: number;
|
||||
logger?: ManagedNpmRootLogger;
|
||||
runCommand?: ManagedNpmRootRunCommand;
|
||||
}): Promise<boolean> {
|
||||
await fs.mkdir(params.npmRoot, { recursive: true });
|
||||
|
||||
const manifestPath = path.join(params.npmRoot, "package.json");
|
||||
const manifest = await readManagedNpmRootManifest(manifestPath);
|
||||
const dependencies = readDependencyRecord(manifest.dependencies);
|
||||
const hasManifestDependency = "openclaw" in dependencies;
|
||||
const hasLockDependency = await managedNpmRootLockfileHasOpenClawPeer(params.npmRoot);
|
||||
const hasPackageDir = await pathExists(path.join(params.npmRoot, "node_modules", "openclaw"));
|
||||
if (!hasManifestDependency && !hasLockDependency && !hasPackageDir) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const command = params.runCommand ?? runCommandWithTimeout;
|
||||
const npmArgs = hasManifestDependency
|
||||
? [
|
||||
"npm",
|
||||
"uninstall",
|
||||
"--loglevel=error",
|
||||
"--ignore-scripts",
|
||||
"--no-audit",
|
||||
"--no-fund",
|
||||
"--prefix",
|
||||
".",
|
||||
"openclaw",
|
||||
]
|
||||
: [
|
||||
"npm",
|
||||
"prune",
|
||||
"--loglevel=error",
|
||||
"--ignore-scripts",
|
||||
"--no-audit",
|
||||
"--no-fund",
|
||||
"--prefix",
|
||||
".",
|
||||
];
|
||||
try {
|
||||
const result = await command(npmArgs, {
|
||||
cwd: params.npmRoot,
|
||||
timeoutMs: Math.max(params.timeoutMs ?? 300_000, 300_000),
|
||||
env: createSafeNpmInstallEnv(process.env, { packageLock: true, quiet: true }),
|
||||
});
|
||||
if (result.code !== 0) {
|
||||
params.logger?.warn?.(
|
||||
`npm ${hasManifestDependency ? "uninstall openclaw" : "prune"} failed while repairing managed npm root; falling back to direct cleanup: ${result.stderr.trim() || result.stdout.trim()}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
params.logger?.warn?.(
|
||||
`npm ${hasManifestDependency ? "uninstall openclaw" : "prune"} failed while repairing managed npm root; falling back to direct cleanup: ${String(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
await scrubManagedNpmRootOpenClawPeer({ npmRoot: params.npmRoot });
|
||||
return true;
|
||||
}
|
||||
|
||||
async function managedNpmRootLockfileHasOpenClawPeer(npmRoot: string): Promise<boolean> {
|
||||
const lockPath = path.join(npmRoot, "package-lock.json");
|
||||
try {
|
||||
const parsed = JSON.parse(await fs.readFile(lockPath, "utf8")) as ManagedNpmRootLockfile;
|
||||
if (isRecord(parsed.packages)) {
|
||||
const rootPackage = parsed.packages[""];
|
||||
if (
|
||||
isRecord(rootPackage) &&
|
||||
isRecord(rootPackage.dependencies) &&
|
||||
"openclaw" in rootPackage.dependencies
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if ("node_modules/openclaw" in parsed.packages) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return isRecord(parsed.dependencies) && "openclaw" in parsed.dependencies;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return false;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function pathExists(filePath: string): Promise<boolean> {
|
||||
return await fs
|
||||
.lstat(filePath)
|
||||
.then(() => true)
|
||||
.catch((err: NodeJS.ErrnoException) => {
|
||||
if (err.code === "ENOENT") {
|
||||
return false;
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
async function scrubManagedNpmRootOpenClawPeer(params: { npmRoot: string }): Promise<void> {
|
||||
const manifestPath = path.join(params.npmRoot, "package.json");
|
||||
const manifest = await readManagedNpmRootManifest(manifestPath);
|
||||
const dependencies = readDependencyRecord(manifest.dependencies);
|
||||
if ("openclaw" in dependencies) {
|
||||
const { openclaw: _removed, ...nextDependencies } = dependencies;
|
||||
await fs.writeFile(
|
||||
manifestPath,
|
||||
`${JSON.stringify({ ...manifest, private: true, dependencies: nextDependencies }, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
const lockPath = path.join(params.npmRoot, "package-lock.json");
|
||||
try {
|
||||
const parsed = JSON.parse(await fs.readFile(lockPath, "utf8")) as ManagedNpmRootLockfile;
|
||||
let lockChanged = false;
|
||||
if (isRecord(parsed.packages)) {
|
||||
const rootPackage = parsed.packages[""];
|
||||
if (isRecord(rootPackage) && isRecord(rootPackage.dependencies)) {
|
||||
const dependencies = { ...rootPackage.dependencies };
|
||||
if ("openclaw" in dependencies) {
|
||||
delete dependencies.openclaw;
|
||||
parsed.packages[""] = { ...rootPackage, dependencies };
|
||||
lockChanged = true;
|
||||
}
|
||||
}
|
||||
if ("node_modules/openclaw" in parsed.packages) {
|
||||
delete parsed.packages["node_modules/openclaw"];
|
||||
lockChanged = true;
|
||||
}
|
||||
}
|
||||
if (isRecord(parsed.dependencies) && "openclaw" in parsed.dependencies) {
|
||||
const dependencies = { ...parsed.dependencies };
|
||||
delete dependencies.openclaw;
|
||||
parsed.dependencies = dependencies;
|
||||
lockChanged = true;
|
||||
}
|
||||
if (lockChanged) {
|
||||
await fs.writeFile(lockPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf8");
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const openclawPackageDir = path.join(params.npmRoot, "node_modules", "openclaw");
|
||||
if (await pathExists(openclawPackageDir)) {
|
||||
await fs.rm(openclawPackageDir, { recursive: true, force: true });
|
||||
}
|
||||
const binDir = path.join(params.npmRoot, "node_modules", ".bin");
|
||||
await Promise.all(
|
||||
["openclaw", "openclaw.cmd", "openclaw.ps1"].map((binName) =>
|
||||
fs.rm(path.join(binDir, binName), { force: true }),
|
||||
),
|
||||
);
|
||||
await fs.rm(path.join(params.npmRoot, "node_modules", ".package-lock.json"), {
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function readManagedNpmRootInstalledDependency(params: {
|
||||
npmRoot: string;
|
||||
packageName: string;
|
||||
|
||||
@@ -28,6 +28,8 @@ describe("safe npm install helpers", () => {
|
||||
{
|
||||
PATH: "/usr/bin:/bin",
|
||||
NPM_CONFIG_IGNORE_SCRIPTS: "false",
|
||||
NPM_CONFIG_LEGACY_PEER_DEPS: "false",
|
||||
NPM_CONFIG_STRICT_PEER_DEPS: "true",
|
||||
npm_config_global: "true",
|
||||
npm_config_include_workspace_root: "true",
|
||||
npm_config_ignore_scripts: "false",
|
||||
@@ -64,11 +66,26 @@ describe("safe npm install helpers", () => {
|
||||
npm_config_package_lock: "false",
|
||||
npm_config_progress: "false",
|
||||
npm_config_save: "false",
|
||||
npm_config_strict_peer_deps: "false",
|
||||
npm_config_workspaces: "false",
|
||||
npm_config_yes: "true",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not inherit host legacy peer dependency mode by default", () => {
|
||||
expect(
|
||||
createSafeNpmInstallEnv({
|
||||
PATH: "/usr/bin:/bin",
|
||||
npm_config_legacy_peer_deps: "true",
|
||||
npm_config_strict_peer_deps: "true",
|
||||
}),
|
||||
).toMatchObject({
|
||||
PATH: "/usr/bin:/bin",
|
||||
npm_config_legacy_peer_deps: "false",
|
||||
npm_config_strict_peer_deps: "false",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows package-lock-enabled installs to write lockfiles", () => {
|
||||
expect(
|
||||
createSafeNpmInstallEnv(
|
||||
|
||||
@@ -27,10 +27,11 @@ export function createSafeNpmInstallEnv(
|
||||
npm_config_audit: "false",
|
||||
npm_config_fund: "false",
|
||||
npm_config_ignore_scripts: "true",
|
||||
npm_config_legacy_peer_deps: options.legacyPeerDeps ? "true" : "false",
|
||||
npm_config_package_lock: options.packageLock === true ? "true" : "false",
|
||||
npm_config_strict_peer_deps: "false",
|
||||
...(options.packageLock === true ? { npm_config_save: "true" } : {}),
|
||||
...(options.ignoreWorkspaces ? { npm_config_workspaces: "false" } : {}),
|
||||
...(options.legacyPeerDeps ? { npm_config_legacy_peer_deps: "true" } : {}),
|
||||
};
|
||||
if (options.quiet) {
|
||||
Object.assign(nextEnv, {
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { execFile, execFileSync } from "node:child_process";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import http from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { installPluginFromNpmSpec } from "./install.js";
|
||||
|
||||
type PackedVersion = {
|
||||
archive: Buffer;
|
||||
integrity: string;
|
||||
peerDependencies?: Record<string, string>;
|
||||
peerDependenciesMeta?: Record<string, { optional?: boolean }>;
|
||||
shasum: string;
|
||||
tarballName: string;
|
||||
version: string;
|
||||
@@ -19,6 +22,7 @@ const tempDirs: string[] = [];
|
||||
const servers: http.Server[] = [];
|
||||
const envKeys = ["NPM_CONFIG_REGISTRY", "npm_config_registry"] as const;
|
||||
const originalEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]]));
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
afterEach(async () => {
|
||||
for (const server of servers.splice(0)) {
|
||||
@@ -43,11 +47,19 @@ async function makeTempDir(label: string): Promise<string> {
|
||||
|
||||
async function packPlugin(params: {
|
||||
packageName: string;
|
||||
peerDependencies?: Record<string, string>;
|
||||
peerDependenciesMeta?: Record<string, { optional?: boolean }>;
|
||||
pluginId: string;
|
||||
version: string;
|
||||
rootDir: string;
|
||||
}): Promise<PackedVersion> {
|
||||
const packageDir = path.join(params.rootDir, `package-${params.version}`);
|
||||
const packageDir = path.join(params.rootDir, `package-${params.packageName}-${params.version}`);
|
||||
const peerDependenciesMeta = params.peerDependencies
|
||||
? (params.peerDependenciesMeta ??
|
||||
Object.fromEntries(
|
||||
Object.keys(params.peerDependencies).map((name) => [name, { optional: true }]),
|
||||
))
|
||||
: undefined;
|
||||
await fs.mkdir(path.join(packageDir, "dist"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(packageDir, "package.json"),
|
||||
@@ -57,6 +69,12 @@ async function packPlugin(params: {
|
||||
version: params.version,
|
||||
type: "module",
|
||||
openclaw: { extensions: ["./dist/index.js"] },
|
||||
...(params.peerDependencies
|
||||
? {
|
||||
peerDependencies: params.peerDependencies,
|
||||
...(peerDependenciesMeta ? { peerDependenciesMeta } : {}),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
@@ -92,12 +110,90 @@ async function packPlugin(params: {
|
||||
return {
|
||||
archive,
|
||||
integrity: `sha512-${crypto.createHash("sha512").update(archive).digest("base64")}`,
|
||||
...(params.peerDependencies ? { peerDependencies: params.peerDependencies } : {}),
|
||||
...(peerDependenciesMeta ? { peerDependenciesMeta } : {}),
|
||||
shasum: crypto.createHash("sha1").update(archive).digest("hex"),
|
||||
tarballName,
|
||||
version: params.version,
|
||||
};
|
||||
}
|
||||
|
||||
async function startStaticRegistry(
|
||||
packages: Array<{
|
||||
latest: string;
|
||||
packageName: string;
|
||||
versions: PackedVersion[];
|
||||
}>,
|
||||
): Promise<string> {
|
||||
const packageEntries = packages.map((pkg) => ({
|
||||
...pkg,
|
||||
encodedPackageName: encodeURIComponent(pkg.packageName).replace("%40", "@"),
|
||||
versionsByVersion: new Map(pkg.versions.map((entry) => [entry.version, entry])),
|
||||
}));
|
||||
const server = http.createServer((request, response) => {
|
||||
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
||||
const baseUrl = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
|
||||
if (request.method !== "GET") {
|
||||
response.writeHead(405, { "content-type": "text/plain" });
|
||||
response.end("method not allowed");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const pkg of packageEntries) {
|
||||
if (url.pathname === `/${pkg.encodedPackageName}`) {
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(
|
||||
`${JSON.stringify({
|
||||
name: pkg.packageName,
|
||||
"dist-tags": { latest: pkg.latest },
|
||||
versions: Object.fromEntries(
|
||||
[...pkg.versionsByVersion.entries()].map(([version, entry]) => [
|
||||
version,
|
||||
{
|
||||
name: pkg.packageName,
|
||||
version,
|
||||
...(entry.peerDependencies ? { peerDependencies: entry.peerDependencies } : {}),
|
||||
...(entry.peerDependenciesMeta
|
||||
? { peerDependenciesMeta: entry.peerDependenciesMeta }
|
||||
: {}),
|
||||
dist: {
|
||||
integrity: entry.integrity,
|
||||
shasum: entry.shasum,
|
||||
tarball: `${baseUrl}/${pkg.encodedPackageName}/-/${entry.tarballName}`,
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
})}\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const tarballPrefix = `/${pkg.encodedPackageName}/-/`;
|
||||
if (url.pathname.startsWith(tarballPrefix)) {
|
||||
const entry = [...pkg.versionsByVersion.values()].find((candidate) =>
|
||||
url.pathname.endsWith(`/${candidate.tarballName}`),
|
||||
);
|
||||
if (entry) {
|
||||
response.writeHead(200, {
|
||||
"content-length": String(entry.archive.length),
|
||||
"content-type": "application/octet-stream",
|
||||
});
|
||||
response.end(entry.archive);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
response.writeHead(404, { "content-type": "text/plain" });
|
||||
response.end(`not found: ${url.pathname}`);
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
servers.push(server);
|
||||
return `http://127.0.0.1:${(server.address() as { port: number }).port}`;
|
||||
}
|
||||
|
||||
async function startMutableRegistry(params: {
|
||||
packageName: string;
|
||||
initialLatest: string;
|
||||
@@ -135,6 +231,10 @@ async function startMutableRegistry(params: {
|
||||
{
|
||||
name: params.packageName,
|
||||
version,
|
||||
...(entry.peerDependencies ? { peerDependencies: entry.peerDependencies } : {}),
|
||||
...(entry.peerDependenciesMeta
|
||||
? { peerDependenciesMeta: entry.peerDependenciesMeta }
|
||||
: {}),
|
||||
dist: {
|
||||
integrity: entry.integrity,
|
||||
shasum: entry.shasum,
|
||||
@@ -173,6 +273,155 @@ async function startMutableRegistry(params: {
|
||||
}
|
||||
|
||||
describe("installPluginFromNpmSpec e2e", () => {
|
||||
it("scrubs root openclaw materialized by required npm peers", async () => {
|
||||
const rootDir = await makeTempDir("npm-plugin-required-peer-e2e");
|
||||
const npmRoot = path.join(rootDir, "managed-npm");
|
||||
const packageName = `required-peer-plugin-${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
||||
const versions = [
|
||||
await packPlugin({
|
||||
packageName,
|
||||
peerDependencies: { openclaw: ">=2026.0.0" },
|
||||
peerDependenciesMeta: {},
|
||||
pluginId: packageName,
|
||||
version: "1.0.0",
|
||||
rootDir,
|
||||
}),
|
||||
];
|
||||
const openClawVersions = [
|
||||
await packPlugin({
|
||||
packageName: "openclaw",
|
||||
pluginId: "registry-openclaw-copy",
|
||||
version: "2026.0.0",
|
||||
rootDir,
|
||||
}),
|
||||
];
|
||||
const registry = await startStaticRegistry([
|
||||
{ packageName, latest: "1.0.0", versions },
|
||||
{ packageName: "openclaw", latest: "2026.0.0", versions: openClawVersions },
|
||||
]);
|
||||
process.env.NPM_CONFIG_REGISTRY = registry;
|
||||
process.env.npm_config_registry = registry;
|
||||
|
||||
const rawNpmRoot = path.join(rootDir, "raw-managed-npm");
|
||||
await fs.mkdir(rawNpmRoot, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(rawNpmRoot, "package.json"),
|
||||
`${JSON.stringify({ private: true, dependencies: { [packageName]: "1.0.0" } }, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await execFileAsync(
|
||||
"npm",
|
||||
["install", "--ignore-scripts", "--no-audit", "--no-fund", "--loglevel=error"],
|
||||
{
|
||||
cwd: rawNpmRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
NPM_CONFIG_REGISTRY: registry,
|
||||
NPM_CONFIG_LEGACY_PEER_DEPS: "false",
|
||||
NPM_CONFIG_STRICT_PEER_DEPS: "false",
|
||||
npm_config_registry: registry,
|
||||
npm_config_legacy_peer_deps: "false",
|
||||
npm_config_strict_peer_deps: "false",
|
||||
},
|
||||
timeout: 120_000,
|
||||
},
|
||||
);
|
||||
const rawLock = JSON.parse(
|
||||
await fs.readFile(path.join(rawNpmRoot, "package-lock.json"), "utf8"),
|
||||
) as {
|
||||
packages?: Record<string, unknown>;
|
||||
};
|
||||
expect(rawLock.packages?.["node_modules/openclaw"]).toMatchObject({
|
||||
peer: true,
|
||||
version: "2026.0.0",
|
||||
});
|
||||
|
||||
const result = await installPluginFromNpmSpec({
|
||||
spec: `${packageName}@1.0.0`,
|
||||
npmDir: npmRoot,
|
||||
logger: { info: () => {}, warn: () => {} },
|
||||
timeoutMs: 120_000,
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
const lock = JSON.parse(await fs.readFile(path.join(npmRoot, "package-lock.json"), "utf8")) as {
|
||||
packages?: Record<string, unknown>;
|
||||
};
|
||||
expect(lock.packages?.["node_modules/openclaw"]).toBeUndefined();
|
||||
await expect(fs.lstat(path.join(npmRoot, "node_modules", "openclaw"))).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
await expect(
|
||||
fs
|
||||
.lstat(path.join(result.targetDir, "node_modules", "openclaw"))
|
||||
.then((stat) => stat.isSymbolicLink()),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("relinks managed npm sibling openclaw peers after later plugin installs", async () => {
|
||||
const rootDir = await makeTempDir("npm-plugin-peer-e2e");
|
||||
const npmRoot = path.join(rootDir, "managed-npm");
|
||||
const peerPackageName = `peer-plugin-${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
||||
const laterPackageName = `later-plugin-${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
||||
const peerVersions = [
|
||||
await packPlugin({
|
||||
packageName: peerPackageName,
|
||||
peerDependencies: { openclaw: ">=2026.0.0" },
|
||||
pluginId: peerPackageName,
|
||||
version: "1.0.0",
|
||||
rootDir,
|
||||
}),
|
||||
];
|
||||
const laterVersions = [
|
||||
await packPlugin({
|
||||
packageName: laterPackageName,
|
||||
pluginId: laterPackageName,
|
||||
version: "1.0.0",
|
||||
rootDir,
|
||||
}),
|
||||
];
|
||||
const registry = await startStaticRegistry([
|
||||
{ packageName: peerPackageName, latest: "1.0.0", versions: peerVersions },
|
||||
{ packageName: laterPackageName, latest: "1.0.0", versions: laterVersions },
|
||||
]);
|
||||
process.env.NPM_CONFIG_REGISTRY = registry;
|
||||
process.env.npm_config_registry = registry;
|
||||
|
||||
const first = await installPluginFromNpmSpec({
|
||||
spec: `${peerPackageName}@1.0.0`,
|
||||
npmDir: npmRoot,
|
||||
logger: { info: () => {}, warn: () => {} },
|
||||
timeoutMs: 120_000,
|
||||
});
|
||||
if (!first.ok) {
|
||||
throw new Error(first.error);
|
||||
}
|
||||
const peerLink = path.join(first.targetDir, "node_modules", "openclaw");
|
||||
await expect(fs.lstat(peerLink).then((stat) => stat.isSymbolicLink())).resolves.toBe(true);
|
||||
|
||||
const second = await installPluginFromNpmSpec({
|
||||
spec: `${laterPackageName}@1.0.0`,
|
||||
npmDir: npmRoot,
|
||||
logger: { info: () => {}, warn: () => {} },
|
||||
timeoutMs: 120_000,
|
||||
});
|
||||
if (!second.ok) {
|
||||
throw new Error(second.error);
|
||||
}
|
||||
|
||||
await expect(fs.lstat(peerLink).then((stat) => stat.isSymbolicLink())).resolves.toBe(true);
|
||||
const manifest = JSON.parse(await fs.readFile(path.join(npmRoot, "package.json"), "utf8")) as {
|
||||
dependencies?: Record<string, string>;
|
||||
};
|
||||
expect(manifest.dependencies?.openclaw).toBeUndefined();
|
||||
const lock = JSON.parse(await fs.readFile(path.join(npmRoot, "package-lock.json"), "utf8")) as {
|
||||
packages?: Record<string, unknown>;
|
||||
};
|
||||
expect(lock.packages?.["node_modules/openclaw"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("pins a mutable npm tag to the version resolved before install", async () => {
|
||||
const rootDir = await makeTempDir("npm-plugin-e2e");
|
||||
const npmRoot = path.join(rootDir, "managed-npm");
|
||||
|
||||
@@ -141,6 +141,7 @@ type MockNpmPackage = {
|
||||
versions?: string[];
|
||||
installedVersion?: string;
|
||||
installedIntegrity?: string;
|
||||
materializesRootOpenClaw?: boolean;
|
||||
skipLockfileEntry?: boolean;
|
||||
};
|
||||
|
||||
@@ -162,6 +163,12 @@ function writeNpmRootPackageLock(params: {
|
||||
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"),
|
||||
@@ -170,6 +177,31 @@ function writeNpmRootPackageLock(params: {
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -186,6 +218,7 @@ function mockNpmViewAndInstall(params: {
|
||||
versions?: string[];
|
||||
installedVersion?: string;
|
||||
installedIntegrity?: string;
|
||||
materializesRootOpenClaw?: boolean;
|
||||
skipLockfileEntry?: boolean;
|
||||
}) {
|
||||
mockNpmViewAndInstallMany([params]);
|
||||
@@ -231,6 +264,7 @@ function mockNpmViewAndInstallMany(packages: MockNpmPackage[]) {
|
||||
dependencies?: Record<string, string>;
|
||||
};
|
||||
const installedPackages: MockNpmPackage[] = [];
|
||||
prunePluginLocalOpenClawPeerLinks(npmRoot);
|
||||
for (const packageName of Object.keys(manifest.dependencies ?? {})) {
|
||||
const pkg = packagesByName.get(packageName);
|
||||
if (!pkg) {
|
||||
@@ -246,6 +280,15 @@ function mockNpmViewAndInstallMany(packages: MockNpmPackage[]) {
|
||||
...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({
|
||||
@@ -257,6 +300,19 @@ function mockNpmViewAndInstallMany(packages: MockNpmPackage[]) {
|
||||
}
|
||||
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 ?? ""}`);
|
||||
@@ -504,9 +560,142 @@ describe("installPluginFromNpmSpec", () => {
|
||||
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[] = [];
|
||||
@@ -545,6 +734,19 @@ describe("installPluginFromNpmSpec", () => {
|
||||
|
||||
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(
|
||||
@@ -559,6 +761,7 @@ describe("installPluginFromNpmSpec", () => {
|
||||
);
|
||||
}
|
||||
if (argv[0] === "npm" && argv[1] === "install") {
|
||||
fs.rmSync(peerLink, { recursive: true, force: true });
|
||||
return {
|
||||
code: 1,
|
||||
stdout: "",
|
||||
@@ -568,6 +771,9 @@ describe("installPluginFromNpmSpec", () => {
|
||||
termination: "exit" as const,
|
||||
};
|
||||
}
|
||||
if (argv[0] === "npm" && argv[1] === "uninstall") {
|
||||
return successfulSpawn("");
|
||||
}
|
||||
throw new Error(`unexpected command: ${argv.join(" ")}`);
|
||||
});
|
||||
|
||||
@@ -588,6 +794,7 @@ describe("installPluginFromNpmSpec", () => {
|
||||
).resolves.toMatchObject({
|
||||
dependencies: {},
|
||||
});
|
||||
expect(fs.lstatSync(peerLink).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it("rolls back installed npm package debris when security scan blocks the plugin", async () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { resolveNpmIntegrityDriftWithDefaultMessage } from "../infra/npm-integrity.js";
|
||||
import {
|
||||
readManagedNpmRootInstalledDependency,
|
||||
repairManagedNpmRootOpenClawPeer,
|
||||
removeManagedNpmRootDependency,
|
||||
resolveManagedNpmRootDependencySpec,
|
||||
upsertManagedNpmRootDependency,
|
||||
@@ -47,7 +48,10 @@ import {
|
||||
type PackageManifest as PluginPackageManifest,
|
||||
} from "./manifest.js";
|
||||
import { validatePackageExtensionEntriesForInstall } from "./package-entry-resolution.js";
|
||||
import { linkOpenClawPeerDependencies } from "./plugin-peer-link.js";
|
||||
import {
|
||||
linkOpenClawPeerDependencies,
|
||||
relinkOpenClawPeerDependenciesInManagedNpmRoot,
|
||||
} from "./plugin-peer-link.js";
|
||||
|
||||
export { resolvePluginInstallDir } from "./install-paths.js";
|
||||
|
||||
@@ -350,6 +354,16 @@ async function rollbackManagedNpmPluginInstall(params: {
|
||||
`Failed to remove managed npm dependency ${params.packageName}: ${String(error)}`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
await relinkOpenClawPeerDependenciesInManagedNpmRoot({
|
||||
npmRoot: params.npmRoot,
|
||||
logger: params.logger,
|
||||
});
|
||||
} catch (error) {
|
||||
params.logger.warn?.(
|
||||
`Failed to repair managed npm peer links after rollback for ${params.packageName}: ${String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveInstalledNpmResolutionMismatch(params: {
|
||||
@@ -1335,6 +1349,16 @@ export async function installPluginFromNpmSpec(
|
||||
}
|
||||
|
||||
logger.info?.(`Installing ${spec} into ${npmRoot}…`);
|
||||
if (parsedSpec.name !== "openclaw") {
|
||||
const repairedOpenClawPeer = await repairManagedNpmRootOpenClawPeer({
|
||||
npmRoot,
|
||||
timeoutMs,
|
||||
logger,
|
||||
});
|
||||
if (repairedOpenClawPeer) {
|
||||
logger.info?.(`Repaired stale openclaw peer dependency in ${npmRoot}`);
|
||||
}
|
||||
}
|
||||
await upsertManagedNpmRootDependency({
|
||||
npmRoot,
|
||||
packageName: parsedSpec.name,
|
||||
@@ -1362,15 +1386,29 @@ export async function installPluginFromNpmSpec(
|
||||
},
|
||||
);
|
||||
if (install.code !== 0) {
|
||||
await removeManagedNpmRootDependency({
|
||||
await rollbackManagedNpmPluginInstall({
|
||||
npmRoot,
|
||||
packageName: parsedSpec.name,
|
||||
targetDir: installRoot,
|
||||
timeoutMs,
|
||||
logger,
|
||||
});
|
||||
return {
|
||||
ok: false,
|
||||
error: `npm install failed: ${install.stderr.trim() || install.stdout.trim()}`,
|
||||
};
|
||||
}
|
||||
if (parsedSpec.name !== "openclaw") {
|
||||
const repairedOpenClawPeer = await repairManagedNpmRootOpenClawPeer({
|
||||
npmRoot,
|
||||
timeoutMs,
|
||||
logger,
|
||||
});
|
||||
if (repairedOpenClawPeer) {
|
||||
logger.info?.(`Repaired stale openclaw peer dependency in ${npmRoot} after npm install`);
|
||||
}
|
||||
}
|
||||
await relinkOpenClawPeerDependenciesInManagedNpmRoot({ npmRoot, logger });
|
||||
|
||||
let installedDependency: ManagedNpmRootInstalledDependency | null;
|
||||
try {
|
||||
|
||||
@@ -7,6 +7,76 @@ type PluginPeerLinkLogger = {
|
||||
warn?: (message: string) => void;
|
||||
};
|
||||
|
||||
type RelinkManagedNpmRootResult = {
|
||||
checked: number;
|
||||
attempted: number;
|
||||
};
|
||||
|
||||
function readStringRecord(value: unknown): Record<string, string> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
const record: Record<string, string> = {};
|
||||
for (const [key, raw] of Object.entries(value)) {
|
||||
if (typeof raw === "string") {
|
||||
record[key] = raw;
|
||||
}
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
async function readPackagePeerDependencies(packageDir: string): Promise<Record<string, string>> {
|
||||
try {
|
||||
const raw = await fs.readFile(path.join(packageDir, "package.json"), "utf8");
|
||||
const parsed = JSON.parse(raw) as { peerDependencies?: unknown };
|
||||
return readStringRecord(parsed.peerDependencies);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function listManagedNpmRootPackageDirs(npmRoot: string): Promise<string[]> {
|
||||
const nodeModulesDir = path.join(npmRoot, "node_modules");
|
||||
let entries: import("node:fs").Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(nodeModulesDir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const packageDirs: string[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name === ".bin") {
|
||||
continue;
|
||||
}
|
||||
const entryPath = path.join(nodeModulesDir, entry.name);
|
||||
if (entry.name.startsWith("@")) {
|
||||
const scopedEntries = await fs.readdir(entryPath, { withFileTypes: true }).catch((error) => {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
for (const scopedEntry of scopedEntries) {
|
||||
if (scopedEntry.isDirectory()) {
|
||||
packageDirs.push(path.join(entryPath, scopedEntry.name));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!entry.name.startsWith(".")) {
|
||||
packageDirs.push(entryPath);
|
||||
}
|
||||
}
|
||||
return packageDirs.toSorted((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
/**
|
||||
* Symlink the host openclaw package for plugins that declare it as a peer.
|
||||
* Plugin package managers still own third-party dependencies; this only wires
|
||||
@@ -49,3 +119,25 @@ export async function linkOpenClawPeerDependencies(params: {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function relinkOpenClawPeerDependenciesInManagedNpmRoot(params: {
|
||||
npmRoot: string;
|
||||
logger: PluginPeerLinkLogger;
|
||||
}): Promise<RelinkManagedNpmRootResult> {
|
||||
let checked = 0;
|
||||
let attempted = 0;
|
||||
for (const packageDir of await listManagedNpmRootPackageDirs(params.npmRoot)) {
|
||||
const peerDependencies = await readPackagePeerDependencies(packageDir);
|
||||
if (!Object.hasOwn(peerDependencies, "openclaw")) {
|
||||
continue;
|
||||
}
|
||||
checked += 1;
|
||||
await linkOpenClawPeerDependencies({
|
||||
installedDir: packageDir,
|
||||
peerDependencies,
|
||||
logger: params.logger,
|
||||
});
|
||||
attempted += 1;
|
||||
}
|
||||
return { checked, attempted };
|
||||
}
|
||||
|
||||
@@ -993,6 +993,54 @@ describe("uninstallPlugin", () => {
|
||||
await expect(fs.access(pluginDir)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("repairs remaining npm plugin openclaw peer links after npm uninstall prunes them", async () => {
|
||||
const stateDir = path.join(tempDir, "state");
|
||||
const npmRoot = path.join(stateDir, "npm");
|
||||
const removedPluginDir = path.join(npmRoot, "node_modules", "removed-plugin");
|
||||
const peerPluginDir = path.join(npmRoot, "node_modules", "peer-plugin");
|
||||
const peerLink = path.join(peerPluginDir, "node_modules", "openclaw");
|
||||
await fs.mkdir(removedPluginDir, { recursive: true });
|
||||
await fs.mkdir(path.dirname(peerLink), { recursive: true });
|
||||
await fs.writeFile(path.join(removedPluginDir, "package.json"), "{}\n");
|
||||
await fs.writeFile(
|
||||
path.join(peerPluginDir, "package.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
name: "peer-plugin",
|
||||
version: "1.0.0",
|
||||
peerDependencies: { openclaw: ">=2026.0.0" },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
await fs.symlink(tempDir, peerLink, "junction");
|
||||
runCommandWithTimeoutMock.mockImplementationOnce(async () => {
|
||||
await fs.rm(peerLink, { recursive: true, force: true });
|
||||
return {
|
||||
code: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
signal: null,
|
||||
killed: false,
|
||||
termination: "exit",
|
||||
};
|
||||
});
|
||||
|
||||
const applied = await applyPluginUninstallDirectoryRemoval({
|
||||
target: removedPluginDir,
|
||||
cleanup: {
|
||||
kind: "npm",
|
||||
npmRoot,
|
||||
packageName: "removed-plugin",
|
||||
},
|
||||
});
|
||||
|
||||
expect(applied).toEqual({ directoryRemoved: true, warnings: [] });
|
||||
await expect(fs.access(removedPluginDir)).rejects.toThrow();
|
||||
await expect(fs.lstat(peerLink).then((stat) => stat.isSymbolicLink())).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("skips npm cleanup when the managed package directory is already absent", async () => {
|
||||
const stateDir = path.join(tempDir, "state");
|
||||
const npmRoot = path.join(stateDir, "npm");
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
resolveDefaultPluginNpmDir,
|
||||
resolvePluginInstallDir,
|
||||
} from "./install-paths.js";
|
||||
import { relinkOpenClawPeerDependenciesInManagedNpmRoot } from "./plugin-peer-link.js";
|
||||
import { defaultSlotIdForKey } from "./slots.js";
|
||||
|
||||
export type UninstallActions = {
|
||||
@@ -616,6 +617,18 @@ export async function applyPluginUninstallDirectoryRemoval(
|
||||
}`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
await relinkOpenClawPeerDependenciesInManagedNpmRoot({
|
||||
npmRoot: removal.cleanup.npmRoot,
|
||||
logger: {
|
||||
warn: (message) => warnings.push(message),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
warnings.push(
|
||||
`Failed to repair managed npm peer links after uninstalling ${removal.cleanup.packageName}: ${formatErrorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await fs.rm(removal.target, { recursive: true, force: true });
|
||||
|
||||
Reference in New Issue
Block a user