fix(doctor): prune stale plugin lock entries

This commit is contained in:
Vincent Koc
2026-05-04 01:33:21 -07:00
parent 43bdb886e9
commit da1e1435ad
3 changed files with 127 additions and 0 deletions

View File

@@ -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.

View File

@@ -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");
});
});

View File

@@ -58,6 +58,14 @@ function readStringMap(value: unknown): Record<string, string> {
return result;
}
function deleteObjectKey(record: Record<string, unknown>, 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 {