diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d43bece492..496249ae0b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ 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/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/src/infra/npm-managed-root.test.ts b/src/infra/npm-managed-root.test.ts index f7a8278bdc6..90928fa06bf 100644 --- a/src/infra/npm-managed-root.test.ts +++ b/src/infra/npm-managed-root.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { + repairManagedNpmRootOpenClawPeer, removeManagedNpmRootDependency, readManagedNpmRootInstalledDependency, resolveManagedNpmRootDependencySpec, @@ -167,4 +168,80 @@ 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 expect(repairManagedNpmRootOpenClawPeer({ npmRoot })).resolves.toBe(true); + + 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", + }); + }); }); diff --git a/src/infra/npm-managed-root.ts b/src/infra/npm-managed-root.ts index 74adc2943e0..db0f88d11f7 100644 --- a/src/infra/npm-managed-root.ts +++ b/src/infra/npm-managed-root.ts @@ -15,6 +15,12 @@ export type ManagedNpmRootInstalledDependency = { resolved?: string; }; +type ManagedNpmRootLockfile = { + packages?: Record; + dependencies?: Record; + [key: string]: unknown; +}; + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -75,6 +81,78 @@ export async function upsertManagedNpmRootDependency(params: { await fs.writeFile(manifestPath, `${JSON.stringify(next, null, 2)}\n`, "utf8"); } +export async function repairManagedNpmRootOpenClawPeer(params: { + npmRoot: string; +}): Promise { + let changed = false; + + 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); + if ("openclaw" in dependencies) { + const { openclaw: _removed, ...nextDependencies } = dependencies; + await fs.writeFile( + manifestPath, + `${JSON.stringify({ ...manifest, private: true, dependencies: nextDependencies }, null, 2)}\n`, + "utf8", + ); + changed = true; + } + + 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"); + changed = true; + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") { + throw err; + } + } + + const openclawPackageDir = path.join(params.npmRoot, "node_modules", "openclaw"); + const openclawPackageDirExists = await fs + .lstat(openclawPackageDir) + .then(() => true) + .catch((err: NodeJS.ErrnoException) => { + if (err.code === "ENOENT") { + return false; + } + throw err; + }); + if (openclawPackageDirExists) { + await fs.rm(openclawPackageDir, { recursive: true, force: true }); + changed = true; + } + + return changed; +} + export async function readManagedNpmRootInstalledDependency(params: { npmRoot: string; packageName: string; diff --git a/src/plugins/install.npm-spec.test.ts b/src/plugins/install.npm-spec.test.ts index ad07956efbb..b9a2aaba530 100644 --- a/src/plugins/install.npm-spec.test.ts +++ b/src/plugins/install.npm-spec.test.ts @@ -507,6 +507,94 @@ describe("installPluginFromNpmSpec", () => { }, ); + 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[] = []; diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 33399abcc68..253fd8bed6a 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, @@ -1335,6 +1336,12 @@ export async function installPluginFromNpmSpec( } logger.info?.(`Installing ${spec} into ${npmRoot}…`); + if (parsedSpec.name !== "openclaw") { + const repairedOpenClawPeer = await repairManagedNpmRootOpenClawPeer({ npmRoot }); + if (repairedOpenClawPeer) { + logger.info?.(`Repaired stale openclaw peer dependency in ${npmRoot}`); + } + } await upsertManagedNpmRootDependency({ npmRoot, packageName: parsedSpec.name,