Expose stale plugin runtime symlink doctor lint findings

This commit is contained in:
Gio Della-Libera
2026-07-01 10:39:00 -07:00
parent 48e8965b10
commit 287d2230ee
4 changed files with 233 additions and 1 deletions
@@ -0,0 +1,131 @@
// Plugin runtime symlink tests cover doctor detection of stale global symlinks.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
collectStalePluginRuntimeSymlinkHealthFindings,
collectStalePluginRuntimeSymlinks,
stalePluginRuntimeSymlinkToHealthFinding,
} from "./plugin-runtime-symlinks.js";
async function expectSymlinkPresent(targetPath: string): Promise<void> {
expect((await fs.lstat(targetPath)).isSymbolicLink()).toBe(true);
}
async function canCreateDirectorySymlink(root: string): Promise<boolean> {
const target = path.join(root, "symlink-capability-target");
const link = path.join(root, "symlink-capability-link");
await fs.mkdir(target, { recursive: true });
try {
await fs.symlink(target, link, "dir");
return (await fs.lstat(link)).isSymbolicLink();
} catch (error) {
const code = (error as NodeJS.ErrnoException | undefined)?.code;
if (code === "EPERM" || code === "EACCES" || code === "ENOTSUP") {
return false;
}
throw error;
} finally {
await fs.rm(link, { recursive: true, force: true });
await fs.rm(target, { recursive: true, force: true });
}
}
describe("plugin runtime symlink health findings", () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-plugin-runtime-symlinks-"));
});
afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
});
it("maps dangling plugin-runtime symlinks to read-only lint findings", async () => {
if (!(await canCreateDirectorySymlink(tempDir))) {
return;
}
const packageRoot = path.join(tempDir, "prefix", "lib", "node_modules", "openclaw");
const nodeModulesRoot = path.dirname(packageRoot);
const legacyRoot = path.join(tempDir, "state", "plugin-runtime-deps");
const missingTarget = path.join(
legacyRoot,
"openclaw-slack",
"node_modules",
"@slack",
"web-api",
);
const scopeRoot = path.join(nodeModulesRoot, "@slack");
const staleLink = path.join(scopeRoot, "web-api");
const liveTarget = path.join(tempDir, "live", "@slack", "bolt");
const liveLink = path.join(scopeRoot, "bolt");
await fs.mkdir(packageRoot, { recursive: true });
await fs.mkdir(scopeRoot, { recursive: true });
await fs.mkdir(liveTarget, { recursive: true });
await fs.symlink(missingTarget, staleLink, "dir");
await fs.symlink(liveTarget, liveLink, "dir");
const [stale] = await collectStalePluginRuntimeSymlinks(packageRoot);
if (!stale) {
throw new Error("expected stale plugin-runtime symlink finding");
}
expect(stale).toEqual({
name: "@slack/web-api",
path: staleLink,
target: missingTarget,
});
expect(stalePluginRuntimeSymlinkToHealthFinding(stale)).toEqual({
checkId: "core/doctor/stale-plugin-runtime-symlinks",
severity: "warning",
message: `Stale plugin-runtime symlink @slack/web-api points at ${missingTarget}.`,
path: staleLink,
target: staleLink,
requirement: "stale-plugin-runtime-symlink-removed",
fixHint: "Run `openclaw doctor --fix` to remove stale plugin-runtime symlinks.",
});
expect(await collectStalePluginRuntimeSymlinkHealthFindings({ packageRoot })).toEqual([
expect.objectContaining({
checkId: "core/doctor/stale-plugin-runtime-symlinks",
path: staleLink,
}),
]);
await expectSymlinkPresent(staleLink);
await expectSymlinkPresent(liveLink);
});
it("reports symlinks that point inside classified stale roots", async () => {
if (!(await canCreateDirectorySymlink(tempDir))) {
return;
}
const packageRoot = path.join(tempDir, "prefix", "lib", "node_modules", "openclaw");
const nodeModulesRoot = path.dirname(packageRoot);
const legacyRoot = path.join(tempDir, "state", "plugin-runtime-deps");
const existingTarget = path.join(legacyRoot, "openclaw-demo", "node_modules", "left-pad");
const staleLink = path.join(nodeModulesRoot, "left-pad");
await fs.mkdir(packageRoot, { recursive: true });
await fs.mkdir(existingTarget, { recursive: true });
await fs.symlink(existingTarget, staleLink, "dir");
await expect(collectStalePluginRuntimeSymlinks(packageRoot)).resolves.toEqual([]);
await expect(
collectStalePluginRuntimeSymlinkHealthFindings({
packageRoot,
staleRoots: [legacyRoot],
}),
).resolves.toEqual([
expect.objectContaining({
checkId: "core/doctor/stale-plugin-runtime-symlinks",
path: staleLink,
target: staleLink,
}),
]);
await expectSymlinkPresent(staleLink);
});
});
@@ -3,6 +3,8 @@ import fs from "node:fs/promises";
import path from "node:path";
import { sortUniqueStrings } from "@openclaw/normalization-core/string-normalization";
import { note } from "../../../../packages/terminal-core/src/note.js";
import type { HealthFinding } from "../../../flows/health-checks.js";
import { resolveOpenClawPackageRootSync } from "../../../infra/openclaw-root.js";
import { shortenHomePath } from "../../../utils.js";
const PLUGIN_RUNTIME_DEPS_MARKER = "plugin-runtime-deps";
@@ -99,6 +101,35 @@ export async function collectStalePluginRuntimeSymlinks(
return stale.toSorted((left, right) => left.name.localeCompare(right.name));
}
export function stalePluginRuntimeSymlinkToHealthFinding(
item: StalePluginRuntimeSymlink,
): HealthFinding {
return {
checkId: "core/doctor/stale-plugin-runtime-symlinks",
severity: "warning",
message: `Stale plugin-runtime symlink ${item.name} points at ${item.target}.`,
path: item.path,
target: item.path,
requirement: "stale-plugin-runtime-symlink-removed",
fixHint: "Run `openclaw doctor --fix` to remove stale plugin-runtime symlinks.",
};
}
export async function collectStalePluginRuntimeSymlinkHealthFindings(
params: { packageRoot?: string | null } & PluginRuntimeSymlinkOptions = {},
): Promise<HealthFinding[]> {
const packageRoot =
params.packageRoot ??
resolveOpenClawPackageRootSync({
argv1: process.argv[1],
moduleUrl: import.meta.url,
cwd: process.cwd(),
});
return (await collectStalePluginRuntimeSymlinks(packageRoot, params)).map(
stalePluginRuntimeSymlinkToHealthFinding,
);
}
/** Emit a doctor note describing stale plugin-runtime symlinks, if any exist. */
export async function noteStalePluginRuntimeSymlinks(
packageRoot: string | null | undefined,
+56 -1
View File
@@ -114,6 +114,7 @@ const mocks = vi.hoisted(() => ({
requirement: hit.reason,
}),
),
collectStalePluginRuntimeSymlinkHealthFindings: vi.fn(async () => [] as unknown[]),
applyWizardMetadata: vi.fn((cfg: unknown) => cfg),
logConfigUpdated: vi.fn(),
isRecord: vi.fn(
@@ -130,6 +131,11 @@ vi.mock("../commands/doctor/shared/release-configured-plugin-installs.js", () =>
maybeRunConfiguredPluginInstallReleaseStep: mocks.maybeRunConfiguredPluginInstallReleaseStep,
}));
vi.mock("../commands/doctor/shared/plugin-runtime-symlinks.js", () => ({
collectStalePluginRuntimeSymlinkHealthFindings:
mocks.collectStalePluginRuntimeSymlinkHealthFindings,
}));
vi.mock("./bundled-health-checks.js", () => ({
registerBundledHealthChecks: mocks.registerBundledHealthChecks,
}));
@@ -541,6 +547,8 @@ describe("doctor health contributions", () => {
mocks.scanConfiguredChannelPluginBlockers.mockReset();
mocks.scanConfiguredChannelPluginBlockers.mockReturnValue([]);
mocks.channelPluginBlockerHitToHealthFinding.mockClear();
mocks.collectStalePluginRuntimeSymlinkHealthFindings.mockReset();
mocks.collectStalePluginRuntimeSymlinkHealthFindings.mockResolvedValue([]);
});
afterEach(() => {
@@ -1351,9 +1359,9 @@ describe("doctor health contributions", () => {
expect(contributionIds).toContain("core/doctor/plugin-registry");
expect(contributionIds).toContain("core/doctor/configured-plugin-installs");
expect(contributionIds).toContain("core/doctor/legacy-plugin-dependencies");
expect(contributionIds).toContain("core/doctor/stale-plugin-runtime-symlinks");
expect(contributionIds).toContain("core/doctor/disk-space");
expect(contributionIds).toContain("core/doctor/heartbeat-template");
expect(contributionIds).toContain("core/doctor/disk-space");
expect(contributionIds).toContain("core/doctor/device-pairing");
expect(contributionIds).toContain("core/doctor/channel-plugin-blockers");
expect(contributionIds).toContain("core/doctor/tool-result-cap");
@@ -1403,6 +1411,53 @@ describe("doctor health contributions", () => {
});
});
it("keeps stale plugin-runtime symlinks opt-in for structured lint selection", async () => {
const contributionChecks = await resolveDoctorContributionHealthChecks();
const check = contributionChecks.find(
(entry) => entry.id === "core/doctor/stale-plugin-runtime-symlinks",
);
expect(check).toMatchObject({ defaultEnabled: false });
expect(check).toBeDefined();
mocks.collectStalePluginRuntimeSymlinkHealthFindings.mockResolvedValueOnce([
{
checkId: "core/doctor/stale-plugin-runtime-symlinks",
severity: "warning",
message: "Stale plugin-runtime symlink left-pad points at plugin-runtime-deps.",
path: "/tmp/node_modules/left-pad",
target: "/tmp/node_modules/left-pad",
},
]);
const ctx = {
cfg: {},
mode: "lint",
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
} as const;
await expect(runDoctorLintChecks(ctx, { checks: [check!] })).resolves.toMatchObject({
checksRun: 0,
checksSkipped: 1,
});
expect(mocks.collectStalePluginRuntimeSymlinkHealthFindings).not.toHaveBeenCalled();
await expect(
runDoctorLintChecks(ctx, {
checks: [check!],
onlyIds: ["core/doctor/stale-plugin-runtime-symlinks"],
}),
).resolves.toMatchObject({
checksRun: 1,
checksSkipped: 0,
findings: [
expect.objectContaining({
checkId: "core/doctor/stale-plugin-runtime-symlinks",
path: "/tmp/node_modules/left-pad",
}),
],
});
expect(mocks.collectStalePluginRuntimeSymlinkHealthFindings).toHaveBeenCalledTimes(1);
});
it("reports agent findings for inherited default tool result caps", async () => {
const contributionChecks = await resolveDoctorContributionHealthChecks();
const toolResultCapCheck = contributionChecks.find(
+15
View File
@@ -1462,6 +1462,21 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] {
},
run: async () => {},
}),
createDoctorHealthContribution({
id: "doctor:stale-plugin-runtime-symlinks",
label: "Stale plugin runtime symlinks",
healthChecks: {
id: "core/doctor/stale-plugin-runtime-symlinks",
description: "Stale plugin-runtime symlinks are represented as findings.",
defaultEnabled: false,
async detect() {
const { collectStalePluginRuntimeSymlinkHealthFindings } =
await import("../commands/doctor/shared/plugin-runtime-symlinks.js");
return await collectStalePluginRuntimeSymlinkHealthFindings();
},
},
run: async () => {},
}),
createDoctorHealthContribution({
id: "doctor:release-configured-plugin-installs",
label: "Configured plugin repair",