mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:40:44 +00:00
fix(doctor): respect channel owner plugin repairs
This commit is contained in:
@@ -43,6 +43,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
- Release validation: install the cross-OS TypeScript harness through Windows-safe Node/npm shims so native Windows package checks reach the OpenClaw smoke suites instead of exiting before artifact capture. Thanks @vincentkoc.
|
||||
- Release validation: let Windows packaged-upgrade checks continue after the shipped 2026.5.2 updater hits its native-module swap cleanup fallback, verifying the fallback-installed candidate through package metadata and downstream smoke instead of crashing on the immediate update-status probe. Thanks @vincentkoc.
|
||||
- Doctor/plugins: skip channel-derived official plugin installs when another configured plugin is the effective owner for the same channel, so `doctor --repair` does not reinstall `feishu` while `openclaw-lark` handles `channels.feishu`. Fixes #76623. Thanks @fuyizheng3120.
|
||||
- Agents/bootstrap: keep pending `BOOTSTRAP.md` and bootstrap truncation notices in system-prompt Project Context instead of copying setup text or raw warning diagnostics into WebChat user/runtime context. Fixes #76946.
|
||||
- Channels/WhatsApp: allow `@whiskeysockets/libsignal-node` in `onlyBuiltDependencies` so pnpm v9+ `blockExoticSubdeps` no longer rejects the baileys git-tarball subdep and silences all inbound agent replies. Fixes #76539. Thanks @ottodeng and @vincentkoc.
|
||||
- Gateway/install: keep `.env`-managed values in the macOS LaunchAgent env file while still tracking `OPENCLAW_SERVICE_MANAGED_ENV_KEYS`, so regenerated services do not boot without managed auth/provider keys. Fixes #75374.
|
||||
|
||||
@@ -514,6 +514,40 @@ describe("repairMissingConfiguredPluginInstalls", () => {
|
||||
expect(result).toEqual({ changes: [], warnings: [] });
|
||||
});
|
||||
|
||||
it("does not install channel plugins when the matching plugin entry is disabled", async () => {
|
||||
mocks.listChannelPluginCatalogEntries.mockReturnValue([
|
||||
{
|
||||
id: "matrix",
|
||||
pluginId: "matrix",
|
||||
meta: { label: "Matrix" },
|
||||
install: {
|
||||
npmSpec: "@openclaw/plugin-matrix@1.2.3",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const { repairMissingConfiguredPluginInstalls } =
|
||||
await import("./missing-configured-plugin-install.js");
|
||||
const result = await repairMissingConfiguredPluginInstalls({
|
||||
cfg: {
|
||||
plugins: {
|
||||
entries: {
|
||||
matrix: { enabled: false },
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
matrix: { homeserver: "https://matrix.example.org" },
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(mocks.installPluginFromClawHub).not.toHaveBeenCalled();
|
||||
expect(mocks.installPluginFromNpmSpec).not.toHaveBeenCalled();
|
||||
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ changes: [], warnings: [] });
|
||||
});
|
||||
|
||||
it("does not download configured channel plugins that are still bundled", async () => {
|
||||
mocks.listChannelPluginCatalogEntries.mockReturnValue([
|
||||
{
|
||||
@@ -1126,6 +1160,218 @@ describe("repairMissingConfiguredPluginInstalls", () => {
|
||||
expect(result).toEqual({ changes: [], warnings: [] });
|
||||
});
|
||||
|
||||
it("does not install a channel catalog plugin when a configured plugin already owns that channel", async () => {
|
||||
mocks.loadPluginMetadataSnapshot.mockReturnValue({
|
||||
plugins: [
|
||||
{
|
||||
id: "openclaw-lark",
|
||||
origin: "config",
|
||||
channels: ["feishu"],
|
||||
channelConfigs: {
|
||||
feishu: {
|
||||
schema: {
|
||||
type: "object",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
diagnostics: [],
|
||||
});
|
||||
mocks.listChannelPluginCatalogEntries.mockReturnValue([
|
||||
{
|
||||
id: "feishu",
|
||||
pluginId: "feishu",
|
||||
meta: { label: "Feishu" },
|
||||
install: {
|
||||
npmSpec: "@openclaw/feishu",
|
||||
},
|
||||
trustedSourceLinkedOfficialInstall: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const { repairMissingConfiguredPluginInstalls } =
|
||||
await import("./missing-configured-plugin-install.js");
|
||||
const result = await repairMissingConfiguredPluginInstalls({
|
||||
cfg: {
|
||||
plugins: {
|
||||
entries: {
|
||||
"openclaw-lark": {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
feishu: {
|
||||
footer: {
|
||||
model: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(mocks.installPluginFromClawHub).not.toHaveBeenCalled();
|
||||
expect(mocks.installPluginFromNpmSpec).not.toHaveBeenCalled();
|
||||
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ changes: [], warnings: [] });
|
||||
});
|
||||
|
||||
it("still installs a channel catalog plugin when the configured owner is blocked by the allowlist", async () => {
|
||||
mocks.loadPluginMetadataSnapshot.mockReturnValue({
|
||||
plugins: [
|
||||
{
|
||||
id: "openclaw-lark",
|
||||
origin: "config",
|
||||
channels: ["feishu"],
|
||||
channelConfigs: {
|
||||
feishu: {
|
||||
schema: {
|
||||
type: "object",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
diagnostics: [],
|
||||
});
|
||||
mocks.listChannelPluginCatalogEntries.mockReturnValue([
|
||||
{
|
||||
id: "feishu",
|
||||
pluginId: "feishu",
|
||||
meta: { label: "Feishu" },
|
||||
install: {
|
||||
npmSpec: "@openclaw/feishu",
|
||||
},
|
||||
trustedSourceLinkedOfficialInstall: true,
|
||||
},
|
||||
]);
|
||||
mocks.installPluginFromNpmSpec.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
pluginId: "feishu",
|
||||
targetDir: "/tmp/openclaw-plugins/feishu",
|
||||
version: "2026.5.2",
|
||||
npmResolution: {
|
||||
name: "@openclaw/feishu",
|
||||
version: "2026.5.2",
|
||||
resolvedSpec: "@openclaw/feishu@2026.5.2",
|
||||
},
|
||||
});
|
||||
|
||||
const { repairMissingConfiguredPluginInstalls } =
|
||||
await import("./missing-configured-plugin-install.js");
|
||||
const result = await repairMissingConfiguredPluginInstalls({
|
||||
cfg: {
|
||||
plugins: {
|
||||
allow: ["some-other-plugin"],
|
||||
entries: {
|
||||
"openclaw-lark": {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
feishu: {
|
||||
footer: {
|
||||
model: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(mocks.installPluginFromNpmSpec).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
spec: "@openclaw/feishu",
|
||||
expectedPluginId: "feishu",
|
||||
trustedSourceLinkedOfficialInstall: true,
|
||||
}),
|
||||
);
|
||||
expect(result.changes).toEqual([
|
||||
'Installed missing configured plugin "feishu" from @openclaw/feishu.',
|
||||
]);
|
||||
});
|
||||
|
||||
it("still installs a channel catalog plugin when that plugin is explicitly configured", async () => {
|
||||
mocks.loadPluginMetadataSnapshot.mockReturnValue({
|
||||
plugins: [
|
||||
{
|
||||
id: "openclaw-lark",
|
||||
origin: "config",
|
||||
channels: ["feishu"],
|
||||
channelConfigs: {
|
||||
feishu: {
|
||||
schema: {
|
||||
type: "object",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
diagnostics: [],
|
||||
});
|
||||
mocks.listChannelPluginCatalogEntries.mockReturnValue([
|
||||
{
|
||||
id: "feishu",
|
||||
pluginId: "feishu",
|
||||
meta: { label: "Feishu" },
|
||||
install: {
|
||||
npmSpec: "@openclaw/feishu",
|
||||
},
|
||||
trustedSourceLinkedOfficialInstall: true,
|
||||
},
|
||||
]);
|
||||
mocks.installPluginFromNpmSpec.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
pluginId: "feishu",
|
||||
targetDir: "/tmp/openclaw-plugins/feishu",
|
||||
version: "2026.5.2",
|
||||
npmResolution: {
|
||||
name: "@openclaw/feishu",
|
||||
version: "2026.5.2",
|
||||
resolvedSpec: "@openclaw/feishu@2026.5.2",
|
||||
},
|
||||
});
|
||||
|
||||
const { repairMissingConfiguredPluginInstalls } =
|
||||
await import("./missing-configured-plugin-install.js");
|
||||
const result = await repairMissingConfiguredPluginInstalls({
|
||||
cfg: {
|
||||
plugins: {
|
||||
entries: {
|
||||
feishu: {
|
||||
enabled: true,
|
||||
},
|
||||
"openclaw-lark": {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
feishu: {
|
||||
footer: {
|
||||
model: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(mocks.installPluginFromNpmSpec).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
spec: "@openclaw/feishu",
|
||||
expectedPluginId: "feishu",
|
||||
trustedSourceLinkedOfficialInstall: true,
|
||||
}),
|
||||
);
|
||||
expect(result.changes).toEqual([
|
||||
'Installed missing configured plugin "feishu" from @openclaw/feishu.',
|
||||
]);
|
||||
});
|
||||
|
||||
it("reinstalls a missing configured plugin from its persisted install record", async () => {
|
||||
const records = {
|
||||
demo: {
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { OpenClawConfig } from "../../../config/types.openclaw.js";
|
||||
import type { PluginInstallRecord } from "../../../config/types.plugins.js";
|
||||
import { parseClawHubPluginSpec } from "../../../infra/clawhub-spec.js";
|
||||
import { parseRegistryNpmSpec } from "../../../infra/npm-registry-spec.js";
|
||||
import { resolveConfiguredChannelPresencePolicy } from "../../../plugins/channel-plugin-ids.js";
|
||||
import { buildClawHubPluginInstallRecordFields } from "../../../plugins/clawhub-install-records.js";
|
||||
import { CLAWHUB_INSTALL_ERROR_CODE, installPluginFromClawHub } from "../../../plugins/clawhub.js";
|
||||
import { resolveDefaultPluginExtensionsDir } from "../../../plugins/install-paths.js";
|
||||
@@ -29,6 +30,7 @@ import type { PluginMetadataSnapshot } from "../../../plugins/plugin-metadata-sn
|
||||
import { resolveProviderInstallCatalogEntries } from "../../../plugins/provider-install-catalog.js";
|
||||
import { updateNpmInstalledPlugins } from "../../../plugins/update.js";
|
||||
import { resolveWebSearchInstallCatalogEntry } from "../../../plugins/web-search-install-catalog.js";
|
||||
import { normalizeOptionalLowercaseString } from "../../../shared/string-coerce.js";
|
||||
import { resolveUserPath } from "../../../utils.js";
|
||||
import { asObjectRecord } from "./object.js";
|
||||
|
||||
@@ -139,6 +141,25 @@ function collectConfiguredPluginIds(cfg: OpenClawConfig, env?: NodeJS.ProcessEnv
|
||||
return ids;
|
||||
}
|
||||
|
||||
function collectBlockedPluginIds(cfg: OpenClawConfig): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
const deny = cfg.plugins?.deny;
|
||||
if (Array.isArray(deny)) {
|
||||
for (const pluginId of deny) {
|
||||
if (typeof pluginId === "string" && pluginId.trim()) {
|
||||
ids.add(pluginId.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
const entries = asObjectRecord(cfg.plugins?.entries);
|
||||
for (const [pluginId, entry] of Object.entries(entries ?? {})) {
|
||||
if (pluginId.trim() && asObjectRecord(entry)?.enabled === false) {
|
||||
ids.add(pluginId.trim());
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function collectConfiguredChannelIds(cfg: OpenClawConfig, env?: NodeJS.ProcessEnv): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
if (asObjectRecord(cfg.plugins)?.enabled === false) {
|
||||
@@ -161,12 +182,45 @@ function collectConfiguredChannelIds(cfg: OpenClawConfig, env?: NodeJS.ProcessEn
|
||||
return ids;
|
||||
}
|
||||
|
||||
function collectEffectiveConfiguredChannelOwnerPluginIds(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
snapshot: PluginMetadataSnapshot;
|
||||
configuredChannelIds: ReadonlySet<string>;
|
||||
}): Map<string, Set<string>> {
|
||||
const owners = new Map<string, Set<string>>();
|
||||
const configuredChannelIds = new Set(
|
||||
[...params.configuredChannelIds]
|
||||
.map((channelId) => normalizeOptionalLowercaseString(channelId))
|
||||
.filter((channelId): channelId is string => Boolean(channelId)),
|
||||
);
|
||||
if (configuredChannelIds.size === 0) {
|
||||
return owners;
|
||||
}
|
||||
for (const entry of resolveConfiguredChannelPresencePolicy({
|
||||
config: params.cfg,
|
||||
env: params.env,
|
||||
includePersistedAuthState: false,
|
||||
manifestRecords: params.snapshot.plugins,
|
||||
})) {
|
||||
if (!entry.effective || !configuredChannelIds.has(entry.channelId)) {
|
||||
continue;
|
||||
}
|
||||
const pluginIds = new Set(entry.pluginIds);
|
||||
if (pluginIds.size > 0) {
|
||||
owners.set(entry.channelId, pluginIds);
|
||||
}
|
||||
}
|
||||
return owners;
|
||||
}
|
||||
|
||||
function collectDownloadableInstallCandidates(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
missingPluginIds: ReadonlySet<string>;
|
||||
configuredPluginIds?: ReadonlySet<string>;
|
||||
configuredChannelIds?: ReadonlySet<string>;
|
||||
configuredChannelOwnerPluginIds?: ReadonlyMap<string, ReadonlySet<string>>;
|
||||
blockedPluginIds?: ReadonlySet<string>;
|
||||
}): DownloadableInstallCandidate[] {
|
||||
const configuredPluginIds =
|
||||
@@ -183,9 +237,25 @@ function collectDownloadableInstallCandidates(params: {
|
||||
continue;
|
||||
}
|
||||
const pluginId = entry.pluginId ?? entry.id;
|
||||
const channelId = normalizeOptionalLowercaseString(entry.id);
|
||||
if (params.blockedPluginIds?.has(pluginId)) {
|
||||
continue;
|
||||
}
|
||||
const selectedOnlyByChannel =
|
||||
!params.missingPluginIds.has(pluginId) &&
|
||||
!configuredPluginIds.has(pluginId) &&
|
||||
(channelId ? configuredChannelIds.has(channelId) : configuredChannelIds.has(entry.id));
|
||||
const configuredChannelOwnerPluginIds = channelId
|
||||
? params.configuredChannelOwnerPluginIds?.get(channelId)
|
||||
: undefined;
|
||||
if (
|
||||
selectedOnlyByChannel &&
|
||||
configuredChannelOwnerPluginIds &&
|
||||
configuredChannelOwnerPluginIds.size > 0 &&
|
||||
!configuredChannelOwnerPluginIds.has(pluginId)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
!params.missingPluginIds.has(pluginId) &&
|
||||
!configuredPluginIds.has(pluginId) &&
|
||||
@@ -292,6 +362,7 @@ function collectUpdateDeferredPluginIds(params: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
configuredPluginIds: ReadonlySet<string>;
|
||||
configuredChannelIds: ReadonlySet<string>;
|
||||
configuredChannelOwnerPluginIds?: ReadonlyMap<string, ReadonlySet<string>>;
|
||||
blockedPluginIds?: ReadonlySet<string>;
|
||||
}): Set<string> {
|
||||
const pluginIds = new Set(params.configuredPluginIds);
|
||||
@@ -301,6 +372,7 @@ function collectUpdateDeferredPluginIds(params: {
|
||||
missingPluginIds: new Set(),
|
||||
configuredPluginIds: params.configuredPluginIds,
|
||||
configuredChannelIds: params.configuredChannelIds,
|
||||
configuredChannelOwnerPluginIds: params.configuredChannelOwnerPluginIds,
|
||||
blockedPluginIds: params.blockedPluginIds,
|
||||
})) {
|
||||
pluginIds.add(candidate.pluginId);
|
||||
@@ -488,6 +560,7 @@ export async function repairMissingConfiguredPluginInstalls(params: {
|
||||
env: params.env,
|
||||
pluginIds: collectConfiguredPluginIds(params.cfg, params.env),
|
||||
channelIds: collectConfiguredChannelIds(params.cfg, params.env),
|
||||
blockedPluginIds: collectBlockedPluginIds(params.cfg),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -530,6 +603,12 @@ async function repairMissingPluginInstalls(params: {
|
||||
env,
|
||||
});
|
||||
const knownIds = new Set(snapshot.plugins.map((plugin) => plugin.id));
|
||||
const configuredChannelOwnerPluginIds = collectEffectiveConfiguredChannelOwnerPluginIds({
|
||||
cfg: params.cfg,
|
||||
env,
|
||||
snapshot,
|
||||
configuredChannelIds: params.channelIds,
|
||||
});
|
||||
const bundledPluginsById = new Map(
|
||||
snapshot.plugins
|
||||
.filter((plugin) => plugin.origin === "bundled")
|
||||
@@ -569,6 +648,7 @@ async function repairMissingPluginInstalls(params: {
|
||||
env,
|
||||
configuredPluginIds: params.pluginIds,
|
||||
configuredChannelIds: params.channelIds,
|
||||
configuredChannelOwnerPluginIds,
|
||||
blockedPluginIds: params.blockedPluginIds,
|
||||
});
|
||||
for (const pluginId of updateDeferredPluginIds) {
|
||||
@@ -642,6 +722,7 @@ async function repairMissingPluginInstalls(params: {
|
||||
missingPluginIds,
|
||||
configuredPluginIds: params.pluginIds,
|
||||
configuredChannelIds: params.channelIds,
|
||||
configuredChannelOwnerPluginIds,
|
||||
blockedPluginIds:
|
||||
deferredPluginIds.size > 0
|
||||
? new Set([...(params.blockedPluginIds ?? []), ...deferredPluginIds])
|
||||
|
||||
Reference in New Issue
Block a user