Skip to content

Commit 7016659

Browse files
SnowSky1obviyus
andauthored
fix: land bundled plugin doctor migration (#55054) (thanks @SnowSky1)
* fix(doctor): migrate legacy bundled plugin load paths * fix(doctor): preserve unknown plugin path entries * fix: derive bundled plugin legacy paths from actual directory names * fix: land bundled plugin doctor migration (#55054) (thanks @SnowSky1) --------- Co-authored-by: Ayaan Zaidi <hi@obviy.us>
1 parent a9b982c commit 7016659

6 files changed

Lines changed: 430 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ Docs: https://docs.openclaw.ai
7575
- GitHub Copilot/auth refresh: treat large `expires_at` values as seconds epochs and clamp far-future runtime auth refresh timers so Copilot token refresh cannot fall into a `setTimeout` overflow hot loop. (#55360) Thanks @michael-abdo.
7676
- Agents/status: use the persisted runtime session model in `session_status` when no explicit override exists, and honor per-agent `thinkingDefault` in both `session_status` and `/status`. (#55425) Thanks @scoootscooob, @xaeon2026, and @ysfbsf.
7777
- Heartbeat/runner: guarantee the interval timer is re-armed after heartbeat runs and unexpected runner errors so scheduled heartbeats do not silently stop after an interrupted cycle. (#52270) Thanks @MiloStack.
78+
- Config/Doctor: rewrite stale bundled plugin load paths from legacy `extensions/*` locations to the packaged bundled path, including directory-name mismatches and slash-suffixed config entries. (#55054) Thanks @SnowSky1.
7879

7980
## 2026.3.24
8081

src/commands/doctor/repair-sequencing.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
maybeRepairTelegramAllowFromUsernames,
66
} from "./providers/telegram.js";
77
import { maybeRepairAllowlistPolicyAllowFrom } from "./shared/allowlist-policy-repair.js";
8+
import { maybeRepairBundledPluginLoadPaths } from "./shared/bundled-plugin-load-paths.js";
89
import {
910
applyDoctorConfigMutation,
1011
type DoctorConfigMutationState,
@@ -49,6 +50,7 @@ export async function runDoctorRepairSequence(params: {
4950
applyMutation(await maybeRepairTelegramAllowFromUsernames(state.candidate));
5051
applyMutation(maybeRepairDiscordNumericIds(state.candidate));
5152
applyMutation(maybeRepairOpenPolicyAllowFrom(state.candidate));
53+
applyMutation(maybeRepairBundledPluginLoadPaths(state.candidate, process.env));
5254
applyMutation(maybeRepairStalePluginConfig(state.candidate, process.env));
5355
applyMutation(await maybeRepairAllowlistPolicyAllowFrom(state.candidate));
5456

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import path from "node:path";
2+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3+
import type { BundledPluginSource } from "../../../plugins/bundled-sources.js";
4+
import * as bundledSources from "../../../plugins/bundled-sources.js";
5+
import {
6+
collectBundledPluginLoadPathWarnings,
7+
maybeRepairBundledPluginLoadPaths,
8+
scanBundledPluginLoadPathMigrations,
9+
} from "./bundled-plugin-load-paths.js";
10+
11+
function bundled(pluginId: string, localPath: string): BundledPluginSource {
12+
return {
13+
pluginId,
14+
localPath,
15+
npmSpec: `@openclaw/${pluginId}`,
16+
};
17+
}
18+
19+
describe("bundled plugin load path repair", () => {
20+
beforeEach(() => {
21+
vi.spyOn(bundledSources, "resolveBundledPluginSources").mockReturnValue(
22+
new Map([["feishu", bundled("feishu", "/app/node_modules/openclaw/dist/extensions/feishu")]]),
23+
);
24+
});
25+
26+
afterEach(() => {
27+
vi.restoreAllMocks();
28+
});
29+
30+
it("detects legacy bundled plugin paths that still point at source extensions", () => {
31+
const packageRoot = path.resolve("app-node-modules", "openclaw");
32+
const legacyPath = path.join(packageRoot, "extensions", "feishu");
33+
const bundledPath = path.join(packageRoot, "dist", "extensions", "feishu");
34+
vi.spyOn(bundledSources, "resolveBundledPluginSources").mockReturnValue(
35+
new Map([["feishu", bundled("feishu", bundledPath)]]),
36+
);
37+
38+
const hits = scanBundledPluginLoadPathMigrations({
39+
plugins: {
40+
load: {
41+
paths: [legacyPath],
42+
},
43+
},
44+
});
45+
46+
expect(hits).toEqual([
47+
{
48+
pluginId: "feishu",
49+
fromPath: legacyPath,
50+
toPath: bundledPath,
51+
pathLabel: "plugins.load.paths",
52+
},
53+
]);
54+
});
55+
56+
it("rewrites legacy bundled paths during doctor repair", () => {
57+
const packageRoot = path.resolve("app-node-modules", "openclaw");
58+
const legacyPath = path.join(packageRoot, "extensions", "feishu");
59+
const bundledPath = path.join(packageRoot, "dist", "extensions", "feishu");
60+
vi.spyOn(bundledSources, "resolveBundledPluginSources").mockReturnValue(
61+
new Map([["feishu", bundled("feishu", bundledPath)]]),
62+
);
63+
64+
const result = maybeRepairBundledPluginLoadPaths({
65+
plugins: {
66+
load: {
67+
paths: [legacyPath],
68+
},
69+
},
70+
});
71+
72+
expect(result.changes).toEqual([
73+
`- plugins.load.paths: rewrote bundled feishu path from ${legacyPath} to ${bundledPath}`,
74+
]);
75+
expect(result.config.plugins?.load?.paths).toEqual([bundledPath]);
76+
});
77+
78+
it("derives legacy paths from the bundled directory name instead of plugin id", () => {
79+
const packageRoot = path.resolve("app-node-modules", "openclaw");
80+
const legacyPath = path.join(packageRoot, "extensions", "kimi-coding");
81+
const bundledPath = path.join(packageRoot, "dist", "extensions", "kimi-coding");
82+
vi.spyOn(bundledSources, "resolveBundledPluginSources").mockReturnValue(
83+
new Map([["kimi", bundled("kimi", bundledPath)]]),
84+
);
85+
86+
const hits = scanBundledPluginLoadPathMigrations({
87+
plugins: {
88+
load: {
89+
paths: [legacyPath],
90+
},
91+
},
92+
});
93+
94+
expect(hits).toEqual([
95+
{
96+
pluginId: "kimi",
97+
fromPath: legacyPath,
98+
toPath: bundledPath,
99+
pathLabel: "plugins.load.paths",
100+
},
101+
]);
102+
});
103+
104+
it("matches legacy bundled paths with a trailing slash", () => {
105+
const packageRoot = path.resolve("app-node-modules", "openclaw");
106+
const legacyPath = `${path.join(packageRoot, "extensions", "feishu")}${path.sep}`;
107+
const bundledPath = path.join(packageRoot, "dist", "extensions", "feishu");
108+
vi.spyOn(bundledSources, "resolveBundledPluginSources").mockReturnValue(
109+
new Map([["feishu", bundled("feishu", bundledPath)]]),
110+
);
111+
112+
const result = maybeRepairBundledPluginLoadPaths({
113+
plugins: {
114+
load: {
115+
paths: [legacyPath],
116+
},
117+
},
118+
});
119+
120+
expect(result.config.plugins?.load?.paths).toEqual([bundledPath]);
121+
});
122+
123+
it("rewrites dist-runtime bundled paths back to their legacy source path", () => {
124+
const packageRoot = path.resolve("app-node-modules", "openclaw");
125+
const legacyPath = path.join(packageRoot, "extensions", "feishu");
126+
const bundledPath = path.join(packageRoot, "dist-runtime", "extensions", "feishu");
127+
vi.spyOn(bundledSources, "resolveBundledPluginSources").mockReturnValue(
128+
new Map([["feishu", bundled("feishu", bundledPath)]]),
129+
);
130+
131+
const result = maybeRepairBundledPluginLoadPaths({
132+
plugins: {
133+
load: {
134+
paths: [legacyPath],
135+
},
136+
},
137+
});
138+
139+
expect(result.config.plugins?.load?.paths).toEqual([bundledPath]);
140+
});
141+
142+
it("preserves non-string path entries when repairing legacy bundled paths", () => {
143+
const packageRoot = path.resolve("app-node-modules", "openclaw");
144+
const legacyPath = path.join(packageRoot, "extensions", "feishu");
145+
const bundledPath = path.join(packageRoot, "dist", "extensions", "feishu");
146+
vi.spyOn(bundledSources, "resolveBundledPluginSources").mockReturnValue(
147+
new Map([["feishu", bundled("feishu", bundledPath)]]),
148+
);
149+
150+
const cfg = {
151+
plugins: {
152+
load: {
153+
paths: [legacyPath, 42, "/other/path"],
154+
},
155+
},
156+
} as unknown as Parameters<typeof maybeRepairBundledPluginLoadPaths>[0];
157+
158+
const result = maybeRepairBundledPluginLoadPaths(cfg);
159+
160+
expect(result.config.plugins?.load?.paths).toEqual([bundledPath, 42, "/other/path"]);
161+
});
162+
163+
it("formats a doctor hint for legacy bundled plugin paths", () => {
164+
const packageRoot = path.resolve("app-node-modules", "openclaw");
165+
const legacyPath = path.join(packageRoot, "extensions", "feishu");
166+
const bundledPath = path.join(packageRoot, "dist", "extensions", "feishu");
167+
168+
const warnings = collectBundledPluginLoadPathWarnings({
169+
hits: [
170+
{
171+
pluginId: "feishu",
172+
fromPath: legacyPath,
173+
toPath: bundledPath,
174+
pathLabel: "plugins.load.paths",
175+
},
176+
],
177+
doctorFixCommand: "openclaw doctor --fix",
178+
});
179+
180+
expect(warnings).toEqual([
181+
expect.stringContaining(`plugins.load.paths: legacy bundled plugin path "${legacyPath}"`),
182+
expect.stringContaining('Run "openclaw doctor --fix"'),
183+
]);
184+
});
185+
186+
it("ignores bundled plugins that already resolve to source extensions", () => {
187+
const sourcePath = path.resolve("repo", "openclaw", "extensions", "feishu");
188+
vi.spyOn(bundledSources, "resolveBundledPluginSources").mockReturnValue(
189+
new Map([["feishu", bundled("feishu", sourcePath)]]),
190+
);
191+
192+
const hits = scanBundledPluginLoadPathMigrations({
193+
plugins: {
194+
load: {
195+
paths: [sourcePath],
196+
},
197+
},
198+
});
199+
200+
expect(hits).toEqual([]);
201+
});
202+
});
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import path from "node:path";
2+
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../../agents/agent-scope.js";
3+
import type { OpenClawConfig } from "../../../config/config.js";
4+
import { resolveBundledPluginSources } from "../../../plugins/bundled-sources.js";
5+
import { sanitizeForLog } from "../../../terminal/ansi.js";
6+
import { resolveUserPath } from "../../../utils.js";
7+
import { asObjectRecord } from "./object.js";
8+
9+
type BundledPluginLoadPathHit = {
10+
pluginId: string;
11+
fromPath: string;
12+
toPath: string;
13+
pathLabel: string;
14+
};
15+
16+
function resolveBundledWorkspaceDir(cfg: OpenClawConfig): string | undefined {
17+
return resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)) ?? undefined;
18+
}
19+
20+
function normalizeBundledLookupPath(targetPath: string): string {
21+
const normalized = path.normalize(targetPath);
22+
const root = path.parse(normalized).root;
23+
let trimmed = normalized;
24+
while (trimmed.length > root.length && (trimmed.endsWith(path.sep) || trimmed.endsWith("/"))) {
25+
trimmed = trimmed.slice(0, -1);
26+
}
27+
return trimmed;
28+
}
29+
30+
function buildLegacyBundledPath(localPath: string): string | null {
31+
const normalized = normalizeBundledLookupPath(localPath);
32+
for (const bundledRoot of [
33+
path.join("dist", "extensions"),
34+
path.join("dist-runtime", "extensions"),
35+
]) {
36+
const marker = `${bundledRoot}${path.sep}`;
37+
const markerIndex = normalized.lastIndexOf(marker);
38+
if (markerIndex === -1) {
39+
continue;
40+
}
41+
const packageRoot = normalized.slice(0, markerIndex);
42+
const bundledLeaf = normalized.slice(markerIndex + marker.length);
43+
if (!bundledLeaf) {
44+
continue;
45+
}
46+
return path.join(packageRoot, "extensions", bundledLeaf);
47+
}
48+
return null;
49+
}
50+
51+
export function scanBundledPluginLoadPathMigrations(
52+
cfg: OpenClawConfig,
53+
env: NodeJS.ProcessEnv = process.env,
54+
): BundledPluginLoadPathHit[] {
55+
const plugins = asObjectRecord(cfg.plugins);
56+
const load = asObjectRecord(plugins?.load);
57+
const rawPaths = Array.isArray(load?.paths) ? load.paths : [];
58+
if (rawPaths.length === 0) {
59+
return [];
60+
}
61+
62+
const bundled = resolveBundledPluginSources({
63+
workspaceDir: resolveBundledWorkspaceDir(cfg),
64+
env,
65+
});
66+
if (bundled.size === 0) {
67+
return [];
68+
}
69+
70+
const legacyPathMap = new Map<string, { pluginId: string; toPath: string }>();
71+
for (const source of bundled.values()) {
72+
const legacyPath = buildLegacyBundledPath(source.localPath);
73+
if (!legacyPath) {
74+
continue;
75+
}
76+
legacyPathMap.set(normalizeBundledLookupPath(legacyPath), {
77+
pluginId: source.pluginId,
78+
toPath: source.localPath,
79+
});
80+
}
81+
82+
const hits: BundledPluginLoadPathHit[] = [];
83+
for (const rawPath of rawPaths) {
84+
if (typeof rawPath !== "string") {
85+
continue;
86+
}
87+
const normalized = normalizeBundledLookupPath(resolveUserPath(rawPath, env));
88+
const match = legacyPathMap.get(normalized);
89+
if (!match) {
90+
continue;
91+
}
92+
hits.push({
93+
pluginId: match.pluginId,
94+
fromPath: rawPath,
95+
toPath: match.toPath,
96+
pathLabel: "plugins.load.paths",
97+
});
98+
}
99+
100+
return hits;
101+
}
102+
103+
export function collectBundledPluginLoadPathWarnings(params: {
104+
hits: BundledPluginLoadPathHit[];
105+
doctorFixCommand: string;
106+
}): string[] {
107+
if (params.hits.length === 0) {
108+
return [];
109+
}
110+
const lines = params.hits.map(
111+
(hit) =>
112+
`- ${hit.pathLabel}: legacy bundled plugin path "${hit.fromPath}" still points at ${hit.pluginId}; current packaged path is "${hit.toPath}".`,
113+
);
114+
lines.push(`- Run "${params.doctorFixCommand}" to rewrite these bundled plugin paths.`);
115+
return lines.map((line) => sanitizeForLog(line));
116+
}
117+
118+
export function maybeRepairBundledPluginLoadPaths(
119+
cfg: OpenClawConfig,
120+
env: NodeJS.ProcessEnv = process.env,
121+
): {
122+
config: OpenClawConfig;
123+
changes: string[];
124+
} {
125+
const hits = scanBundledPluginLoadPathMigrations(cfg, env);
126+
if (hits.length === 0) {
127+
return { config: cfg, changes: [] };
128+
}
129+
130+
const next = structuredClone(cfg);
131+
const paths = next.plugins?.load?.paths;
132+
if (!Array.isArray(paths)) {
133+
return { config: cfg, changes: [] };
134+
}
135+
136+
const replacements = new Map(
137+
hits.map((hit) => [normalizeBundledLookupPath(resolveUserPath(hit.fromPath, env)), hit]),
138+
);
139+
const seen = new Set<string>();
140+
const rewritten: Array<(typeof paths)[number]> = [];
141+
for (const entry of paths) {
142+
if (typeof entry !== "string") {
143+
rewritten.push(entry);
144+
continue;
145+
}
146+
const resolved = normalizeBundledLookupPath(resolveUserPath(entry, env));
147+
const replacement = replacements.get(resolved)?.toPath ?? entry;
148+
const replacementResolved = normalizeBundledLookupPath(resolveUserPath(replacement, env));
149+
if (seen.has(replacementResolved)) {
150+
continue;
151+
}
152+
seen.add(replacementResolved);
153+
rewritten.push(replacement);
154+
}
155+
156+
next.plugins = {
157+
...next.plugins,
158+
load: {
159+
...next.plugins?.load,
160+
paths: rewritten,
161+
},
162+
};
163+
164+
return {
165+
config: next,
166+
changes: hits.map(
167+
(hit) =>
168+
`- plugins.load.paths: rewrote bundled ${hit.pluginId} path from ${hit.fromPath} to ${hit.toPath}`,
169+
),
170+
};
171+
}

0 commit comments

Comments
 (0)