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>
This commit is contained in:
SnowSky1 2026-03-27 11:35:34 +08:00 committed by GitHub
parent a9b982c954
commit 7016659dbe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 430 additions and 0 deletions

View File

@ -75,6 +75,7 @@ Docs: https://docs.openclaw.ai
- 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.
- 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.
- 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.
- 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.
## 2026.3.24

View File

@ -5,6 +5,7 @@ import {
maybeRepairTelegramAllowFromUsernames,
} from "./providers/telegram.js";
import { maybeRepairAllowlistPolicyAllowFrom } from "./shared/allowlist-policy-repair.js";
import { maybeRepairBundledPluginLoadPaths } from "./shared/bundled-plugin-load-paths.js";
import {
applyDoctorConfigMutation,
type DoctorConfigMutationState,
@ -49,6 +50,7 @@ export async function runDoctorRepairSequence(params: {
applyMutation(await maybeRepairTelegramAllowFromUsernames(state.candidate));
applyMutation(maybeRepairDiscordNumericIds(state.candidate));
applyMutation(maybeRepairOpenPolicyAllowFrom(state.candidate));
applyMutation(maybeRepairBundledPluginLoadPaths(state.candidate, process.env));
applyMutation(maybeRepairStalePluginConfig(state.candidate, process.env));
applyMutation(await maybeRepairAllowlistPolicyAllowFrom(state.candidate));

View File

@ -0,0 +1,202 @@
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { BundledPluginSource } from "../../../plugins/bundled-sources.js";
import * as bundledSources from "../../../plugins/bundled-sources.js";
import {
collectBundledPluginLoadPathWarnings,
maybeRepairBundledPluginLoadPaths,
scanBundledPluginLoadPathMigrations,
} from "./bundled-plugin-load-paths.js";
function bundled(pluginId: string, localPath: string): BundledPluginSource {
return {
pluginId,
localPath,
npmSpec: `@openclaw/${pluginId}`,
};
}
describe("bundled plugin load path repair", () => {
beforeEach(() => {
vi.spyOn(bundledSources, "resolveBundledPluginSources").mockReturnValue(
new Map([["feishu", bundled("feishu", "/app/node_modules/openclaw/dist/extensions/feishu")]]),
);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("detects legacy bundled plugin paths that still point at source extensions", () => {
const packageRoot = path.resolve("app-node-modules", "openclaw");
const legacyPath = path.join(packageRoot, "extensions", "feishu");
const bundledPath = path.join(packageRoot, "dist", "extensions", "feishu");
vi.spyOn(bundledSources, "resolveBundledPluginSources").mockReturnValue(
new Map([["feishu", bundled("feishu", bundledPath)]]),
);
const hits = scanBundledPluginLoadPathMigrations({
plugins: {
load: {
paths: [legacyPath],
},
},
});
expect(hits).toEqual([
{
pluginId: "feishu",
fromPath: legacyPath,
toPath: bundledPath,
pathLabel: "plugins.load.paths",
},
]);
});
it("rewrites legacy bundled paths during doctor repair", () => {
const packageRoot = path.resolve("app-node-modules", "openclaw");
const legacyPath = path.join(packageRoot, "extensions", "feishu");
const bundledPath = path.join(packageRoot, "dist", "extensions", "feishu");
vi.spyOn(bundledSources, "resolveBundledPluginSources").mockReturnValue(
new Map([["feishu", bundled("feishu", bundledPath)]]),
);
const result = maybeRepairBundledPluginLoadPaths({
plugins: {
load: {
paths: [legacyPath],
},
},
});
expect(result.changes).toEqual([
`- plugins.load.paths: rewrote bundled feishu path from ${legacyPath} to ${bundledPath}`,
]);
expect(result.config.plugins?.load?.paths).toEqual([bundledPath]);
});
it("derives legacy paths from the bundled directory name instead of plugin id", () => {
const packageRoot = path.resolve("app-node-modules", "openclaw");
const legacyPath = path.join(packageRoot, "extensions", "kimi-coding");
const bundledPath = path.join(packageRoot, "dist", "extensions", "kimi-coding");
vi.spyOn(bundledSources, "resolveBundledPluginSources").mockReturnValue(
new Map([["kimi", bundled("kimi", bundledPath)]]),
);
const hits = scanBundledPluginLoadPathMigrations({
plugins: {
load: {
paths: [legacyPath],
},
},
});
expect(hits).toEqual([
{
pluginId: "kimi",
fromPath: legacyPath,
toPath: bundledPath,
pathLabel: "plugins.load.paths",
},
]);
});
it("matches legacy bundled paths with a trailing slash", () => {
const packageRoot = path.resolve("app-node-modules", "openclaw");
const legacyPath = `${path.join(packageRoot, "extensions", "feishu")}${path.sep}`;
const bundledPath = path.join(packageRoot, "dist", "extensions", "feishu");
vi.spyOn(bundledSources, "resolveBundledPluginSources").mockReturnValue(
new Map([["feishu", bundled("feishu", bundledPath)]]),
);
const result = maybeRepairBundledPluginLoadPaths({
plugins: {
load: {
paths: [legacyPath],
},
},
});
expect(result.config.plugins?.load?.paths).toEqual([bundledPath]);
});
it("rewrites dist-runtime bundled paths back to their legacy source path", () => {
const packageRoot = path.resolve("app-node-modules", "openclaw");
const legacyPath = path.join(packageRoot, "extensions", "feishu");
const bundledPath = path.join(packageRoot, "dist-runtime", "extensions", "feishu");
vi.spyOn(bundledSources, "resolveBundledPluginSources").mockReturnValue(
new Map([["feishu", bundled("feishu", bundledPath)]]),
);
const result = maybeRepairBundledPluginLoadPaths({
plugins: {
load: {
paths: [legacyPath],
},
},
});
expect(result.config.plugins?.load?.paths).toEqual([bundledPath]);
});
it("preserves non-string path entries when repairing legacy bundled paths", () => {
const packageRoot = path.resolve("app-node-modules", "openclaw");
const legacyPath = path.join(packageRoot, "extensions", "feishu");
const bundledPath = path.join(packageRoot, "dist", "extensions", "feishu");
vi.spyOn(bundledSources, "resolveBundledPluginSources").mockReturnValue(
new Map([["feishu", bundled("feishu", bundledPath)]]),
);
const cfg = {
plugins: {
load: {
paths: [legacyPath, 42, "/other/path"],
},
},
} as unknown as Parameters<typeof maybeRepairBundledPluginLoadPaths>[0];
const result = maybeRepairBundledPluginLoadPaths(cfg);
expect(result.config.plugins?.load?.paths).toEqual([bundledPath, 42, "/other/path"]);
});
it("formats a doctor hint for legacy bundled plugin paths", () => {
const packageRoot = path.resolve("app-node-modules", "openclaw");
const legacyPath = path.join(packageRoot, "extensions", "feishu");
const bundledPath = path.join(packageRoot, "dist", "extensions", "feishu");
const warnings = collectBundledPluginLoadPathWarnings({
hits: [
{
pluginId: "feishu",
fromPath: legacyPath,
toPath: bundledPath,
pathLabel: "plugins.load.paths",
},
],
doctorFixCommand: "openclaw doctor --fix",
});
expect(warnings).toEqual([
expect.stringContaining(`plugins.load.paths: legacy bundled plugin path "${legacyPath}"`),
expect.stringContaining('Run "openclaw doctor --fix"'),
]);
});
it("ignores bundled plugins that already resolve to source extensions", () => {
const sourcePath = path.resolve("repo", "openclaw", "extensions", "feishu");
vi.spyOn(bundledSources, "resolveBundledPluginSources").mockReturnValue(
new Map([["feishu", bundled("feishu", sourcePath)]]),
);
const hits = scanBundledPluginLoadPathMigrations({
plugins: {
load: {
paths: [sourcePath],
},
},
});
expect(hits).toEqual([]);
});
});

View File

@ -0,0 +1,171 @@
import path from "node:path";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../../agents/agent-scope.js";
import type { OpenClawConfig } from "../../../config/config.js";
import { resolveBundledPluginSources } from "../../../plugins/bundled-sources.js";
import { sanitizeForLog } from "../../../terminal/ansi.js";
import { resolveUserPath } from "../../../utils.js";
import { asObjectRecord } from "./object.js";
type BundledPluginLoadPathHit = {
pluginId: string;
fromPath: string;
toPath: string;
pathLabel: string;
};
function resolveBundledWorkspaceDir(cfg: OpenClawConfig): string | undefined {
return resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)) ?? undefined;
}
function normalizeBundledLookupPath(targetPath: string): string {
const normalized = path.normalize(targetPath);
const root = path.parse(normalized).root;
let trimmed = normalized;
while (trimmed.length > root.length && (trimmed.endsWith(path.sep) || trimmed.endsWith("/"))) {
trimmed = trimmed.slice(0, -1);
}
return trimmed;
}
function buildLegacyBundledPath(localPath: string): string | null {
const normalized = normalizeBundledLookupPath(localPath);
for (const bundledRoot of [
path.join("dist", "extensions"),
path.join("dist-runtime", "extensions"),
]) {
const marker = `${bundledRoot}${path.sep}`;
const markerIndex = normalized.lastIndexOf(marker);
if (markerIndex === -1) {
continue;
}
const packageRoot = normalized.slice(0, markerIndex);
const bundledLeaf = normalized.slice(markerIndex + marker.length);
if (!bundledLeaf) {
continue;
}
return path.join(packageRoot, "extensions", bundledLeaf);
}
return null;
}
export function scanBundledPluginLoadPathMigrations(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv = process.env,
): BundledPluginLoadPathHit[] {
const plugins = asObjectRecord(cfg.plugins);
const load = asObjectRecord(plugins?.load);
const rawPaths = Array.isArray(load?.paths) ? load.paths : [];
if (rawPaths.length === 0) {
return [];
}
const bundled = resolveBundledPluginSources({
workspaceDir: resolveBundledWorkspaceDir(cfg),
env,
});
if (bundled.size === 0) {
return [];
}
const legacyPathMap = new Map<string, { pluginId: string; toPath: string }>();
for (const source of bundled.values()) {
const legacyPath = buildLegacyBundledPath(source.localPath);
if (!legacyPath) {
continue;
}
legacyPathMap.set(normalizeBundledLookupPath(legacyPath), {
pluginId: source.pluginId,
toPath: source.localPath,
});
}
const hits: BundledPluginLoadPathHit[] = [];
for (const rawPath of rawPaths) {
if (typeof rawPath !== "string") {
continue;
}
const normalized = normalizeBundledLookupPath(resolveUserPath(rawPath, env));
const match = legacyPathMap.get(normalized);
if (!match) {
continue;
}
hits.push({
pluginId: match.pluginId,
fromPath: rawPath,
toPath: match.toPath,
pathLabel: "plugins.load.paths",
});
}
return hits;
}
export function collectBundledPluginLoadPathWarnings(params: {
hits: BundledPluginLoadPathHit[];
doctorFixCommand: string;
}): string[] {
if (params.hits.length === 0) {
return [];
}
const lines = params.hits.map(
(hit) =>
`- ${hit.pathLabel}: legacy bundled plugin path "${hit.fromPath}" still points at ${hit.pluginId}; current packaged path is "${hit.toPath}".`,
);
lines.push(`- Run "${params.doctorFixCommand}" to rewrite these bundled plugin paths.`);
return lines.map((line) => sanitizeForLog(line));
}
export function maybeRepairBundledPluginLoadPaths(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv = process.env,
): {
config: OpenClawConfig;
changes: string[];
} {
const hits = scanBundledPluginLoadPathMigrations(cfg, env);
if (hits.length === 0) {
return { config: cfg, changes: [] };
}
const next = structuredClone(cfg);
const paths = next.plugins?.load?.paths;
if (!Array.isArray(paths)) {
return { config: cfg, changes: [] };
}
const replacements = new Map(
hits.map((hit) => [normalizeBundledLookupPath(resolveUserPath(hit.fromPath, env)), hit]),
);
const seen = new Set<string>();
const rewritten: Array<(typeof paths)[number]> = [];
for (const entry of paths) {
if (typeof entry !== "string") {
rewritten.push(entry);
continue;
}
const resolved = normalizeBundledLookupPath(resolveUserPath(entry, env));
const replacement = replacements.get(resolved)?.toPath ?? entry;
const replacementResolved = normalizeBundledLookupPath(resolveUserPath(replacement, env));
if (seen.has(replacementResolved)) {
continue;
}
seen.add(replacementResolved);
rewritten.push(replacement);
}
next.plugins = {
...next.plugins,
load: {
...next.plugins?.load,
paths: rewritten,
},
};
return {
config: next,
changes: hits.map(
(hit) =>
`- plugins.load.paths: rewrote bundled ${hit.pluginId} path from ${hit.fromPath} to ${hit.toPath}`,
),
};
}

View File

@ -1,4 +1,6 @@
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as bundledSources from "../../../plugins/bundled-sources.js";
import type { PluginManifestRecord } from "../../../plugins/manifest-registry.js";
import * as manifestRegistry from "../../../plugins/manifest-registry.js";
import { collectDoctorPreviewWarnings } from "./preview-warnings.js";
@ -95,6 +97,44 @@ describe("doctor preview warnings", () => {
expect(warnings[0]).not.toContain("Auto-removal is paused");
});
it("includes bundled plugin load path migration warnings", () => {
const packageRoot = path.resolve("app-node-modules", "openclaw");
const legacyPath = path.join(packageRoot, "extensions", "feishu");
const bundledPath = path.join(packageRoot, "dist", "extensions", "feishu");
vi.spyOn(manifestRegistry, "loadPluginManifestRegistry").mockReturnValue({
plugins: [manifest("feishu")],
diagnostics: [],
});
vi.spyOn(bundledSources, "resolveBundledPluginSources").mockReturnValue(
new Map([
[
"feishu",
{
pluginId: "feishu",
localPath: bundledPath,
npmSpec: "@openclaw/feishu",
},
],
]),
);
const warnings = collectDoctorPreviewWarnings({
cfg: {
plugins: {
load: {
paths: [legacyPath],
},
},
},
doctorFixCommand: "openclaw doctor --fix",
});
expect(warnings).toEqual([
expect.stringContaining(`plugins.load.paths: legacy bundled plugin path "${legacyPath}"`),
]);
expect(warnings[0]).toContain('Run "openclaw doctor --fix"');
});
it("warns but skips auto-removal when plugin discovery has errors", () => {
vi.spyOn(manifestRegistry, "loadPluginManifestRegistry").mockReturnValue({
plugins: [],

View File

@ -9,6 +9,10 @@ import {
collectTelegramEmptyAllowlistExtraWarnings,
scanTelegramAllowFromUsernameEntries,
} from "../providers/telegram.js";
import {
collectBundledPluginLoadPathWarnings,
scanBundledPluginLoadPathMigrations,
} from "./bundled-plugin-load-paths.js";
import { scanEmptyAllowlistPolicyWarnings } from "./empty-allowlist-scan.js";
import {
collectExecSafeBinCoverageWarnings,
@ -77,6 +81,16 @@ export function collectDoctorPreviewWarnings(params: {
);
}
const bundledPluginLoadPathHits = scanBundledPluginLoadPathMigrations(params.cfg, process.env);
if (bundledPluginLoadPathHits.length > 0) {
warnings.push(
collectBundledPluginLoadPathWarnings({
hits: bundledPluginLoadPathHits,
doctorFixCommand: params.doctorFixCommand,
}).join("\n"),
);
}
const emptyAllowlistWarnings = scanEmptyAllowlistPolicyWarnings(params.cfg, {
doctorFixCommand: params.doctorFixCommand,
extraWarningsForAccount: collectTelegramEmptyAllowlistExtraWarnings,