diff --git a/CHANGELOG.md b/CHANGELOG.md index e66d28f4d82..4f40aa37346 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/commands/doctor/repair-sequencing.ts b/src/commands/doctor/repair-sequencing.ts index d626af035c7..39248b13c46 100644 --- a/src/commands/doctor/repair-sequencing.ts +++ b/src/commands/doctor/repair-sequencing.ts @@ -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)); diff --git a/src/commands/doctor/shared/bundled-plugin-load-paths.test.ts b/src/commands/doctor/shared/bundled-plugin-load-paths.test.ts new file mode 100644 index 00000000000..24ab5b5a919 --- /dev/null +++ b/src/commands/doctor/shared/bundled-plugin-load-paths.test.ts @@ -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[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([]); + }); +}); diff --git a/src/commands/doctor/shared/bundled-plugin-load-paths.ts b/src/commands/doctor/shared/bundled-plugin-load-paths.ts new file mode 100644 index 00000000000..85df8965bca --- /dev/null +++ b/src/commands/doctor/shared/bundled-plugin-load-paths.ts @@ -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(); + 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(); + 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}`, + ), + }; +} diff --git a/src/commands/doctor/shared/preview-warnings.test.ts b/src/commands/doctor/shared/preview-warnings.test.ts index 124b2930b3c..4308ff3dd5c 100644 --- a/src/commands/doctor/shared/preview-warnings.test.ts +++ b/src/commands/doctor/shared/preview-warnings.test.ts @@ -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: [], diff --git a/src/commands/doctor/shared/preview-warnings.ts b/src/commands/doctor/shared/preview-warnings.ts index 008991afb76..217cf830b79 100644 --- a/src/commands/doctor/shared/preview-warnings.ts +++ b/src/commands/doctor/shared/preview-warnings.ts @@ -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,