fix(plugins): ignore bundled load path aliases

This commit is contained in:
Peter Steinberger
2026-04-26 04:39:49 +01:00
parent 42f87c07e9
commit 540c70d166
8 changed files with 223 additions and 61 deletions

View File

@@ -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.

View File

@@ -178,7 +178,9 @@ OpenClaw scans for plugins in this order (first match wins):
<Steps>
<Step title="Config paths">
`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.
</Step>
<Step title="Workspace plugins">

View File

@@ -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"'),
]);
});

View File

@@ -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<string, { pluginId: string; toPath: string }>();
const bundledPathMap = new Map<string, { pluginId: string; toPath: string }>();
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<string>();
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}`,
),
};
}

View File

@@ -34,6 +34,12 @@ vi.mock("./config-state.js", async (importOriginal) => ({
}) => ({
activated: params.config?.entries?.[params.id]?.enabled !== false,
}),
resolveEffectiveEnableState: (params: {
config?: { entries?: Record<string, { enabled?: boolean }> };
id: string;
}) => ({
enabled: params.config?.entries?.[params.id]?.enabled !== false,
}),
}));
const { loadEnabledClaudeBundleCommands } = await import("./bundle-commands.js");

View File

@@ -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;
}

View File

@@ -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");

View File

@@ -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",