diff --git a/CHANGELOG.md b/CHANGELOG.md index b38e55a25a2..08e89fb2096 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index 44759ed6903..b0bcda0321e 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -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 }; } diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index d7390306ac7..8502a1da56a 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -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({