mirror of https://github.com/openclaw/openclaw.git
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:
parent
a9b982c954
commit
7016659dbe
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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}`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue