diff --git a/CHANGELOG.md b/CHANGELOG.md index b538fe8782a..3226787f692 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/plugins/dependency-resolution.md b/docs/plugins/dependency-resolution.md index 1e6e0410161..fd7a4364dfb 100644 --- a/docs/plugins/dependency-resolution.md +++ b/docs/plugins/dependency-resolution.md @@ -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 diff --git a/src/infra/npm-install-env.ts b/src/infra/npm-install-env.ts index 7d328c3112a..cf29354cc8b 100644 --- a/src/infra/npm-install-env.ts +++ b/src/infra/npm-install-env.ts @@ -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", ]); diff --git a/src/infra/npm-managed-root.test.ts b/src/infra/npm-managed-root.test.ts index 4ab457ce921..8524288a3d7 100644 --- a/src/infra/npm-managed-root.test.ts +++ b/src/infra/npm-managed-root.test.ts @@ -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 { 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; + }; + 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; version?: string }>; + dependencies?: Record; + }; + 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", + }); + }); }); diff --git a/src/infra/npm-managed-root.ts b/src/infra/npm-managed-root.ts index a700d7cc388..d06fcde46b4 100644 --- a/src/infra/npm-managed-root.ts +++ b/src/infra/npm-managed-root.ts @@ -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; + dependencies?: Record; + [key: string]: unknown; +}; + +type ManagedNpmRootLogger = { + warn?: (message: string) => void; +}; + +type ManagedNpmRootRunCommand = typeof runCommandWithTimeout; + function isRecord(value: unknown): value is Record { 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 { + 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 { + 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 { + 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 { + 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; diff --git a/src/infra/safe-package-install.test.ts b/src/infra/safe-package-install.test.ts index 98caedccf43..9c64e398435 100644 --- a/src/infra/safe-package-install.test.ts +++ b/src/infra/safe-package-install.test.ts @@ -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( diff --git a/src/infra/safe-package-install.ts b/src/infra/safe-package-install.ts index 72f318981d3..001f3353019 100644 --- a/src/infra/safe-package-install.ts +++ b/src/infra/safe-package-install.ts @@ -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, { diff --git a/src/plugins/install.npm-spec.e2e.test.ts b/src/plugins/install.npm-spec.e2e.test.ts index 20d06fafcf7..bc6dc747cbe 100644 --- a/src/plugins/install.npm-spec.e2e.test.ts +++ b/src/plugins/install.npm-spec.e2e.test.ts @@ -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; + peerDependenciesMeta?: Record; 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 { async function packPlugin(params: { packageName: string; + peerDependencies?: Record; + peerDependenciesMeta?: Record; pluginId: string; version: string; rootDir: string; }): Promise { - 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 { + 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((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; + }; + 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; + }; + 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; + }; + expect(manifest.dependencies?.openclaw).toBeUndefined(); + const lock = JSON.parse(await fs.readFile(path.join(npmRoot, "package-lock.json"), "utf8")) as { + packages?: Record; + }; + 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"); diff --git a/src/plugins/install.npm-spec.test.ts b/src/plugins/install.npm-spec.test.ts index ad07956efbb..15c12ff4890 100644 --- a/src/plugins/install.npm-spec.test.ts +++ b/src/plugins/install.npm-spec.test.ts @@ -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; }; 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; + }; + 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; + }; + 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; + dependencies?: Record; + }; + 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 () => { diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 33399abcc68..7517dc2944b 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -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 { diff --git a/src/plugins/plugin-peer-link.ts b/src/plugins/plugin-peer-link.ts index ade9fd27a97..01ebad64776 100644 --- a/src/plugins/plugin-peer-link.ts +++ b/src/plugins/plugin-peer-link.ts @@ -7,6 +7,76 @@ type PluginPeerLinkLogger = { warn?: (message: string) => void; }; +type RelinkManagedNpmRootResult = { + checked: number; + attempted: number; +}; + +function readStringRecord(value: unknown): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return {}; + } + const record: Record = {}; + for (const [key, raw] of Object.entries(value)) { + if (typeof raw === "string") { + record[key] = raw; + } + } + return record; +} + +async function readPackagePeerDependencies(packageDir: string): Promise> { + 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 { + 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 { + 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 }; +} diff --git a/src/plugins/uninstall.test.ts b/src/plugins/uninstall.test.ts index a7a8543353b..47acb3480eb 100644 --- a/src/plugins/uninstall.test.ts +++ b/src/plugins/uninstall.test.ts @@ -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"); diff --git a/src/plugins/uninstall.ts b/src/plugins/uninstall.ts index 561c7d8453d..1f1f820ee47 100644 --- a/src/plugins/uninstall.ts +++ b/src/plugins/uninstall.ts @@ -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 });