fix: prune stale plugin runtime symlinks

This commit is contained in:
Peter Steinberger
2026-05-03 21:39:45 +01:00
parent 1ace6a0d6a
commit 797d02497e
7 changed files with 366 additions and 5 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<readonly DirentLike[]>;
lstat(file: string): Promise<StatsLike>;
readlink(file: string): Promise<string>;
stat(file: string): Promise<unknown>;
rm(file: string, options: { force: true }): Promise<void>;
}
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<DirentLike[]>,
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<StalePluginRuntimeSymlink[]> {
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<void> {
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<string | null> {
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;
}
}

View File

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

View File

@@ -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();