mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:50:43 +00:00
fix(plugins): ignore bundled load path aliases
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"'),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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}`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
98
src/plugins/bundled-load-path-aliases.ts
Normal file
98
src/plugins/bundled-load-path-aliases.ts
Normal 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;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user