From 797d02497e8e5121201c80b16aa77d07482a4b1d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 3 May 2026 21:39:45 +0100 Subject: [PATCH] fix: prune stale plugin runtime symlinks --- scripts/e2e/lib/upgrade-survivor/run.sh | 3 +- scripts/postinstall-bundled-plugins.mjs | 96 +++++++++- .../shared/plugin-dependency-cleanup.test.ts | 37 ++++ .../shared/plugin-dependency-cleanup.ts | 20 +- .../doctor/shared/plugin-runtime-symlinks.ts | 175 ++++++++++++++++++ src/flows/doctor-health.ts | 3 + .../postinstall-bundled-plugins.test.ts | 37 ++++ 7 files changed, 366 insertions(+), 5 deletions(-) create mode 100644 src/commands/doctor/shared/plugin-runtime-symlinks.ts diff --git a/scripts/e2e/lib/upgrade-survivor/run.sh b/scripts/e2e/lib/upgrade-survivor/run.sh index 9902dbaff5d..83cc4d5881f 100644 --- a/scripts/e2e/lib/upgrade-survivor/run.sh +++ b/scripts/e2e/lib/upgrade-survivor/run.sh @@ -252,7 +252,7 @@ legacy_runtime_deps_symlink_plugin() { legacy_runtime_deps_symlink_target() { local plugin="$1" - printf '%s/dist/extensions/%s/node_modules\n' "$(package_root)" "$plugin" + printf '%s/@openclaw-upgrade-survivor/%s-runtime-dep\n' "$(dirname "$(package_root)")" "$plugin" } legacy_runtime_deps_symlink_source() { @@ -431,6 +431,7 @@ seed_legacy_runtime_deps_symlink() { source_dir="$(legacy_runtime_deps_symlink_source "$plugin")" target_dir="$(legacy_runtime_deps_symlink_target "$plugin")" mkdir -p "$source_dir" + mkdir -p "$(dirname "$target_dir")" printf '{"name":"openclaw-upgrade-survivor-legacy-runtime-deps","version":"0.0.0"}\n' \ >"$source_dir/package.json" rm -rf "$target_dir" diff --git a/scripts/postinstall-bundled-plugins.mjs b/scripts/postinstall-bundled-plugins.mjs index 6483295e617..23c1e2a6816 100644 --- a/scripts/postinstall-bundled-plugins.mjs +++ b/scripts/postinstall-bundled-plugins.mjs @@ -11,6 +11,7 @@ import { openSync, readdirSync, readFileSync, + readlinkSync, realpathSync, renameSync, rmdirSync, @@ -366,13 +367,101 @@ export function collectLegacyPluginRuntimeDepsStateRoots(params = {}) { ); } +function isPathInsideRoot(candidate, root) { + const relativePath = relative(root, candidate); + return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath)); +} + +function collectLegacyPluginRuntimeDepsSymlinkPaths(roots, params = {}) { + const packageRoot = params.packageRoot ?? DEFAULT_PACKAGE_ROOT; + const readDir = params.readdirSync ?? readdirSync; + const pathLstat = params.lstatSync ?? lstatSync; + const readLink = params.readlinkSync ?? readlinkSync; + const pathExists = params.existsSync ?? existsSync; + const containingNodeModules = dirname(packageRoot); + if (basename(containingNodeModules) !== "node_modules") { + return []; + } + + const normalizedRoots = roots.map((root) => pathResolve(root)); + const candidates = []; + function addCandidate(linkPath) { + let linkStat; + try { + linkStat = pathLstat(linkPath); + } catch { + return; + } + if (!linkStat.isSymbolicLink()) { + return; + } + let target; + try { + target = readLink(linkPath); + } catch { + return; + } + if (!target.includes(LEGACY_PLUGIN_RUNTIME_DEPS_DIR)) { + return; + } + const resolvedTarget = pathResolve(dirname(linkPath), target); + const pointsIntoPrunedRoot = normalizedRoots.some((root) => + isPathInsideRoot(resolvedTarget, root), + ); + if (pointsIntoPrunedRoot || !pathExists(resolvedTarget)) { + candidates.push(linkPath); + } + } + + let entries; + try { + entries = readDir(containingNodeModules, { withFileTypes: true }); + } catch { + return []; + } + for (const entry of entries) { + if (entry.isDirectory() && entry.name.startsWith("@")) { + const scopeDir = join(containingNodeModules, entry.name); + let scopeEntries; + try { + scopeEntries = readDir(scopeDir, { withFileTypes: true }); + } catch { + continue; + } + for (const scopeEntry of scopeEntries) { + addCandidate(join(scopeDir, scopeEntry.name)); + } + continue; + } + if (entry.isSymbolicLink()) { + addCandidate(join(containingNodeModules, entry.name)); + } + } + return [...new Set(candidates.map((entry) => pathResolve(entry)))].toSorted((left, right) => + left.localeCompare(right), + ); +} + export function pruneLegacyPluginRuntimeDepsState(params = {}) { const pathExists = params.existsSync ?? existsSync; const removePath = params.rmSync ?? rmSync; const log = params.log ?? console; const removed = []; + const removedSymlinks = []; + const roots = collectLegacyPluginRuntimeDepsStateRoots(params); - for (const root of collectLegacyPluginRuntimeDepsStateRoots(params)) { + for (const linkPath of collectLegacyPluginRuntimeDepsSymlinkPaths(roots, params)) { + try { + removePath(linkPath, { force: true }); + removedSymlinks.push(linkPath); + } catch (error) { + log.warn?.( + `[postinstall] could not prune legacy plugin runtime deps symlink ${linkPath}: ${String(error)}`, + ); + } + } + + for (const root of roots) { if (!pathExists(root)) { continue; } @@ -389,6 +478,11 @@ export function pruneLegacyPluginRuntimeDepsState(params = {}) { if (removed.length > 0) { log.log?.(`[postinstall] pruned legacy plugin runtime deps: ${removed.join(", ")}`); } + if (removedSymlinks.length > 0) { + log.log?.( + `[postinstall] pruned legacy plugin runtime deps symlinks: ${removedSymlinks.join(", ")}`, + ); + } return removed; } diff --git a/src/commands/doctor/shared/plugin-dependency-cleanup.test.ts b/src/commands/doctor/shared/plugin-dependency-cleanup.test.ts index 28dcd34d3ba..07ebbda2dc6 100644 --- a/src/commands/doctor/shared/plugin-dependency-cleanup.test.ts +++ b/src/commands/doctor/shared/plugin-dependency-cleanup.test.ts @@ -91,4 +91,41 @@ describe("cleanupLegacyPluginDependencyState", () => { await expect(fs.stat(explicitStageDir)).rejects.toThrow(); await expect(fs.stat(path.join(stateDirectory, "plugin-runtime-deps"))).rejects.toThrow(); }); + + it("removes dangling global plugin-runtime symlinks that point at legacy runtime deps", async () => { + const stateDir = path.join(tempDir, "state"); + const packageRoot = path.join(tempDir, "prefix", "lib", "node_modules", "openclaw"); + const nodeModulesRoot = path.dirname(packageRoot); + const legacyRuntimeRoot = path.join(stateDir, "plugin-runtime-deps"); + const legacyTarget = path.join( + legacyRuntimeRoot, + "openclaw-2026.4.29-slack", + "node_modules", + "@slack", + "web-api", + ); + const slackScope = path.join(nodeModulesRoot, "@slack"); + const slackLink = path.join(slackScope, "web-api"); + const liveTarget = path.join(tempDir, "live", "@slack", "bolt"); + const liveLink = path.join(slackScope, "bolt"); + + await fs.mkdir(legacyTarget, { recursive: true }); + await fs.writeFile(path.join(legacyTarget, "package.json"), "{}\n"); + await fs.mkdir(liveTarget, { recursive: true }); + await fs.writeFile(path.join(liveTarget, "package.json"), "{}\n"); + await fs.mkdir(slackScope, { recursive: true }); + await fs.mkdir(packageRoot, { recursive: true }); + await fs.symlink(legacyTarget, slackLink, "dir"); + await fs.symlink(liveTarget, liveLink, "dir"); + + const result = await cleanupLegacyPluginDependencyState({ + env: { OPENCLAW_STATE_DIR: stateDir }, + packageRoot, + }); + + expect(result.warnings).toEqual([]); + expect(result.changes).toContain(`Removed stale plugin-runtime symlink: ${slackLink}`); + await expect(fs.lstat(slackLink)).rejects.toThrow(); + await expect(fs.lstat(liveLink)).resolves.toBeDefined(); + }); }); diff --git a/src/commands/doctor/shared/plugin-dependency-cleanup.ts b/src/commands/doctor/shared/plugin-dependency-cleanup.ts index 652ed274eee..d426d086898 100644 --- a/src/commands/doctor/shared/plugin-dependency-cleanup.ts +++ b/src/commands/doctor/shared/plugin-dependency-cleanup.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { resolveStateDir } from "../../../config/paths.js"; import { resolveOpenClawPackageRootSync } from "../../../infra/openclaw-root.js"; import { resolveConfigDir, resolveUserPath } from "../../../utils.js"; +import { removeStalePluginRuntimeSymlinks } from "./plugin-runtime-symlinks.js"; const LEGACY_DIRECT_CHILD_NAMES = new Set(["plugin-runtime-deps", "bundled-plugin-runtime-deps"]); @@ -122,9 +123,22 @@ export async function cleanupLegacyPluginDependencyState(params: { const env = params.env ?? process.env; const changes: string[] = []; const warnings: string[] = []; - for (const target of await collectLegacyPluginDependencyTargets(env, { - packageRoot: params.packageRoot, - })) { + const packageRoot = + params.packageRoot ?? + resolveOpenClawPackageRootSync({ + argv1: process.argv[1], + moduleUrl: import.meta.url, + cwd: process.cwd(), + }); + const targets = await collectLegacyPluginDependencyTargets(env, { + packageRoot, + }); + const staleSymlinks = await removeStalePluginRuntimeSymlinks(packageRoot, { + staleRoots: targets, + }); + changes.push(...staleSymlinks.changes); + warnings.push(...staleSymlinks.warnings); + for (const target of targets) { if (!(await pathExists(target))) { continue; } diff --git a/src/commands/doctor/shared/plugin-runtime-symlinks.ts b/src/commands/doctor/shared/plugin-runtime-symlinks.ts new file mode 100644 index 00000000000..323d8bde9a4 --- /dev/null +++ b/src/commands/doctor/shared/plugin-runtime-symlinks.ts @@ -0,0 +1,175 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { note } from "../../../terminal/note.js"; +import { shortenHomePath } from "../../../utils.js"; + +const PLUGIN_RUNTIME_DEPS_MARKER = "plugin-runtime-deps"; +const MAX_REPORTED = 6; + +interface FsLike { + readdir(dir: string, options: { withFileTypes: true }): Promise; + lstat(file: string): Promise; + readlink(file: string): Promise; + stat(file: string): Promise; + rm(file: string, options: { force: true }): Promise; +} + +interface DirentLike { + name: string; + isDirectory(): boolean; + isSymbolicLink(): boolean; +} + +interface StatsLike { + isSymbolicLink(): boolean; +} + +export interface StalePluginRuntimeSymlink { + readonly name: string; + readonly path: string; + readonly target: string; +} + +export interface PluginRuntimeSymlinkOptions { + readonly fs?: FsLike; + readonly staleRoots?: readonly string[]; +} + +const DEFAULT_FS: FsLike = { + readdir: (dir, options) => fs.readdir(dir, options) as Promise, + lstat: (file) => fs.lstat(file), + readlink: (file) => fs.readlink(file), + stat: (file) => fs.stat(file), + rm: (file, options) => fs.rm(file, options), +}; + +export async function collectStalePluginRuntimeSymlinks( + packageRoot: string | null | undefined, + options: PluginRuntimeSymlinkOptions = {}, +): Promise { + if (!packageRoot) { + return []; + } + const containingNodeModules = path.dirname(packageRoot); + if (path.basename(containingNodeModules) !== "node_modules") { + return []; + } + + const fsApi = options.fs ?? DEFAULT_FS; + const staleRoots = uniqueResolvedRoots(options.staleRoots ?? []); + const stale: StalePluginRuntimeSymlink[] = []; + const entries = await fsApi + .readdir(containingNodeModules, { withFileTypes: true }) + .catch(() => [] as DirentLike[]); + for (const entry of entries) { + if (entry.isDirectory() && entry.name.startsWith("@")) { + const scopeDir = path.join(containingNodeModules, entry.name); + const scopeEntries = await fsApi + .readdir(scopeDir, { withFileTypes: true }) + .catch(() => [] as DirentLike[]); + for (const scopeEntry of scopeEntries) { + const fullPath = path.join(scopeDir, scopeEntry.name); + const target = await inspectCandidate(fullPath, fsApi, staleRoots); + if (target) { + stale.push({ name: `${entry.name}/${scopeEntry.name}`, path: fullPath, target }); + } + } + continue; + } + if (!entry.isSymbolicLink()) { + continue; + } + const fullPath = path.join(containingNodeModules, entry.name); + const target = await inspectCandidate(fullPath, fsApi, staleRoots); + if (target) { + stale.push({ name: entry.name, path: fullPath, target }); + } + } + + return stale.toSorted((left, right) => left.name.localeCompare(right.name)); +} + +export async function noteStalePluginRuntimeSymlinks( + packageRoot: string | null | undefined, + options: PluginRuntimeSymlinkOptions & { + readonly noteFn?: (message: string, title?: string) => void; + readonly shortenPath?: (value: string) => string; + } = {}, +): Promise { + const stale = await collectStalePluginRuntimeSymlinks(packageRoot, options); + if (stale.length === 0) { + return; + } + + const shortenPath = options.shortenPath ?? shortenHomePath; + const lines = [ + "- Plugin-runtime symlinks under the global Node prefix point at pruned", + ` ${PLUGIN_RUNTIME_DEPS_MARKER} directories from a previous OpenClaw install.`, + "- Bundled plugin ESM imports can fail with ERR_MODULE_NOT_FOUND until repaired.", + ]; + for (const item of stale.slice(0, MAX_REPORTED)) { + lines.push(` - ${item.name} -> ${shortenPath(item.target)}`); + } + if (stale.length > MAX_REPORTED) { + lines.push(` - ...and ${stale.length - MAX_REPORTED} more`); + } + lines.push("- Repair: run `openclaw doctor --fix` to remove the dangling symlinks."); + (options.noteFn ?? note)(lines.join("\n"), "Plugin-runtime symlinks"); +} + +export async function removeStalePluginRuntimeSymlinks( + packageRoot: string | null | undefined, + options: PluginRuntimeSymlinkOptions = {}, +): Promise<{ changes: string[]; warnings: string[] }> { + const fsApi = options.fs ?? DEFAULT_FS; + const changes: string[] = []; + const warnings: string[] = []; + for (const item of await collectStalePluginRuntimeSymlinks(packageRoot, options)) { + try { + await fsApi.rm(item.path, { force: true }); + changes.push(`Removed stale plugin-runtime symlink: ${item.path}`); + } catch (error) { + warnings.push(`Failed to remove stale plugin-runtime symlink ${item.path}: ${String(error)}`); + } + } + return { changes, warnings }; +} + +function uniqueResolvedRoots(values: readonly string[]): string[] { + return [...new Set(values.map((value) => path.resolve(value)))].toSorted((left, right) => + left.localeCompare(right), + ); +} + +function isPathInsideRoot(candidate: string, root: string): boolean { + const relativePath = path.relative(root, candidate); + return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)); +} + +async function inspectCandidate( + fullPath: string, + fsApi: FsLike, + staleRoots: readonly string[], +): Promise { + const stat = await fsApi.lstat(fullPath).catch(() => null); + if (!stat?.isSymbolicLink()) { + return null; + } + const target = await fsApi.readlink(fullPath).catch(() => null); + if (!target || !target.includes(PLUGIN_RUNTIME_DEPS_MARKER)) { + return null; + } + const resolvedTarget = path.isAbsolute(target) + ? target + : path.resolve(path.dirname(fullPath), target); + if (staleRoots.some((root) => isPathInsideRoot(resolvedTarget, root))) { + return resolvedTarget; + } + try { + await fsApi.stat(resolvedTarget); + return null; + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + return code === "ENOENT" || code === "ENOTDIR" ? resolvedTarget : null; + } +} diff --git a/src/flows/doctor-health.ts b/src/flows/doctor-health.ts index a10ac64a0d5..b07f1e08984 100644 --- a/src/flows/doctor-health.ts +++ b/src/flows/doctor-health.ts @@ -35,9 +35,12 @@ export async function doctorCommand(runtime?: RuntimeEnv, options: DoctorOptions const { maybeRepairUiProtocolFreshness } = await import("../commands/doctor-ui.js"); const { noteSourceInstallIssues } = await import("../commands/doctor-install.js"); + const { noteStalePluginRuntimeSymlinks } = + await import("../commands/doctor/shared/plugin-runtime-symlinks.js"); const { noteStartupOptimizationHints } = await import("../commands/doctor-platform-notes.js"); await maybeRepairUiProtocolFreshness(effectiveRuntime, prompter); noteSourceInstallIssues(root); + await noteStalePluginRuntimeSymlinks(root); noteStartupOptimizationHints(); const { loadAndMaybeMigrateDoctorConfig } = await import("../commands/doctor-config-flow.js"); diff --git a/test/scripts/postinstall-bundled-plugins.test.ts b/test/scripts/postinstall-bundled-plugins.test.ts index a7abd0648a2..cb55276a07c 100644 --- a/test/scripts/postinstall-bundled-plugins.test.ts +++ b/test/scripts/postinstall-bundled-plugins.test.ts @@ -448,6 +448,43 @@ describe("bundled plugin postinstall", () => { ); }); + it("prunes global plugin-runtime symlinks before deleting their legacy targets", async () => { + const prefix = await createTempDirAsync("openclaw-packaged-prefix-"); + const home = await createTempDirAsync("openclaw-packaged-home-"); + const packageRoot = path.join(prefix, "lib", "node_modules", "openclaw"); + const nodeModulesRoot = path.dirname(packageRoot); + const legacyRuntimeRoot = path.join(home, ".openclaw", "plugin-runtime-deps"); + const legacyTarget = path.join( + legacyRuntimeRoot, + "openclaw-2026.4.29-slack", + "node_modules", + "@slack", + "web-api", + ); + const slackScope = path.join(nodeModulesRoot, "@slack"); + const slackLink = path.join(slackScope, "web-api"); + + await fs.mkdir(legacyTarget, { recursive: true }); + await fs.writeFile(path.join(legacyTarget, "package.json"), "{}\n"); + await fs.mkdir(slackScope, { recursive: true }); + await fs.mkdir(packageRoot, { recursive: true }); + await fs.symlink(legacyTarget, slackLink, "dir"); + + const log = { log: vi.fn(), warn: vi.fn() }; + pruneLegacyPluginRuntimeDepsState({ + env: { HOME: home }, + packageRoot, + log, + }); + + await expect(fs.lstat(slackLink)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(fs.stat(legacyRuntimeRoot)).rejects.toMatchObject({ code: "ENOENT" }); + expect(log.warn).not.toHaveBeenCalled(); + expect(log.log).toHaveBeenCalledWith( + expect.stringContaining("[postinstall] pruned legacy plugin runtime deps symlinks:"), + ); + }); + it("keeps legacy plugin runtime deps cleanup non-fatal", () => { const warn = vi.fn();