fix: tighten read-only external channel discovery

This commit is contained in:
Gustavo Madeira Santana
2026-04-20 19:19:56 -04:00
parent fd2d20a8ee
commit 098a6d4411
6 changed files with 236 additions and 53 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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`

View File

@@ -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);
});
});

View File

@@ -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<string>();
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<string, ChannelPlugin>();
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(

View File

@@ -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}")`,
);