fix(plugins): prioritize bundled duplicates in auto-discovery

Landed from contributor PR #29710 by @Sid-Qin.

Co-authored-by: SidQin-cyber <sidqin0410@gmail.com>
This commit is contained in:
Peter Steinberger
2026-03-01 23:48:30 +00:00
parent 5056b6438d
commit 577becf1ad
3 changed files with 56 additions and 10 deletions

View File

@@ -92,6 +92,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Plugins/Discovery precedence: load bundled plugins before auto-discovered global extensions so bundled channel plugins win duplicate-ID resolution by default (explicit `plugins.load.paths` overrides remain highest precedence), with loader regression coverage. Landed from contributor PR #29710 by @Sid-Qin. Thanks @Sid-Qin.
- Discord/Reconnect integrity: release Discord message listener lane immediately while preserving serialized handler execution, add HELLO-stall resume-first recovery with bounded fresh-identify fallback after repeated stalls, and extend lifecycle/listener regression coverage for forced reconnect scenarios. Landed from contributor PR #29508 by @cgdusek. Thanks @cgdusek.
- Security/Skills: harden skill installer metadata parsing by rejecting unsafe installer specs (brew/node/go/uv/download) and constrain plugin-declared skill directories to the plugin root (including symlink-escape checks), with regression coverage.
- ACP/Harness thread spawn routing: force ACP harness thread creation through `sessions_spawn` (`runtime: "acp"`, `thread: true`) and explicitly forbid `message action=thread-create` for ACP harness requests, avoiding misrouted `Unknown channel` errors. (#30957) Thanks @dutifulbob.

View File

@@ -609,16 +609,6 @@ export function discoverOpenClawPlugins(params: {
}
}
const globalDir = path.join(resolveConfigDir(), "extensions");
discoverInDirectory({
dir: globalDir,
origin: "global",
ownershipUid: params.ownershipUid,
candidates,
diagnostics,
seen,
});
const bundledDir = resolveBundledPluginsDir();
if (bundledDir) {
discoverInDirectory({
@@ -631,5 +621,17 @@ export function discoverOpenClawPlugins(params: {
});
}
// Keep auto-discovered global extensions behind bundled plugins.
// Users can still intentionally override via plugins.load.paths (origin=config).
const globalDir = path.join(resolveConfigDir(), "extensions");
discoverInDirectory({
dir: globalDir,
origin: "global",
ownershipUid: params.ownershipUid,
candidates,
diagnostics,
seen,
});
return { candidates, diagnostics };
}

View File

@@ -569,6 +569,49 @@ describe("loadOpenClawPlugins", () => {
expect(loaded?.origin).toBe("config");
expect(overridden?.origin).toBe("bundled");
});
it("prefers bundled plugin over auto-discovered global duplicate ids", () => {
const bundledDir = makeTempDir();
writePlugin({
id: "feishu",
body: `export default { id: "feishu", register() {} };`,
dir: bundledDir,
filename: "index.js",
});
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir;
const stateDir = makeTempDir();
withEnv({ OPENCLAW_STATE_DIR: stateDir, CLAWDBOT_STATE_DIR: undefined }, () => {
const globalDir = path.join(stateDir, "extensions", "feishu");
fs.mkdirSync(globalDir, { recursive: true });
writePlugin({
id: "feishu",
body: `export default { id: "feishu", register() {} };`,
dir: globalDir,
filename: "index.js",
});
const registry = loadOpenClawPlugins({
cache: false,
config: {
plugins: {
allow: ["feishu"],
entries: {
feishu: { enabled: true },
},
},
},
});
const entries = registry.plugins.filter((entry) => entry.id === "feishu");
const loaded = entries.find((entry) => entry.status === "loaded");
const overridden = entries.find((entry) => entry.status === "disabled");
expect(loaded?.origin).toBe("bundled");
expect(overridden?.origin).toBe("global");
expect(overridden?.error).toContain("overridden by bundled plugin");
});
});
it("warns when plugins.allow is empty and non-bundled plugins are discoverable", () => {
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
const plugin = writePlugin({