Skip to content

Commit 287d223

Browse files
committed
Expose stale plugin runtime symlink doctor lint findings
1 parent 48e8965 commit 287d223

4 files changed

Lines changed: 233 additions & 1 deletion

File tree

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// Plugin runtime symlink tests cover doctor detection of stale global symlinks.
2+
import fs from "node:fs/promises";
3+
import os from "node:os";
4+
import path from "node:path";
5+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
6+
import {
7+
collectStalePluginRuntimeSymlinkHealthFindings,
8+
collectStalePluginRuntimeSymlinks,
9+
stalePluginRuntimeSymlinkToHealthFinding,
10+
} from "./plugin-runtime-symlinks.js";
11+
12+
async function expectSymlinkPresent(targetPath: string): Promise<void> {
13+
expect((await fs.lstat(targetPath)).isSymbolicLink()).toBe(true);
14+
}
15+
16+
async function canCreateDirectorySymlink(root: string): Promise<boolean> {
17+
const target = path.join(root, "symlink-capability-target");
18+
const link = path.join(root, "symlink-capability-link");
19+
await fs.mkdir(target, { recursive: true });
20+
try {
21+
await fs.symlink(target, link, "dir");
22+
return (await fs.lstat(link)).isSymbolicLink();
23+
} catch (error) {
24+
const code = (error as NodeJS.ErrnoException | undefined)?.code;
25+
if (code === "EPERM" || code === "EACCES" || code === "ENOTSUP") {
26+
return false;
27+
}
28+
throw error;
29+
} finally {
30+
await fs.rm(link, { recursive: true, force: true });
31+
await fs.rm(target, { recursive: true, force: true });
32+
}
33+
}
34+
35+
describe("plugin runtime symlink health findings", () => {
36+
let tempDir: string;
37+
38+
beforeEach(async () => {
39+
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-plugin-runtime-symlinks-"));
40+
});
41+
42+
afterEach(async () => {
43+
await fs.rm(tempDir, { recursive: true, force: true });
44+
});
45+
46+
it("maps dangling plugin-runtime symlinks to read-only lint findings", async () => {
47+
if (!(await canCreateDirectorySymlink(tempDir))) {
48+
return;
49+
}
50+
51+
const packageRoot = path.join(tempDir, "prefix", "lib", "node_modules", "openclaw");
52+
const nodeModulesRoot = path.dirname(packageRoot);
53+
const legacyRoot = path.join(tempDir, "state", "plugin-runtime-deps");
54+
const missingTarget = path.join(
55+
legacyRoot,
56+
"openclaw-slack",
57+
"node_modules",
58+
"@slack",
59+
"web-api",
60+
);
61+
const scopeRoot = path.join(nodeModulesRoot, "@slack");
62+
const staleLink = path.join(scopeRoot, "web-api");
63+
const liveTarget = path.join(tempDir, "live", "@slack", "bolt");
64+
const liveLink = path.join(scopeRoot, "bolt");
65+
66+
await fs.mkdir(packageRoot, { recursive: true });
67+
await fs.mkdir(scopeRoot, { recursive: true });
68+
await fs.mkdir(liveTarget, { recursive: true });
69+
await fs.symlink(missingTarget, staleLink, "dir");
70+
await fs.symlink(liveTarget, liveLink, "dir");
71+
72+
const [stale] = await collectStalePluginRuntimeSymlinks(packageRoot);
73+
if (!stale) {
74+
throw new Error("expected stale plugin-runtime symlink finding");
75+
}
76+
77+
expect(stale).toEqual({
78+
name: "@slack/web-api",
79+
path: staleLink,
80+
target: missingTarget,
81+
});
82+
expect(stalePluginRuntimeSymlinkToHealthFinding(stale)).toEqual({
83+
checkId: "core/doctor/stale-plugin-runtime-symlinks",
84+
severity: "warning",
85+
message: `Stale plugin-runtime symlink @slack/web-api points at ${missingTarget}.`,
86+
path: staleLink,
87+
target: staleLink,
88+
requirement: "stale-plugin-runtime-symlink-removed",
89+
fixHint: "Run `openclaw doctor --fix` to remove stale plugin-runtime symlinks.",
90+
});
91+
expect(await collectStalePluginRuntimeSymlinkHealthFindings({ packageRoot })).toEqual([
92+
expect.objectContaining({
93+
checkId: "core/doctor/stale-plugin-runtime-symlinks",
94+
path: staleLink,
95+
}),
96+
]);
97+
await expectSymlinkPresent(staleLink);
98+
await expectSymlinkPresent(liveLink);
99+
});
100+
101+
it("reports symlinks that point inside classified stale roots", async () => {
102+
if (!(await canCreateDirectorySymlink(tempDir))) {
103+
return;
104+
}
105+
106+
const packageRoot = path.join(tempDir, "prefix", "lib", "node_modules", "openclaw");
107+
const nodeModulesRoot = path.dirname(packageRoot);
108+
const legacyRoot = path.join(tempDir, "state", "plugin-runtime-deps");
109+
const existingTarget = path.join(legacyRoot, "openclaw-demo", "node_modules", "left-pad");
110+
const staleLink = path.join(nodeModulesRoot, "left-pad");
111+
112+
await fs.mkdir(packageRoot, { recursive: true });
113+
await fs.mkdir(existingTarget, { recursive: true });
114+
await fs.symlink(existingTarget, staleLink, "dir");
115+
116+
await expect(collectStalePluginRuntimeSymlinks(packageRoot)).resolves.toEqual([]);
117+
await expect(
118+
collectStalePluginRuntimeSymlinkHealthFindings({
119+
packageRoot,
120+
staleRoots: [legacyRoot],
121+
}),
122+
).resolves.toEqual([
123+
expect.objectContaining({
124+
checkId: "core/doctor/stale-plugin-runtime-symlinks",
125+
path: staleLink,
126+
target: staleLink,
127+
}),
128+
]);
129+
await expectSymlinkPresent(staleLink);
130+
});
131+
});

src/commands/doctor/shared/plugin-runtime-symlinks.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import fs from "node:fs/promises";
33
import path from "node:path";
44
import { sortUniqueStrings } from "@openclaw/normalization-core/string-normalization";
55
import { note } from "../../../../packages/terminal-core/src/note.js";
6+
import type { HealthFinding } from "../../../flows/health-checks.js";
7+
import { resolveOpenClawPackageRootSync } from "../../../infra/openclaw-root.js";
68
import { shortenHomePath } from "../../../utils.js";
79

810
const PLUGIN_RUNTIME_DEPS_MARKER = "plugin-runtime-deps";
@@ -99,6 +101,35 @@ export async function collectStalePluginRuntimeSymlinks(
99101
return stale.toSorted((left, right) => left.name.localeCompare(right.name));
100102
}
101103

104+
export function stalePluginRuntimeSymlinkToHealthFinding(
105+
item: StalePluginRuntimeSymlink,
106+
): HealthFinding {
107+
return {
108+
checkId: "core/doctor/stale-plugin-runtime-symlinks",
109+
severity: "warning",
110+
message: `Stale plugin-runtime symlink ${item.name} points at ${item.target}.`,
111+
path: item.path,
112+
target: item.path,
113+
requirement: "stale-plugin-runtime-symlink-removed",
114+
fixHint: "Run `openclaw doctor --fix` to remove stale plugin-runtime symlinks.",
115+
};
116+
}
117+
118+
export async function collectStalePluginRuntimeSymlinkHealthFindings(
119+
params: { packageRoot?: string | null } & PluginRuntimeSymlinkOptions = {},
120+
): Promise<HealthFinding[]> {
121+
const packageRoot =
122+
params.packageRoot ??
123+
resolveOpenClawPackageRootSync({
124+
argv1: process.argv[1],
125+
moduleUrl: import.meta.url,
126+
cwd: process.cwd(),
127+
});
128+
return (await collectStalePluginRuntimeSymlinks(packageRoot, params)).map(
129+
stalePluginRuntimeSymlinkToHealthFinding,
130+
);
131+
}
132+
102133
/** Emit a doctor note describing stale plugin-runtime symlinks, if any exist. */
103134
export async function noteStalePluginRuntimeSymlinks(
104135
packageRoot: string | null | undefined,

src/flows/doctor-health-contributions.test.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ const mocks = vi.hoisted(() => ({
114114
requirement: hit.reason,
115115
}),
116116
),
117+
collectStalePluginRuntimeSymlinkHealthFindings: vi.fn(async () => [] as unknown[]),
117118
applyWizardMetadata: vi.fn((cfg: unknown) => cfg),
118119
logConfigUpdated: vi.fn(),
119120
isRecord: vi.fn(
@@ -130,6 +131,11 @@ vi.mock("../commands/doctor/shared/release-configured-plugin-installs.js", () =>
130131
maybeRunConfiguredPluginInstallReleaseStep: mocks.maybeRunConfiguredPluginInstallReleaseStep,
131132
}));
132133

134+
vi.mock("../commands/doctor/shared/plugin-runtime-symlinks.js", () => ({
135+
collectStalePluginRuntimeSymlinkHealthFindings:
136+
mocks.collectStalePluginRuntimeSymlinkHealthFindings,
137+
}));
138+
133139
vi.mock("./bundled-health-checks.js", () => ({
134140
registerBundledHealthChecks: mocks.registerBundledHealthChecks,
135141
}));
@@ -541,6 +547,8 @@ describe("doctor health contributions", () => {
541547
mocks.scanConfiguredChannelPluginBlockers.mockReset();
542548
mocks.scanConfiguredChannelPluginBlockers.mockReturnValue([]);
543549
mocks.channelPluginBlockerHitToHealthFinding.mockClear();
550+
mocks.collectStalePluginRuntimeSymlinkHealthFindings.mockReset();
551+
mocks.collectStalePluginRuntimeSymlinkHealthFindings.mockResolvedValue([]);
544552
});
545553

546554
afterEach(() => {
@@ -1351,9 +1359,9 @@ describe("doctor health contributions", () => {
13511359
expect(contributionIds).toContain("core/doctor/plugin-registry");
13521360
expect(contributionIds).toContain("core/doctor/configured-plugin-installs");
13531361
expect(contributionIds).toContain("core/doctor/legacy-plugin-dependencies");
1362+
expect(contributionIds).toContain("core/doctor/stale-plugin-runtime-symlinks");
13541363
expect(contributionIds).toContain("core/doctor/disk-space");
13551364
expect(contributionIds).toContain("core/doctor/heartbeat-template");
1356-
expect(contributionIds).toContain("core/doctor/disk-space");
13571365
expect(contributionIds).toContain("core/doctor/device-pairing");
13581366
expect(contributionIds).toContain("core/doctor/channel-plugin-blockers");
13591367
expect(contributionIds).toContain("core/doctor/tool-result-cap");
@@ -1403,6 +1411,53 @@ describe("doctor health contributions", () => {
14031411
});
14041412
});
14051413

1414+
it("keeps stale plugin-runtime symlinks opt-in for structured lint selection", async () => {
1415+
const contributionChecks = await resolveDoctorContributionHealthChecks();
1416+
const check = contributionChecks.find(
1417+
(entry) => entry.id === "core/doctor/stale-plugin-runtime-symlinks",
1418+
);
1419+
expect(check).toMatchObject({ defaultEnabled: false });
1420+
expect(check).toBeDefined();
1421+
mocks.collectStalePluginRuntimeSymlinkHealthFindings.mockResolvedValueOnce([
1422+
{
1423+
checkId: "core/doctor/stale-plugin-runtime-symlinks",
1424+
severity: "warning",
1425+
message: "Stale plugin-runtime symlink left-pad points at plugin-runtime-deps.",
1426+
path: "/tmp/node_modules/left-pad",
1427+
target: "/tmp/node_modules/left-pad",
1428+
},
1429+
]);
1430+
1431+
const ctx = {
1432+
cfg: {},
1433+
mode: "lint",
1434+
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
1435+
} as const;
1436+
1437+
await expect(runDoctorLintChecks(ctx, { checks: [check!] })).resolves.toMatchObject({
1438+
checksRun: 0,
1439+
checksSkipped: 1,
1440+
});
1441+
expect(mocks.collectStalePluginRuntimeSymlinkHealthFindings).not.toHaveBeenCalled();
1442+
1443+
await expect(
1444+
runDoctorLintChecks(ctx, {
1445+
checks: [check!],
1446+
onlyIds: ["core/doctor/stale-plugin-runtime-symlinks"],
1447+
}),
1448+
).resolves.toMatchObject({
1449+
checksRun: 1,
1450+
checksSkipped: 0,
1451+
findings: [
1452+
expect.objectContaining({
1453+
checkId: "core/doctor/stale-plugin-runtime-symlinks",
1454+
path: "/tmp/node_modules/left-pad",
1455+
}),
1456+
],
1457+
});
1458+
expect(mocks.collectStalePluginRuntimeSymlinkHealthFindings).toHaveBeenCalledTimes(1);
1459+
});
1460+
14061461
it("reports agent findings for inherited default tool result caps", async () => {
14071462
const contributionChecks = await resolveDoctorContributionHealthChecks();
14081463
const toolResultCapCheck = contributionChecks.find(

src/flows/doctor-health-contributions.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1462,6 +1462,21 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] {
14621462
},
14631463
run: async () => {},
14641464
}),
1465+
createDoctorHealthContribution({
1466+
id: "doctor:stale-plugin-runtime-symlinks",
1467+
label: "Stale plugin runtime symlinks",
1468+
healthChecks: {
1469+
id: "core/doctor/stale-plugin-runtime-symlinks",
1470+
description: "Stale plugin-runtime symlinks are represented as findings.",
1471+
defaultEnabled: false,
1472+
async detect() {
1473+
const { collectStalePluginRuntimeSymlinkHealthFindings } =
1474+
await import("../commands/doctor/shared/plugin-runtime-symlinks.js");
1475+
return await collectStalePluginRuntimeSymlinkHealthFindings();
1476+
},
1477+
},
1478+
run: async () => {},
1479+
}),
14651480
createDoctorHealthContribution({
14661481
id: "doctor:release-configured-plugin-installs",
14671482
label: "Configured plugin repair",

0 commit comments

Comments
 (0)