diff --git a/CHANGELOG.md b/CHANGELOG.md index da9d9b704cf..f500d2bc055 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Doctor/plugins: remove stale managed npm plugin shadow entries from the managed package lock as well as `package.json` and `node_modules`, so future npm operations do not keep referencing repaired bundled-plugin shadows. Thanks @vincentkoc. - Plugins/runtime state: keep the key being registered when namespace eviction runs in the same millisecond as existing entries, so `register` and `registerIfAbsent` do not report success while evicting their own fresh value. Thanks @vincentkoc. - Control UI/Talk: make failed Talk startup errors dismissable and clear the stale Talk error state when dismissed, so missing realtime voice provider configuration does not leave a permanent chat banner. Fixes #77071. Thanks @ijoshdavis. - Control UI/Talk: stop and clear failed realtime Talk sessions when dismissing runtime error banners, so the next Talk click starts a fresh session instead of only stopping the stale one. Thanks @vincentkoc. diff --git a/src/commands/doctor-plugin-registry.test.ts b/src/commands/doctor-plugin-registry.test.ts index aa76bb870d3..741a6ac3b0a 100644 --- a/src/commands/doctor-plugin-registry.test.ts +++ b/src/commands/doctor-plugin-registry.test.ts @@ -104,6 +104,7 @@ function createManagedNpmPlugin(params: { id: string; packageName: string; version: string; + packageLock?: boolean; }) { const npmRoot = path.join(params.stateDir, "npm"); const packageDir = path.join(npmRoot, "node_modules", params.packageName); @@ -117,6 +118,37 @@ function createManagedNpmPlugin(params: { }), "utf8", ); + if (params.packageLock) { + fs.writeFileSync( + path.join(npmRoot, "package-lock.json"), + JSON.stringify({ + lockfileVersion: 3, + packages: { + "": { + dependencies: { + [params.packageName]: params.version, + "other-plugin": "1.0.0", + }, + }, + [`node_modules/${params.packageName}`]: { + version: params.version, + }, + "node_modules/other-plugin": { + version: "1.0.0", + }, + }, + dependencies: { + [params.packageName]: { + version: params.version, + }, + "other-plugin": { + version: "1.0.0", + }, + }, + }), + "utf8", + ); + } fs.writeFileSync( path.join(packageDir, "package.json"), JSON.stringify({ @@ -301,4 +333,51 @@ describe("maybeRepairPluginRegistryState", () => { "Removed stale managed npm plugin package", ); }); + + it("removes stale managed npm packages from the package lock during repair", async () => { + const stateDir = makeTempDir(); + const bundledDir = path.join(stateDir, "bundled", "google-meet"); + fs.mkdirSync(bundledDir, { recursive: true }); + createManagedNpmPlugin({ + stateDir, + id: "google-meet", + packageName: "@openclaw/google-meet", + version: "2026.5.2", + packageLock: true, + }); + await writePersistedInstalledPluginIndex(createCurrentIndex(), { stateDir }); + + await maybeRepairPluginRegistryState({ + stateDir, + candidates: [ + createBundledCandidate({ + rootDir: bundledDir, + id: "google-meet", + packageName: "@openclaw/google-meet", + version: "2026.5.3", + }), + ], + env: hermeticEnv(), + config: { + plugins: { + allow: ["google-meet"], + entries: { + "google-meet": { + enabled: true, + config: {}, + }, + }, + }, + }, + prompter: { shouldRepair: true }, + }); + + const packageLock = JSON.parse( + fs.readFileSync(path.join(stateDir, "npm", "package-lock.json"), "utf8"), + ); + expect(packageLock.packages[""].dependencies).toEqual({ "other-plugin": "1.0.0" }); + expect(packageLock.packages).not.toHaveProperty("node_modules/@openclaw/google-meet"); + expect(packageLock.dependencies).not.toHaveProperty("@openclaw/google-meet"); + expect(packageLock.dependencies).toHaveProperty("other-plugin"); + }); }); diff --git a/src/commands/doctor-plugin-registry.ts b/src/commands/doctor-plugin-registry.ts index 42e010407e4..029ee42dc25 100644 --- a/src/commands/doctor-plugin-registry.ts +++ b/src/commands/doctor-plugin-registry.ts @@ -58,6 +58,14 @@ function readStringMap(value: unknown): Record { return result; } +function deleteObjectKey(record: Record, key: string): boolean { + if (!Object.prototype.hasOwnProperty.call(record, key)) { + return false; + } + delete record[key]; + return true; +} + function readPackageVersion(packageDir: string): string | undefined { const packageJson = readJsonObject(path.join(packageDir, "package.json")); const version = packageJson?.version; @@ -137,6 +145,7 @@ function removeManagedNpmDependency(params: { dependencies, }; saveJsonFile(npmPackageJsonPath, nextPackageJson); + removeManagedNpmPackageLockDependency(params); fs.rmSync(params.packageDir, { recursive: true, force: true }); const scopeDir = path.dirname(params.packageDir); if (path.basename(path.dirname(scopeDir)) === "node_modules") { @@ -148,6 +157,44 @@ function removeManagedNpmDependency(params: { } } +function removeManagedNpmPackageLockDependency(params: { + npmRoot: string; + packageName: string; +}): void { + const packageLockPath = path.join(params.npmRoot, "package-lock.json"); + const packageLock = readJsonObject(packageLockPath); + if (!packageLock) { + return; + } + + let changed = false; + const packages = packageLock.packages; + if (isRecord(packages)) { + const rootPackage = packages[""]; + if (isRecord(rootPackage)) { + const rootDependencies = readStringMap(rootPackage.dependencies); + if (deleteObjectKey(rootDependencies, params.packageName)) { + changed = true; + if (Object.keys(rootDependencies).length === 0) { + delete rootPackage.dependencies; + } else { + rootPackage.dependencies = rootDependencies; + } + } + } + changed = deleteObjectKey(packages, `node_modules/${params.packageName}`) || changed; + } + + const dependencies = packageLock.dependencies; + if (isRecord(dependencies)) { + changed = deleteObjectKey(dependencies, params.packageName) || changed; + } + + if (changed) { + saveJsonFile(packageLockPath, packageLock); + } +} + function maybeRepairStaleManagedNpmBundledPlugins( params: PluginRegistryDoctorRepairParams, ): boolean {