diff --git a/CHANGELOG.md b/CHANGELOG.md index 5419b7a083a..6f64cddb1fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -117,6 +117,7 @@ Docs: https://docs.openclaw.ai - Gateway/Fly.io: seed Control UI allowed origins from the actual runtime bind and port so CLI-driven non-loopback starts do not crash before config exists. Fixes #71823. - Gateway/proxy: bootstrap env proxy dispatching from direct Gateway startup so provider and plugin network requests honor `HTTPS_PROXY`/`HTTP_PROXY` before the first embedded agent attempt runs. (#71833) Thanks @mjamiv. - Plugins/runtime deps: verify clean npm installs actually place requested bundled runtime packages in the managed install root, reporting exact missing specs instead of a false successful repair. (#71883) Thanks @Solvely-Colin. +- Plugins/discovery: ignore stale `plugins.load.paths` aliases that point back at packaged bundled plugin directories and have doctor remove them, keeping bundled plugins on the runtime-deps staging path. Thanks @codex. - Models/LM Studio: preserve `@iq*` quant suffixes in model refs and provider matching so `/model lmstudio/...@iq3_xxs` keeps the exact LM Studio variant. Fixes #71474. (#71486) Thanks @Bartok9, @XinwuC, and @Sanjays2402. - Matrix/cron: preserve the live Matrix delivery target when creating implicit announce reminder jobs so mixed-case room IDs are not reconstructed from lowercased session keys. Fixes #71798. - Feishu: accept Schema 2.0 card action callbacks that report `context.open_chat_id` instead of legacy `context.chat_id`, so button callbacks no longer drop as malformed. Fixes #71670. Thanks @eddy1068. diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 4f8a2777a26..02ebb59b505 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -178,7 +178,9 @@ OpenClaw scans for plugins in this order (first match wins): - `plugins.load.paths` — explicit file or directory paths. + `plugins.load.paths` — explicit file or directory paths. Paths that point + back at OpenClaw's own packaged bundled plugin directories are ignored; + run `openclaw doctor --fix` to remove those stale aliases. diff --git a/src/commands/doctor/shared/bundled-plugin-load-paths.test.ts b/src/commands/doctor/shared/bundled-plugin-load-paths.test.ts index cb7fb230e0a..44e56e16371 100644 --- a/src/commands/doctor/shared/bundled-plugin-load-paths.test.ts +++ b/src/commands/doctor/shared/bundled-plugin-load-paths.test.ts @@ -74,7 +74,7 @@ describe("bundled plugin load path repair", () => { ]); }); - it("rewrites legacy bundled paths during doctor repair", () => { + it("removes legacy bundled paths during doctor repair", () => { const packageRoot = path.resolve("app-node-modules", "openclaw"); const legacyPath = bundledPluginRootAt(packageRoot, "feishu"); const bundledPath = bundledDistPluginRootAt(packageRoot, "feishu"); @@ -91,9 +91,19 @@ describe("bundled plugin load path repair", () => { }); expect(result.changes).toEqual([ - `- plugins.load.paths: rewrote bundled feishu path from ${legacyPath} to ${bundledPath}`, + `- plugins.load.paths: removed bundled feishu path alias ${legacyPath}`, ]); - expect(result.config.plugins?.load?.paths).toEqual([bundledPath]); + expect(result.config.plugins?.load?.paths).toEqual([]); + }); + + it("removes current packaged bundled paths during doctor repair", () => { + const packageRoot = path.resolve("app-node-modules", "openclaw"); + const bundledPath = bundledDistPluginRootAt(packageRoot, "feishu"); + mockBundledSource("feishu", bundledPath); + + const result = maybeRepairBundledPluginLoadPaths(createPluginLoadPathConfig([bundledPath])); + + expect(result.config.plugins?.load?.paths).toEqual([]); }); it("derives legacy paths from the bundled directory name instead of plugin id", () => { @@ -130,10 +140,10 @@ describe("bundled plugin load path repair", () => { const result = maybeRepairBundledPluginLoadPaths(createPluginLoadPathConfig([legacyPath])); - expect(result.config.plugins?.load?.paths).toEqual([bundledPath]); + expect(result.config.plugins?.load?.paths).toEqual([]); }); - it("rewrites dist-runtime bundled paths back to their legacy source path", () => { + it("removes dist-runtime bundled paths", () => { const packageRoot = path.resolve("app-node-modules", "openclaw"); const legacyPath = path.join(packageRoot, "extensions", "feishu"); const bundledPath = path.join(packageRoot, "dist-runtime", "extensions", "feishu"); @@ -141,7 +151,7 @@ describe("bundled plugin load path repair", () => { const result = maybeRepairBundledPluginLoadPaths(createPluginLoadPathConfig([legacyPath])); - expect(result.config.plugins?.load?.paths).toEqual([bundledPath]); + expect(result.config.plugins?.load?.paths).toEqual([]); }); it("preserves non-string path entries when repairing legacy bundled paths", () => { @@ -154,7 +164,7 @@ describe("bundled plugin load path repair", () => { const result = maybeRepairBundledPluginLoadPaths(cfg); - expect(result.config.plugins?.load?.paths).toEqual([bundledPath, 42, "/other/path"]); + expect(result.config.plugins?.load?.paths).toEqual([42, "/other/path"]); }); it("formats a doctor hint for legacy bundled plugin paths", () => { @@ -175,7 +185,7 @@ describe("bundled plugin load path repair", () => { }); expect(warnings).toEqual([ - expect.stringContaining(`plugins.load.paths: legacy bundled plugin path "${legacyPath}"`), + expect.stringContaining(`plugins.load.paths: bundled plugin path "${legacyPath}"`), expect.stringContaining('Run "openclaw doctor --fix"'), ]); }); diff --git a/src/commands/doctor/shared/bundled-plugin-load-paths.ts b/src/commands/doctor/shared/bundled-plugin-load-paths.ts index 574828960bd..a786daa8099 100644 --- a/src/commands/doctor/shared/bundled-plugin-load-paths.ts +++ b/src/commands/doctor/shared/bundled-plugin-load-paths.ts @@ -1,6 +1,9 @@ -import path from "node:path"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../../agents/agent-scope.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; +import { + buildBundledPluginLoadPathAliases, + normalizeBundledLookupPath, +} from "../../../plugins/bundled-load-path-aliases.js"; import { resolveBundledPluginSources } from "../../../plugins/bundled-sources.js"; import { sanitizeForLog } from "../../../terminal/ansi.js"; import { resolveUserPath } from "../../../utils.js"; @@ -17,37 +20,6 @@ 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, @@ -67,16 +39,14 @@ export function scanBundledPluginLoadPathMigrations( return []; } - const legacyPathMap = new Map(); + const bundledPathMap = new Map(); for (const source of bundled.values()) { - const legacyPath = buildLegacyBundledPath(source.localPath); - if (!legacyPath) { - continue; + for (const alias of buildBundledPluginLoadPathAliases(source.localPath)) { + bundledPathMap.set(normalizeBundledLookupPath(alias.path), { + pluginId: source.pluginId, + toPath: source.localPath, + }); } - legacyPathMap.set(normalizeBundledLookupPath(legacyPath), { - pluginId: source.pluginId, - toPath: source.localPath, - }); } const hits: BundledPluginLoadPathHit[] = []; @@ -85,7 +55,7 @@ export function scanBundledPluginLoadPathMigrations( continue; } const normalized = normalizeBundledLookupPath(resolveUserPath(rawPath, env)); - const match = legacyPathMap.get(normalized); + const match = bundledPathMap.get(normalized); if (!match) { continue; } @@ -109,9 +79,9 @@ export function collectBundledPluginLoadPathWarnings(params: { } 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}".`, + `- ${hit.pathLabel}: bundled plugin path "${hit.fromPath}" still aliases ${hit.pluginId}; OpenClaw loads the packaged bundled plugin from "${hit.toPath}".`, ); - lines.push(`- Run "${params.doctorFixCommand}" to rewrite these bundled plugin paths.`); + lines.push(`- Run "${params.doctorFixCommand}" to remove these redundant bundled plugin paths.`); return lines.map((line) => sanitizeForLog(line)); } @@ -133,8 +103,8 @@ export function maybeRepairBundledPluginLoadPaths( return { config: cfg, changes: [] }; } - const replacements = new Map( - hits.map((hit) => [normalizeBundledLookupPath(resolveUserPath(hit.fromPath, env)), hit]), + const removable = new Set( + hits.map((hit) => normalizeBundledLookupPath(resolveUserPath(hit.fromPath, env))), ); const seen = new Set(); const rewritten: Array<(typeof paths)[number]> = []; @@ -144,13 +114,14 @@ export function maybeRepairBundledPluginLoadPaths( continue; } const resolved = normalizeBundledLookupPath(resolveUserPath(entry, env)); - const replacement = replacements.get(resolved)?.toPath ?? entry; - const replacementResolved = normalizeBundledLookupPath(resolveUserPath(replacement, env)); - if (seen.has(replacementResolved)) { + if (removable.has(resolved)) { continue; } - seen.add(replacementResolved); - rewritten.push(replacement); + if (seen.has(resolved)) { + continue; + } + seen.add(resolved); + rewritten.push(entry); } next.plugins = { @@ -164,8 +135,7 @@ export function maybeRepairBundledPluginLoadPaths( return { config: next, changes: hits.map( - (hit) => - `- plugins.load.paths: rewrote bundled ${hit.pluginId} path from ${hit.fromPath} to ${hit.toPath}`, + (hit) => `- plugins.load.paths: removed bundled ${hit.pluginId} path alias ${hit.fromPath}`, ), }; } diff --git a/src/plugins/bundle-commands.test.ts b/src/plugins/bundle-commands.test.ts index e1357a3e420..3615ab72d3e 100644 --- a/src/plugins/bundle-commands.test.ts +++ b/src/plugins/bundle-commands.test.ts @@ -34,6 +34,12 @@ vi.mock("./config-state.js", async (importOriginal) => ({ }) => ({ activated: params.config?.entries?.[params.id]?.enabled !== false, }), + resolveEffectiveEnableState: (params: { + config?: { entries?: Record }; + id: string; + }) => ({ + enabled: params.config?.entries?.[params.id]?.enabled !== false, + }), })); const { loadEnabledClaudeBundleCommands } = await import("./bundle-commands.js"); diff --git a/src/plugins/bundled-load-path-aliases.ts b/src/plugins/bundled-load-path-aliases.ts new file mode 100644 index 00000000000..3cb0b67bfb0 --- /dev/null +++ b/src/plugins/bundled-load-path-aliases.ts @@ -0,0 +1,98 @@ +import path from "node:path"; +import { isPathInside } from "./path-safety.js"; + +export type BundledPluginLoadPathAliasKind = "current" | "legacy"; + +export type BundledPluginLoadPathAlias = { + kind: BundledPluginLoadPathAliasKind; + path: string; +}; + +const PACKAGED_BUNDLED_ROOTS = [ + path.join("dist", "extensions"), + path.join("dist-runtime", "extensions"), +] as const; + +export 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 findPackagedBundledRoot(localPath: string): { + packageRoot: string; + bundledRoot: string; +} | null { + const normalized = normalizeBundledLookupPath(localPath); + for (const packagedRoot of PACKAGED_BUNDLED_ROOTS) { + const marker = `${path.sep}${packagedRoot}`; + const markerIndex = normalized.lastIndexOf(marker); + if (markerIndex === -1) { + continue; + } + const markerEnd = markerIndex + marker.length; + if (normalized.length !== markerEnd && normalized[markerEnd] !== path.sep) { + continue; + } + return { + packageRoot: normalized.slice(0, markerIndex), + bundledRoot: normalized.slice(0, markerEnd), + }; + } + return null; +} + +export function buildLegacyBundledPath(localPath: string): string | null { + const packaged = findPackagedBundledRoot(localPath); + if (!packaged) { + return null; + } + const normalized = normalizeBundledLookupPath(localPath); + const bundledLeaf = + normalized === packaged.bundledRoot + ? "" + : normalized.slice(packaged.bundledRoot.length + path.sep.length); + return bundledLeaf ? path.join(packaged.packageRoot, "extensions", bundledLeaf) : null; +} + +export function buildBundledPluginLoadPathAliases(localPath: string): BundledPluginLoadPathAlias[] { + const legacyPath = buildLegacyBundledPath(localPath); + if (!legacyPath) { + return []; + } + return [ + { kind: "current", path: localPath }, + { kind: "legacy", path: legacyPath }, + ]; +} + +function isSameOrInside(baseDir: string, targetPath: string): boolean { + const base = path.resolve(normalizeBundledLookupPath(baseDir)); + const target = path.resolve(normalizeBundledLookupPath(targetPath)); + return target === base || isPathInside(base, target); +} + +export function resolvePackagedBundledLoadPathAlias(params: { + bundledRoot?: string; + loadPath: string; +}): BundledPluginLoadPathAlias | null { + if (!params.bundledRoot) { + return null; + } + const packaged = findPackagedBundledRoot(params.bundledRoot); + if (!packaged) { + return null; + } + const legacyRoot = path.join(packaged.packageRoot, "extensions"); + if (isSameOrInside(params.bundledRoot, params.loadPath)) { + return { kind: "current", path: params.loadPath }; + } + if (isSameOrInside(legacyRoot, params.loadPath)) { + return { kind: "legacy", path: params.loadPath }; + } + return null; +} diff --git a/src/plugins/discovery.test.ts b/src/plugins/discovery.test.ts index fd5148776fe..630e8a7c669 100644 --- a/src/plugins/discovery.test.ts +++ b/src/plugins/discovery.test.ts @@ -391,6 +391,68 @@ describe("discoverOpenClawPlugins", () => { expect(diagnostics).toEqual([]); }); + it("ignores packaged bundled plugin paths in configured load paths", () => { + const stateDir = makeTempDir(); + const packageRoot = path.join(stateDir, "node_modules", "openclaw"); + const bundledRoot = path.join(packageRoot, "dist", "extensions"); + const bundledPluginDir = path.join(bundledRoot, "feishu"); + mkdirSafe(bundledPluginDir); + writePluginManifest({ pluginDir: bundledPluginDir, id: "feishu" }); + writePluginEntry(path.join(bundledPluginDir, "index.js")); + + const { candidates, diagnostics } = discoverOpenClawPlugins({ + extraPaths: [bundledPluginDir], + env: { + ...buildDiscoveryEnv(stateDir), + OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot, + }, + }); + + expect(candidates.filter((candidate) => candidate.idHint === "feishu")).toEqual([ + expect.objectContaining({ origin: "bundled" }), + ]); + expect(diagnostics).toEqual([ + expect.objectContaining({ + level: "warn", + source: bundledPluginDir, + message: expect.stringContaining("ignored plugins.load.paths entry"), + }), + ]); + }); + + it("ignores legacy bundled plugin load paths that would shadow packaged bundled plugins", () => { + const stateDir = makeTempDir(); + const packageRoot = path.join(stateDir, "node_modules", "openclaw"); + const bundledRoot = path.join(packageRoot, "dist-runtime", "extensions"); + const bundledPluginDir = path.join(bundledRoot, "telegram"); + const legacyPluginDir = path.join(packageRoot, "extensions", "telegram"); + mkdirSafe(bundledPluginDir); + mkdirSafe(legacyPluginDir); + writePluginManifest({ pluginDir: bundledPluginDir, id: "telegram" }); + writePluginManifest({ pluginDir: legacyPluginDir, id: "telegram" }); + writePluginEntry(path.join(bundledPluginDir, "index.js")); + writePluginEntry(path.join(legacyPluginDir, "index.js")); + + const { candidates, diagnostics } = discoverOpenClawPlugins({ + extraPaths: [legacyPluginDir], + env: { + ...buildDiscoveryEnv(stateDir), + OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot, + }, + }); + + expect(candidates.filter((candidate) => candidate.idHint === "telegram")).toEqual([ + expect.objectContaining({ origin: "bundled" }), + ]); + expect(diagnostics).toEqual([ + expect.objectContaining({ + level: "warn", + source: legacyPluginDir, + message: expect.stringContaining("legacy bundled plugin directory"), + }), + ]); + }); + it("loads package extension packs", async () => { const stateDir = makeTempDir(); const globalExt = path.join(stateDir, "extensions", "pack"); diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index 063e968f93c..f1406445fb8 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -8,6 +8,7 @@ import { } from "../shared/string-coerce.js"; import { resolveUserPath } from "../utils.js"; import { detectBundleManifestFormat, loadBundleManifest } from "./bundle-manifest.js"; +import { resolvePackagedBundledLoadPathAlias } from "./bundled-load-path-aliases.js"; import type { PluginBundleFormat, PluginDiagnostic, PluginFormat } from "./manifest-types.js"; import { DEFAULT_PLUGIN_ENTRY_CANDIDATES, @@ -1145,6 +1146,18 @@ export function discoverOpenClawPlugins(params: { if (!trimmed) { continue; } + const bundledAlias = resolvePackagedBundledLoadPathAlias({ + bundledRoot: roots.stock, + loadPath: resolveUserPath(trimmed, env), + }); + if (bundledAlias) { + result.diagnostics.push({ + level: "warn", + source: trimmed, + message: `ignored plugins.load.paths entry that points at OpenClaw's ${bundledAlias.kind} bundled plugin directory; remove this redundant path or run openclaw doctor --fix`, + }); + continue; + } discoverFromPath({ rawPath: trimmed, origin: "config",