From 098a6d4411a861a31b836c482b1950038ac835a4 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 20 Apr 2026 19:19:56 -0400 Subject: [PATCH] fix: tighten read-only external channel discovery --- CHANGELOG.md | 2 +- docs/plugins/manifest.md | 8 +- docs/plugins/sdk-channel-plugins.md | 8 ++ src/channels/plugins/read-only.test.ts | 109 ++++++++++++++++++++----- src/channels/plugins/read-only.ts | 84 ++++++++++++++++--- src/plugins/loader.ts | 78 +++++++++++++----- 6 files changed, 236 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40e71b5e552..bd65c37a676 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,7 +78,7 @@ Docs: https://docs.openclaw.ai - fix(security): block MINIMAX_API_HOST workspace env injection and remove env-driven URL routing [AI-assisted]. (#67300) Thanks @pgondhi987. - Cron/delivery: treat explicit `delivery.mode: "none"` runs as not requested even if the runner reports `delivered: false`, so no-delivery cron jobs no longer persist false delivery failures or errors. (#69285) Thanks @matsuri1987. - Plugins/install: repair active and default-enabled bundled plugin runtime dependencies before import in packaged installs, so bundled Discord, WhatsApp, Slack, Telegram, and provider plugins work without putting their dependency trees in core. -- CLI/channels: keep `status`, `health`, `channels list`, and `channels status` on read-only channel metadata when Telegram, Slack, or Discord are configured, avoiding full bundled plugin runtime imports on those cold paths. Fixes #69042. +- CLI/channels: keep `status`, `health`, `channels list`, and `channels status` on read-only channel metadata when Telegram, Slack, Discord, or third-party channel plugins are configured, avoiding full bundled plugin runtime imports on those cold paths. Fixes #69042. (#69479) Thanks @gumadeiras. - BlueBubbles: raise the outbound `/api/v1/message/text` send timeout default from 10s to 30s, and add a configurable `channels.bluebubbles.sendTimeoutMs` (also per-account) so macOS 26 setups where Private API iMessage sends stall for 60+ seconds no longer silently lose messages at the 10s abort. Probes, chat lookups, and health checks keep the shorter 10s default. Fixes #67486. (#69193) Thanks @omarshahine. - Agents/bootstrap: budget truncation markers against per-file caps, preserve source content instead of silently wasting bootstrap bytes, and avoid marker-only output in tiny-budget truncation cases. (#69114) Thanks @BKF-Gitty. - Context engine/plugins: stop rejecting third-party context engines whose `info.id` differs from the registered plugin slot id. The strict-match contract added in 2026.4.14 broke `lossless-claw` and other plugins whose internal engine id does not equal the slot id they are registered under, producing repeated `info.id must match registered id` lane failures on every turn. Fixes #66601. (#66678) Thanks @GodsBoy. diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index afdc0f59c26..32b6ea28615 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -510,7 +510,7 @@ Important examples: | Field | What it means | | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | | `openclaw.extensions` | Declares native plugin entrypoints. | -| `openclaw.setupEntry` | Lightweight setup-only entrypoint used during onboarding and deferred channel startup. | +| `openclaw.setupEntry` | Lightweight setup-only entrypoint used during onboarding, deferred channel startup, and read-only channel status/SecretRef discovery. | | `openclaw.channel` | Cheap channel catalog metadata like labels, docs paths, aliases, and selection copy. | | `openclaw.channel.configuredState` | Lightweight configured-state checker metadata that can answer "does env-only setup already exist?" without loading the full channel runtime. | | `openclaw.channel.persistedAuthState` | Lightweight persisted-auth checker metadata that can answer "is anything already signed in?" without loading the full channel runtime. | @@ -524,6 +524,12 @@ Important examples: registry loading. Invalid values are rejected; newer-but-valid values skip the plugin on older hosts. +Channel plugins should provide `openclaw.setupEntry` when status, channel list, +or SecretRef scans need to identify configured accounts without loading the full +runtime. The setup entry should expose channel metadata plus setup-safe config, +status, and secrets adapters; keep network clients, gateway listeners, and +transport runtimes in the main extension entrypoint. + `openclaw.install.allowInvalidConfigRecovery` is intentionally narrow. It does not make arbitrary broken configs installable. Today it only allows install flows to recover from specific stale bundled-plugin upgrade failures, such as a diff --git a/docs/plugins/sdk-channel-plugins.md b/docs/plugins/sdk-channel-plugins.md index 8a7091af4ff..49e428757f9 100644 --- a/docs/plugins/sdk-channel-plugins.md +++ b/docs/plugins/sdk-channel-plugins.md @@ -139,6 +139,14 @@ If your channel supports env-driven setup or auth and generic startup/config flows should know those env names before runtime loads, declare them in the plugin manifest with `channelEnvVars`. Keep channel runtime `envVars` or local constants for operator-facing copy only. + +If your channel can appear in `status`, `channels list`, `channels status`, or +SecretRef scans before the plugin runtime starts, add `openclaw.setupEntry` in +`package.json`. That entrypoint should be safe to import in read-only command +paths and should return the channel metadata, setup-safe config adapter, status +adapter, and channel secret target metadata needed for those summaries. Do not +start clients, listeners, or transport runtimes from the setup entry. + `createOptionalChannelSetupWizard`, `DEFAULT_ACCOUNT_ID`, `createTopLevelChannelDmPolicy`, `setSetupChannelEnabled`, and `splitSetupEntries` diff --git a/src/channels/plugins/read-only.test.ts b/src/channels/plugins/read-only.test.ts index 21d7336cd28..d281ada0340 100644 --- a/src/channels/plugins/read-only.test.ts +++ b/src/channels/plugins/read-only.test.ts @@ -10,9 +10,18 @@ import { } from "../../plugins/loader.test-fixtures.js"; import { listReadOnlyChannelPluginsForConfig } from "./read-only.js"; -function writeExternalSetupChannelPlugin(options: { setupEntry?: boolean } = {}) { +function writeExternalSetupChannelPlugin( + options: { + setupEntry?: boolean; + pluginDir?: string; + pluginId?: string; + channelId?: string; + } = {}, +) { useNoBundledPlugins(); - const pluginDir = makeTempDir(); + const pluginDir = options.pluginDir ?? makeTempDir(); + const pluginId = options.pluginId ?? "external-chat"; + const channelId = options.channelId ?? "external-chat"; const fullMarker = path.join(pluginDir, "full-loaded.txt"); const setupMarker = path.join(pluginDir, "setup-loaded.txt"); const setupEntry = options.setupEntry !== false; @@ -21,7 +30,7 @@ function writeExternalSetupChannelPlugin(options: { setupEntry?: boolean } = {}) path.join(pluginDir, "package.json"), JSON.stringify( { - name: "@example/openclaw-external-chat", + name: `@example/openclaw-${pluginId}`, version: "1.0.0", openclaw: { extensions: ["./index.cjs"], @@ -37,9 +46,12 @@ function writeExternalSetupChannelPlugin(options: { setupEntry?: boolean } = {}) path.join(pluginDir, "openclaw.plugin.json"), JSON.stringify( { - id: "external-chat", + id: pluginId, configSchema: EMPTY_PLUGIN_SCHEMA, - channels: ["external-chat"], + channels: [channelId], + channelEnvVars: { + [channelId]: ["EXTERNAL_CHAT_TOKEN"], + }, }, null, 2, @@ -50,16 +62,16 @@ function writeExternalSetupChannelPlugin(options: { setupEntry?: boolean } = {}) path.join(pluginDir, "index.cjs"), `require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8"); module.exports = { - id: "external-chat", + id: ${JSON.stringify(pluginId)}, register(api) { api.registerChannel({ plugin: { - id: "external-chat", + id: ${JSON.stringify(channelId)}, meta: { - id: "external-chat", + id: ${JSON.stringify(channelId)}, label: "External Chat", selectionLabel: "External Chat", - docsPath: "/channels/external-chat", + docsPath: ${JSON.stringify(`/channels/${channelId}`)}, blurb: "full entry", }, capabilities: { chatTypes: ["direct"] }, @@ -71,10 +83,10 @@ module.exports = { secrets: { secretTargetRegistryEntries: [ { - id: "channels.external-chat.token", + id: ${JSON.stringify(`channels.${channelId}.token`)}, targetType: "channel", configFile: "openclaw.json", - pathPattern: "channels.external-chat.token", + pathPattern: ${JSON.stringify(`channels.${channelId}.token`)}, secretShape: "secret_input", expectedResolvedValue: "string", includeInPlan: true, @@ -95,12 +107,12 @@ module.exports = { `require("node:fs").writeFileSync(${JSON.stringify(setupMarker)}, "loaded", "utf-8"); module.exports = { plugin: { - id: "external-chat", + id: ${JSON.stringify(channelId)}, meta: { - id: "external-chat", + id: ${JSON.stringify(channelId)}, label: "External Chat", selectionLabel: "External Chat", - docsPath: "/channels/external-chat", + docsPath: ${JSON.stringify(`/channels/${channelId}`)}, blurb: "setup entry", }, capabilities: { chatTypes: ["direct"] }, @@ -112,10 +124,10 @@ module.exports = { secrets: { secretTargetRegistryEntries: [ { - id: "channels.external-chat.token", + id: ${JSON.stringify(`channels.${channelId}.token`)}, targetType: "channel", configFile: "openclaw.json", - pathPattern: "channels.external-chat.token", + pathPattern: ${JSON.stringify(`channels.${channelId}.token`)}, secretShape: "secret_input", expectedResolvedValue: "string", includeInPlan: true, @@ -192,13 +204,72 @@ describe("listReadOnlyChannelPluginsForConfig", () => { ); const plugin = plugins.find((entry) => entry.id === "external-chat"); - expect(plugin?.meta.blurb).toBe("full entry"); + expect(plugin).toBeUndefined(); + expect(fs.existsSync(setupMarker)).toBe(false); + expect(fs.existsSync(fullMarker)).toBe(false); + }); + + it("uses external channel env vars as read-only configuration triggers", () => { + const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin({ + pluginId: "external-chat-plugin", + channelId: "external-chat", + }); + const plugins = listReadOnlyChannelPluginsForConfig( + { + plugins: { + load: { paths: [pluginDir] }, + allow: ["external-chat-plugin"], + }, + } as never, + { + env: { ...process.env, EXTERNAL_CHAT_TOKEN: "configured" }, + includePersistedAuthState: false, + }, + ); + + const plugin = plugins.find((entry) => entry.id === "external-chat"); + expect(plugin?.meta.blurb).toBe("setup entry"); expect( plugin?.secrets?.secretTargetRegistryEntries?.some( (entry) => entry.id === "channels.external-chat.token", ), ).toBe(true); - expect(fs.existsSync(setupMarker)).toBe(false); - expect(fs.existsSync(fullMarker)).toBe(true); + expect(fs.existsSync(setupMarker)).toBe(true); + expect(fs.existsSync(fullMarker)).toBe(false); + }); + + it("discovers trusted external channel plugins from the default agent workspace", () => { + const workspaceDir = makeTempDir(); + const pluginDir = path.join(workspaceDir, ".openclaw", "extensions", "external-chat-plugin"); + fs.mkdirSync(pluginDir, { recursive: true }); + const { fullMarker, setupMarker } = writeExternalSetupChannelPlugin({ + pluginDir, + pluginId: "external-chat-plugin", + channelId: "external-chat", + }); + const plugins = listReadOnlyChannelPluginsForConfig( + { + agents: { + defaults: { + workspace: workspaceDir, + }, + }, + channels: { + "external-chat": { token: "configured" }, + }, + plugins: { + allow: ["external-chat-plugin"], + }, + } as never, + { + env: { ...process.env }, + includePersistedAuthState: false, + }, + ); + + const plugin = plugins.find((entry) => entry.id === "external-chat"); + expect(plugin?.meta.blurb).toBe("setup entry"); + expect(fs.existsSync(setupMarker)).toBe(true); + expect(fs.existsSync(fullMarker)).toBe(false); }); }); diff --git a/src/channels/plugins/read-only.ts b/src/channels/plugins/read-only.ts index 9a2550a7bfb..652dcd269c7 100644 --- a/src/channels/plugins/read-only.ts +++ b/src/channels/plugins/read-only.ts @@ -1,7 +1,11 @@ +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { resolveDiscoverableScopedChannelPluginIds } from "../../plugins/channel-plugin-ids.js"; import { loadOpenClawPlugins } from "../../plugins/loader.js"; -import { loadPluginManifestRegistry } from "../../plugins/manifest-registry.js"; +import { + loadPluginManifestRegistry, + type PluginManifestRecord, +} from "../../plugins/manifest-registry.js"; import { listPotentialConfiguredChannelIds } from "../config-presence.js"; import { getBundledChannelSetupPlugin } from "./bundled.js"; import { listChannelPlugins } from "./registry.js"; @@ -44,10 +48,53 @@ function addChannelPlugins( } } +function hasNonEmptyEnvValue(env: NodeJS.ProcessEnv, key: string): boolean { + const value = env[key]; + return typeof value === "string" && value.trim().length > 0; +} + +function resolveReadOnlyWorkspaceDir( + cfg: OpenClawConfig, + options: ReadOnlyChannelPluginOptions, +): string | undefined { + return options.workspaceDir ?? resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)); +} + +function listExternalChannelManifestRecords(params: { + cfg: OpenClawConfig; + workspaceDir?: string; + env: NodeJS.ProcessEnv; + cache?: boolean; +}): PluginManifestRecord[] { + return loadPluginManifestRegistry({ + config: params.cfg, + workspaceDir: params.workspaceDir, + env: params.env, + cache: params.cache, + }).plugins.filter((plugin) => plugin.origin !== "bundled" && plugin.channels.length > 0); +} + +function listExternalEnvConfiguredChannelIds(params: { + records: readonly PluginManifestRecord[]; + env: NodeJS.ProcessEnv; +}): string[] { + const channelIds = new Set(); + for (const record of params.records) { + for (const channelId of record.channels) { + const envVars = record.channelEnvVars?.[channelId] ?? []; + if (envVars.some((envVar) => hasNonEmptyEnvValue(params.env, envVar))) { + channelIds.add(channelId); + } + } + } + return [...channelIds].toSorted((left, right) => left.localeCompare(right)); +} + function resolveExternalReadOnlyChannelPluginIds(params: { cfg: OpenClawConfig; activationSourceConfig?: OpenClawConfig; channelIds: readonly string[]; + records: readonly PluginManifestRecord[]; workspaceDir?: string; env: NodeJS.ProcessEnv; cache?: boolean; @@ -69,16 +116,10 @@ function resolveExternalReadOnlyChannelPluginIds(params: { const requestedChannelIds = new Set(params.channelIds); const candidatePluginIdSet = new Set(candidatePluginIds); - return loadPluginManifestRegistry({ - config: params.cfg, - workspaceDir: params.workspaceDir, - env: params.env, - cache: params.cache, - }) - .plugins.filter( + return params.records + .filter( (plugin) => candidatePluginIdSet.has(plugin.id) && - plugin.origin !== "bundled" && plugin.channels.some((channelId) => requestedChannelIds.has(channelId)), ) .map((plugin) => plugin.id) @@ -99,9 +140,24 @@ export function listReadOnlyChannelPluginsForConfig( ): ChannelPlugin[] { const options = resolveReadOnlyChannelPluginOptions(envOrOptions); const env = options.env ?? process.env; - const configuredChannelIds = listPotentialConfiguredChannelIds(cfg, env, { - includePersistedAuthState: options.includePersistedAuthState, + const workspaceDir = resolveReadOnlyWorkspaceDir(cfg, options); + const externalManifestRecords = listExternalChannelManifestRecords({ + cfg, + workspaceDir, + env, + cache: options.cache, }); + const configuredChannelIds = [ + ...new Set([ + ...listPotentialConfiguredChannelIds(cfg, env, { + includePersistedAuthState: options.includePersistedAuthState, + }), + ...listExternalEnvConfiguredChannelIds({ + records: externalManifestRecords, + env, + }), + ]), + ]; const byId = new Map(); addChannelPlugins(byId, listChannelPlugins()); @@ -120,7 +176,8 @@ export function listReadOnlyChannelPluginsForConfig( cfg, activationSourceConfig: options.activationSourceConfig ?? cfg, channelIds: missingConfiguredChannelIds, - workspaceDir: options.workspaceDir, + records: externalManifestRecords, + workspaceDir, env, cache: options.cache, }); @@ -129,11 +186,12 @@ export function listReadOnlyChannelPluginsForConfig( config: cfg, activationSourceConfig: options.activationSourceConfig ?? cfg, env, - workspaceDir: options.workspaceDir, + workspaceDir, cache: false, activate: false, includeSetupOnlyChannelPlugins: true, forceSetupOnlyChannelPlugins: true, + requireSetupEntryForSetupOnlyChannelPlugins: true, onlyPluginIds: externalPluginIds, }); addChannelPlugins( diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 75bc74931f4..b474648dcb5 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -135,6 +135,7 @@ export type PluginLoadOptions = { onlyPluginIds?: string[]; includeSetupOnlyChannelPlugins?: boolean; forceSetupOnlyChannelPlugins?: boolean; + requireSetupEntryForSetupOnlyChannelPlugins?: boolean; /** * Prefer `setupEntry` for configured channel plugins that explicitly opt in * via package metadata because their setup entry covers the pre-listen startup surface. @@ -507,6 +508,7 @@ function buildCacheKey(params: { onlyPluginIds?: string[]; includeSetupOnlyChannelPlugins?: boolean; forceSetupOnlyChannelPlugins?: boolean; + requireSetupEntryForSetupOnlyChannelPlugins?: boolean; preferSetupRuntimeForChannelPlugins?: boolean; loadModules?: boolean; runtimeSubagentMode?: "default" | "explicit" | "gateway-bindable"; @@ -538,6 +540,10 @@ function buildCacheKey(params: { const setupOnlyKey = params.includeSetupOnlyChannelPlugins === true ? "setup-only" : "runtime"; const setupOnlyModeKey = params.forceSetupOnlyChannelPlugins === true ? "force-setup" : "normal-setup"; + const setupOnlyRequirementKey = + params.requireSetupEntryForSetupOnlyChannelPlugins === true + ? "require-setup-entry" + : "allow-full-fallback"; const startupChannelMode = params.preferSetupRuntimeForChannelPlugins === true ? "prefer-setup" : "full"; const moduleLoadMode = params.loadModules === false ? "manifest-only" : "load-modules"; @@ -548,7 +554,7 @@ function buildCacheKey(params: { installs, loadPaths, activationMetadataKey: params.activationMetadataKey ?? "", - })}::${scopeKey}::${setupOnlyKey}::${setupOnlyModeKey}::${startupChannelMode}::${moduleLoadMode}::${runtimeSubagentMode}::${params.pluginSdkResolution ?? "auto"}::${gatewayMethodsKey}`; + })}::${scopeKey}::${setupOnlyKey}::${setupOnlyModeKey}::${setupOnlyRequirementKey}::${startupChannelMode}::${moduleLoadMode}::${runtimeSubagentMode}::${params.pluginSdkResolution ?? "auto"}::${gatewayMethodsKey}`; } function matchesScopedPluginRequest(params: { @@ -624,6 +630,7 @@ function hasExplicitCompatibilityInputs(options: PluginLoadOptions): boolean { options.coreGatewayHandlers !== undefined || options.includeSetupOnlyChannelPlugins === true || options.forceSetupOnlyChannelPlugins === true || + options.requireSetupEntryForSetupOnlyChannelPlugins === true || options.preferSetupRuntimeForChannelPlugins === true || options.loadModules === false ); @@ -640,6 +647,8 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) { const onlyPluginIds = normalizePluginIdScope(options.onlyPluginIds); const includeSetupOnlyChannelPlugins = options.includeSetupOnlyChannelPlugins === true; const forceSetupOnlyChannelPlugins = options.forceSetupOnlyChannelPlugins === true; + const requireSetupEntryForSetupOnlyChannelPlugins = + options.requireSetupEntryForSetupOnlyChannelPlugins === true; const preferSetupRuntimeForChannelPlugins = options.preferSetupRuntimeForChannelPlugins === true; const runtimeSubagentMode = resolveRuntimeSubagentMode(options.runtimeOptions); const coreGatewayMethodNames = Object.keys(options.coreGatewayHandlers ?? {}).toSorted(); @@ -655,6 +664,7 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) { onlyPluginIds, includeSetupOnlyChannelPlugins, forceSetupOnlyChannelPlugins, + requireSetupEntryForSetupOnlyChannelPlugins, preferSetupRuntimeForChannelPlugins, loadModules: options.loadModules, runtimeSubagentMode, @@ -671,6 +681,7 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) { onlyPluginIds, includeSetupOnlyChannelPlugins, forceSetupOnlyChannelPlugins, + requireSetupEntryForSetupOnlyChannelPlugins, preferSetupRuntimeForChannelPlugins, shouldActivate: options.activate !== false, shouldLoadModules: options.loadModules !== false, @@ -988,6 +999,17 @@ function shouldLoadChannelPluginInSetupRuntime(params: { ); } +function channelPluginIdBelongsToManifest(params: { + channelId: string | undefined; + pluginId: string; + manifestChannels: readonly string[]; +}): boolean { + if (!params.channelId) { + return true; + } + return params.channelId === params.pluginId || params.manifestChannels.includes(params.channelId); +} + function createPluginRecord(params: { id: string; name?: string; @@ -1419,6 +1441,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi onlyPluginIds, includeSetupOnlyChannelPlugins, forceSetupOnlyChannelPlugins, + requireSetupEntryForSetupOnlyChannelPlugins, preferSetupRuntimeForChannelPlugins, shouldActivate, shouldLoadModules, @@ -1749,29 +1772,34 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi } } - const canLoadScopedSetupOnlyChannelPlugin = + const scopedSetupOnlyChannelPluginRequested = includeSetupOnlyChannelPlugins && !validateOnly && onlyPluginIdSet && manifestRecord.channels.length > 0 && (!enableState.enabled || forceSetupOnlyChannelPlugins); + const canLoadScopedSetupOnlyChannelPlugin = + scopedSetupOnlyChannelPluginRequested && + (!requireSetupEntryForSetupOnlyChannelPlugins || Boolean(manifestRecord.setupSource)); const registrationMode = canLoadScopedSetupOnlyChannelPlugin ? "setup-only" - : enableState.enabled - ? shouldLoadModules && - !validateOnly && - shouldLoadChannelPluginInSetupRuntime({ - manifestChannels: manifestRecord.channels, - setupSource: manifestRecord.setupSource, - startupDeferConfiguredChannelFullLoadUntilAfterListen: - manifestRecord.startupDeferConfiguredChannelFullLoadUntilAfterListen, - cfg, - env, - preferSetupRuntimeForChannelPlugins, - }) - ? "setup-runtime" - : "full" - : null; + : scopedSetupOnlyChannelPluginRequested && requireSetupEntryForSetupOnlyChannelPlugins + ? null + : enableState.enabled + ? shouldLoadModules && + !validateOnly && + shouldLoadChannelPluginInSetupRuntime({ + manifestChannels: manifestRecord.channels, + setupSource: manifestRecord.setupSource, + startupDeferConfiguredChannelFullLoadUntilAfterListen: + manifestRecord.startupDeferConfiguredChannelFullLoadUntilAfterListen, + cfg, + env, + preferSetupRuntimeForChannelPlugins, + }) + ? "setup-runtime" + : "full" + : null; if (!registrationMode) { record.status = "disabled"; @@ -1992,7 +2020,13 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi continue; } if (setupRegistration.plugin) { - if (setupRegistration.plugin.id && setupRegistration.plugin.id !== record.id) { + if ( + !channelPluginIdBelongsToManifest({ + channelId: setupRegistration.plugin.id, + pluginId: record.id, + manifestChannels: manifestRecord.channels, + }) + ) { pushPluginLoadError( `plugin id mismatch (config uses "${record.id}", setup export uses "${setupRegistration.plugin.id}")`, ); @@ -2117,7 +2151,13 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi if (!mergedSetupPlugin) { continue; } - if (mergedSetupPlugin.id && mergedSetupPlugin.id !== record.id) { + if ( + !channelPluginIdBelongsToManifest({ + channelId: mergedSetupPlugin.id, + pluginId: record.id, + manifestChannels: manifestRecord.channels, + }) + ) { pushPluginLoadError( `plugin id mismatch (config uses "${record.id}", setup export uses "${mergedSetupPlugin.id}")`, );