From 287d2230eed71d84ccd248f9cef77507b88ac312 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Wed, 1 Jul 2026 10:39:00 -0700 Subject: [PATCH] Expose stale plugin runtime symlink doctor lint findings --- .../shared/plugin-runtime-symlinks.test.ts | 131 ++++++++++++++++++ .../doctor/shared/plugin-runtime-symlinks.ts | 31 +++++ src/flows/doctor-health-contributions.test.ts | 57 +++++++- src/flows/doctor-health-contributions.ts | 15 ++ 4 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 src/commands/doctor/shared/plugin-runtime-symlinks.test.ts diff --git a/src/commands/doctor/shared/plugin-runtime-symlinks.test.ts b/src/commands/doctor/shared/plugin-runtime-symlinks.test.ts new file mode 100644 index 000000000000..fe48e6e66607 --- /dev/null +++ b/src/commands/doctor/shared/plugin-runtime-symlinks.test.ts @@ -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 { + expect((await fs.lstat(targetPath)).isSymbolicLink()).toBe(true); +} + +async function canCreateDirectorySymlink(root: string): Promise { + 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); + }); +}); diff --git a/src/commands/doctor/shared/plugin-runtime-symlinks.ts b/src/commands/doctor/shared/plugin-runtime-symlinks.ts index 5e48d0609932..53e210586f95 100644 --- a/src/commands/doctor/shared/plugin-runtime-symlinks.ts +++ b/src/commands/doctor/shared/plugin-runtime-symlinks.ts @@ -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 { + 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, diff --git a/src/flows/doctor-health-contributions.test.ts b/src/flows/doctor-health-contributions.test.ts index 1bd7bb90154b..87314e28af44 100644 --- a/src/flows/doctor-health-contributions.test.ts +++ b/src/flows/doctor-health-contributions.test.ts @@ -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( diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index 390909240473..7ad5ae6f0ed4 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -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",