Skip to content

Commit 24db09a

Browse files
authored
fix(cli): keep channel status checks off plugin runtimes (#69479)
Merged via squash. Prepared head SHA: 63f6e41 Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras
1 parent 09c5669 commit 24db09a

85 files changed

Lines changed: 3176 additions & 366 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
1717

1818
- Auth/commands: require owner identity (an owner-candidate match or internal `operator.admin`) for owner-enforced commands instead of treating wildcard channel `allowFrom` or empty owner-candidate lists as sufficient, so non-owner senders can no longer reach owner-only commands through a permissive fallback when `enforceOwnerForCommands=true` and `commands.ownerAllowFrom` is unset. (#69774) Thanks @drobison00.
1919
- Control UI/CSP: tighten `img-src` to `'self' data:` only, and make Control UI avatar helpers drop remote `http(s)` and protocol-relative URLs so the UI falls back to the built-in logo/badge instead of issuing arbitrary remote image fetches. Same-origin avatar routes (relative paths) and `data:image/...` avatars still render. (#69773)
20+
- 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.
2021

2122
## 2026.4.20
2223

docs/plugins/manifest.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -510,7 +510,7 @@ Important examples:
510510
| Field | What it means |
511511
| ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
512512
| `openclaw.extensions` | Declares native plugin entrypoints. |
513-
| `openclaw.setupEntry` | Lightweight setup-only entrypoint used during onboarding and deferred channel startup. |
513+
| `openclaw.setupEntry` | Lightweight setup-only entrypoint used during onboarding, deferred channel startup, and read-only channel status/SecretRef discovery. |
514514
| `openclaw.channel` | Cheap channel catalog metadata like labels, docs paths, aliases, and selection copy. |
515515
| `openclaw.channel.configuredState` | Lightweight configured-state checker metadata that can answer "does env-only setup already exist?" without loading the full channel runtime. |
516516
| `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:
524524
registry loading. Invalid values are rejected; newer-but-valid values skip the
525525
plugin on older hosts.
526526

527+
Channel plugins should provide `openclaw.setupEntry` when status, channel list,
528+
or SecretRef scans need to identify configured accounts without loading the full
529+
runtime. The setup entry should expose channel metadata plus setup-safe config,
530+
status, and secrets adapters; keep network clients, gateway listeners, and
531+
transport runtimes in the main extension entrypoint.
532+
527533
`openclaw.install.allowInvalidConfigRecovery` is intentionally narrow. It does
528534
not make arbitrary broken configs installable. Today it only allows install
529535
flows to recover from specific stale bundled-plugin upgrade failures, such as a

docs/plugins/sdk-channel-plugins.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,14 @@ If your channel supports env-driven setup or auth and generic startup/config
139139
flows should know those env names before runtime loads, declare them in the
140140
plugin manifest with `channelEnvVars`. Keep channel runtime `envVars` or local
141141
constants for operator-facing copy only.
142+
143+
If your channel can appear in `status`, `channels list`, `channels status`, or
144+
SecretRef scans before the plugin runtime starts, add `openclaw.setupEntry` in
145+
`package.json`. That entrypoint should be safe to import in read-only command
146+
paths and should return the channel metadata, setup-safe config adapter, status
147+
adapter, and channel secret target metadata needed for those summaries. Do not
148+
start clients, listeners, or transport runtimes from the setup entry.
149+
142150
`createOptionalChannelSetupWizard`, `DEFAULT_ACCOUNT_ID`,
143151
`createTopLevelChannelDmPolicy`, `setSetupChannelEnabled`, and
144152
`splitSetupEntries`

extensions/discord/src/channel.ts

Lines changed: 2 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,12 @@ import {
44
createAccountScopedAllowlistNameResolver,
55
createNestedAllowlistOverrideResolver,
66
} from "openclaw/plugin-sdk/allowlist-config-edit";
7-
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
87
import type {
98
ChannelMessageActionAdapter,
109
ChannelMessageToolDiscovery,
1110
} from "openclaw/plugin-sdk/channel-contract";
1211
import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
1312
import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
14-
import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy";
1513
import {
1614
createChannelDirectoryAdapter,
1715
createRuntimeDirectoryLiveAdapter,
@@ -65,6 +63,7 @@ import {
6563
import { resolveDiscordOutboundSessionRoute } from "./outbound-session-route.js";
6664
import type { DiscordProbe } from "./probe.js";
6765
import { getDiscordRuntime } from "./runtime.js";
66+
import { discordSecurityAdapter } from "./security.js";
6867
import { normalizeExplicitDiscordSessionKey } from "./session-key-normalization.js";
6968
import { discordSetupAdapter } from "./setup-adapter.js";
7069
import { createDiscordPluginBase, discordConfigAdapter } from "./shared.js";
@@ -89,9 +88,6 @@ let discordCarbonModuleCache: DiscordCarbonModule | null = null;
8988
const loadDiscordDirectoryConfigModule = createLazyRuntimeModule(
9089
() => import("./directory-config.js"),
9190
);
92-
const loadDiscordSecurityAuditModule = createLazyRuntimeModule(
93-
() => import("./security-audit.runtime.js"),
94-
);
9591
const loadDiscordResolveChannelsModule = createLazyRuntimeModule(
9692
() => import("./resolve-channels.js"),
9793
);
@@ -218,18 +214,6 @@ function resolveDiscordStartupDelayMs(cfg: OpenClawConfig, accountId: string): n
218214
return startupIndex <= 0 ? 0 : startupIndex * DISCORD_ACCOUNT_STARTUP_STAGGER_MS;
219215
}
220216

221-
const resolveDiscordDmPolicy = createScopedDmSecurityResolver<ResolvedDiscordAccount>({
222-
channelKey: "discord",
223-
resolvePolicy: (account) => account.config.dm?.policy,
224-
resolveAllowFrom: (account) => account.config.dm?.allowFrom,
225-
allowFromPathSuffix: "dm.",
226-
normalizeEntry: (raw) =>
227-
raw
228-
.trim()
229-
.replace(/^(discord|user):/i, "")
230-
.replace(/^<@!?(\d+)>$/, "$1"),
231-
});
232-
233217
function formatDiscordIntents(intents?: {
234218
messageContent?: string;
235219
guildMembers?: string;
@@ -286,26 +270,6 @@ const resolveDiscordAllowlistNames = createAccountScopedAllowlistNameResolver({
286270
(await loadDiscordResolveUsersModule()).resolveDiscordUserAllowlist({ token, entries }),
287271
});
288272

289-
const collectDiscordSecurityWarnings =
290-
createOpenProviderConfiguredRouteWarningCollector<ResolvedDiscordAccount>({
291-
providerConfigPresent: (cfg) => cfg.channels?.discord !== undefined,
292-
resolveGroupPolicy: (account) => account.config.groupPolicy,
293-
resolveRouteAllowlistConfigured: (account) =>
294-
Object.keys(account.config.guilds ?? {}).length > 0,
295-
configureRouteAllowlist: {
296-
surface: "Discord guilds",
297-
openScope: "any channel not explicitly denied",
298-
groupPolicyPath: "channels.discord.groupPolicy",
299-
routeAllowlistPath: "channels.discord.guilds.<id>.channels",
300-
},
301-
missingRouteAllowlist: {
302-
surface: "Discord guilds",
303-
openBehavior: "with no guild/channel allowlist; any channel can trigger (mention-gated)",
304-
remediation:
305-
'Set channels.discord.groupPolicy="allowlist" and configure channels.discord.guilds.<id>.channels',
306-
},
307-
});
308-
309273
function normalizeDiscordAcpConversationId(conversationId: string) {
310274
const normalized = conversationId.trim();
311275
return normalized ? { conversationId: normalized } : null;
@@ -829,12 +793,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe>
829793
},
830794
},
831795
},
832-
security: {
833-
resolveDmPolicy: resolveDiscordDmPolicy,
834-
collectWarnings: collectDiscordSecurityWarnings,
835-
collectAuditFindings: async (params) =>
836-
(await loadDiscordSecurityAuditModule()).collectDiscordSecurityAuditFindings(params),
837-
},
796+
security: discordSecurityAdapter,
838797
threading: {
839798
scopedAccountReplyToMode: {
840799
resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }),

extensions/discord/src/security.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
2+
import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy";
3+
import type { ResolvedDiscordAccount } from "./accounts.js";
4+
import type { ChannelPlugin } from "./channel-api.js";
5+
6+
const resolveDiscordDmPolicy = createScopedDmSecurityResolver<ResolvedDiscordAccount>({
7+
channelKey: "discord",
8+
resolvePolicy: (account) => account.config.dm?.policy,
9+
resolveAllowFrom: (account) => account.config.dm?.allowFrom,
10+
allowFromPathSuffix: "dm.",
11+
normalizeEntry: (raw) =>
12+
raw
13+
.trim()
14+
.replace(/^(discord|user):/i, "")
15+
.replace(/^<@!?(\d+)>$/, "$1"),
16+
});
17+
18+
const collectDiscordSecurityWarnings =
19+
createOpenProviderConfiguredRouteWarningCollector<ResolvedDiscordAccount>({
20+
providerConfigPresent: (cfg) => cfg.channels?.discord !== undefined,
21+
resolveGroupPolicy: (account) => account.config.groupPolicy,
22+
resolveRouteAllowlistConfigured: (account) =>
23+
Object.keys(account.config.guilds ?? {}).length > 0,
24+
configureRouteAllowlist: {
25+
surface: "Discord guilds",
26+
openScope: "any channel not explicitly denied",
27+
groupPolicyPath: "channels.discord.groupPolicy",
28+
routeAllowlistPath: "channels.discord.guilds.<id>.channels",
29+
},
30+
missingRouteAllowlist: {
31+
surface: "Discord guilds",
32+
openBehavior: "with no guild/channel allowlist; any channel can trigger (mention-gated)",
33+
remediation:
34+
'Set channels.discord.groupPolicy="allowlist" and configure channels.discord.guilds.<id>.channels',
35+
},
36+
});
37+
38+
let discordSecurityAuditModulePromise:
39+
| Promise<typeof import("./security-audit.runtime.js")>
40+
| undefined;
41+
42+
async function loadDiscordSecurityAuditModule() {
43+
discordSecurityAuditModulePromise ??= import("./security-audit.runtime.js");
44+
return await discordSecurityAuditModulePromise;
45+
}
46+
47+
export const discordSecurityAdapter = {
48+
resolveDmPolicy: resolveDiscordDmPolicy,
49+
collectWarnings: collectDiscordSecurityWarnings,
50+
collectAuditFindings: async (params) =>
51+
(await loadDiscordSecurityAuditModule()).collectDiscordSecurityAuditFindings(params),
52+
} satisfies NonNullable<ChannelPlugin<ResolvedDiscordAccount>["security"]>;

extensions/discord/src/shared.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,12 @@ describe("createDiscordPluginBase", () => {
1818
}),
1919
).toBe("status");
2020
});
21+
22+
it("exposes security checks on the setup surface", () => {
23+
const plugin = createDiscordPluginBase({ setup: {} as never });
24+
25+
expect(plugin.security?.resolveDmPolicy).toBeTypeOf("function");
26+
expect(plugin.security?.collectWarnings).toBeTypeOf("function");
27+
expect(plugin.security?.collectAuditFindings).toBeTypeOf("function");
28+
});
2129
});

extensions/discord/src/shared.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
collectUnsupportedSecretRefConfigCandidates,
2323
unsupportedSecretRefSurfacePatterns,
2424
} from "./security-contract.js";
25+
import { discordSecurityAdapter } from "./security.js";
2526
import { deriveLegacySessionChatType } from "./session-contract.js";
2627

2728
export const DISCORD_CHANNEL = "discord" as const;
@@ -82,6 +83,7 @@ export function createDiscordPluginBase(params: {
8283
| "config"
8384
| "setup"
8485
| "messaging"
86+
| "security"
8587
| "secrets"
8688
> {
8789
return {
@@ -125,6 +127,7 @@ export function createDiscordPluginBase(params: {
125127
messaging: {
126128
deriveLegacySessionChatType,
127129
},
130+
security: discordSecurityAdapter,
128131
secrets: {
129132
secretTargetRegistryEntries,
130133
unsupportedSecretRefSurfacePatterns,
@@ -146,6 +149,7 @@ export function createDiscordPluginBase(params: {
146149
| "config"
147150
| "setup"
148151
| "messaging"
152+
| "security"
149153
| "secrets"
150154
>;
151155
}

extensions/minimax/openclaw.plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"autoEnableWhenConfiguredProviders": ["minimax", "minimax-portal"],
77
"nonSecretAuthMarkers": ["minimax-oauth"],
88
"providerAuthEnvVars": {
9-
"minimax": ["MINIMAX_API_KEY"],
9+
"minimax": ["MINIMAX_CODE_PLAN_KEY", "MINIMAX_CODING_API_KEY", "MINIMAX_API_KEY"],
1010
"minimax-portal": ["MINIMAX_OAUTH_TOKEN", "MINIMAX_API_KEY"]
1111
},
1212
"providerAuthChoices": [

extensions/slack/src/channel.ts

Lines changed: 3 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,9 @@ import {
33
createAccountScopedAllowlistNameResolver,
44
createFlatAllowlistOverrideResolver,
55
} from "openclaw/plugin-sdk/allowlist-config-edit";
6-
import {
7-
adaptScopedAccountAccessor,
8-
createScopedDmSecurityResolver,
9-
} from "openclaw/plugin-sdk/channel-config-helpers";
6+
import { adaptScopedAccountAccessor } from "openclaw/plugin-sdk/channel-config-helpers";
107
import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
118
import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
12-
import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy";
139
import {
1410
createChannelDirectoryAdapter,
1511
createRuntimeDirectoryLiveAdapter,
@@ -62,7 +58,7 @@ import type { SlackProbe } from "./probe.js";
6258
import { resolveSlackReplyBlocks } from "./reply-blocks.js";
6359
import { getOptionalSlackRuntime, getSlackRuntime } from "./runtime.js";
6460
import { fetchSlackScopes } from "./scopes.js";
65-
import { collectSlackSecurityAuditFindings } from "./security-audit.js";
61+
import { slackSecurityAdapter } from "./security.js";
6662
import { slackSetupAdapter } from "./setup-core.js";
6763
import { slackSetupWizard } from "./setup-surface.js";
6864
import {
@@ -74,18 +70,6 @@ import {
7470
import { parseSlackTarget } from "./target-parsing.js";
7571
import { buildSlackThreadingToolContext } from "./threading-tool-context.js";
7672

77-
const resolveSlackDmPolicy = createScopedDmSecurityResolver<ResolvedSlackAccount>({
78-
channelKey: "slack",
79-
resolvePolicy: (account) => account.dm?.policy,
80-
resolveAllowFrom: (account) => account.dm?.allowFrom,
81-
allowFromPathSuffix: "dm.",
82-
normalizeEntry: (raw) =>
83-
raw
84-
.trim()
85-
.replace(/^(slack|user):/i, "")
86-
.trim(),
87-
});
88-
8973
async function resolveSlackHandleAction() {
9074
return (
9175
getOptionalSlackRuntime()?.channel?.slack?.handleSlackAction ??
@@ -289,26 +273,6 @@ const resolveSlackAllowlistNames = createAccountScopedAllowlistNameResolver({
289273
(await loadSlackResolveUsersModule()).resolveSlackUserAllowlist({ token, entries }),
290274
});
291275

292-
const collectSlackSecurityWarnings =
293-
createOpenProviderConfiguredRouteWarningCollector<ResolvedSlackAccount>({
294-
providerConfigPresent: (cfg) => cfg.channels?.slack !== undefined,
295-
resolveGroupPolicy: (account) => account.config.groupPolicy,
296-
resolveRouteAllowlistConfigured: (account) =>
297-
Boolean(account.config.channels) && Object.keys(account.config.channels ?? {}).length > 0,
298-
configureRouteAllowlist: {
299-
surface: "Slack channels",
300-
openScope: "any channel not explicitly denied",
301-
groupPolicyPath: "channels.slack.groupPolicy",
302-
routeAllowlistPath: "channels.slack.channels",
303-
},
304-
missingRouteAllowlist: {
305-
surface: "Slack channels",
306-
openBehavior: "with no channel allowlist; any channel can trigger (mention-gated)",
307-
remediation:
308-
'Set channels.slack.groupPolicy="allowlist" and configure channels.slack.channels',
309-
},
310-
});
311-
312276
export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = createChatChannelPlugin<
313277
ResolvedSlackAccount,
314278
SlackProbe
@@ -554,11 +518,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
554518
},
555519
},
556520
},
557-
security: {
558-
resolveDmPolicy: resolveSlackDmPolicy,
559-
collectWarnings: collectSlackSecurityWarnings,
560-
collectAuditFindings: collectSlackSecurityAuditFindings,
561-
},
521+
security: slackSecurityAdapter,
562522
threading: {
563523
scopedAccountReplyToMode: {
564524
resolveAccount: adaptScopedAccountAccessor(resolveSlackAccount),

extensions/slack/src/security.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
2+
import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy";
3+
import type { ResolvedSlackAccount } from "./accounts.js";
4+
import type { ChannelPlugin } from "./channel-api.js";
5+
import { collectSlackSecurityAuditFindings } from "./security-audit.js";
6+
7+
const resolveSlackDmPolicy = createScopedDmSecurityResolver<ResolvedSlackAccount>({
8+
channelKey: "slack",
9+
resolvePolicy: (account) => account.dm?.policy,
10+
resolveAllowFrom: (account) => account.dm?.allowFrom,
11+
allowFromPathSuffix: "dm.",
12+
normalizeEntry: (raw) =>
13+
raw
14+
.trim()
15+
.replace(/^(slack|user):/i, "")
16+
.trim(),
17+
});
18+
19+
const collectSlackSecurityWarnings =
20+
createOpenProviderConfiguredRouteWarningCollector<ResolvedSlackAccount>({
21+
providerConfigPresent: (cfg) => cfg.channels?.slack !== undefined,
22+
resolveGroupPolicy: (account) => account.config.groupPolicy,
23+
resolveRouteAllowlistConfigured: (account) =>
24+
Boolean(account.config.channels) && Object.keys(account.config.channels ?? {}).length > 0,
25+
configureRouteAllowlist: {
26+
surface: "Slack channels",
27+
openScope: "any channel not explicitly denied",
28+
groupPolicyPath: "channels.slack.groupPolicy",
29+
routeAllowlistPath: "channels.slack.channels",
30+
},
31+
missingRouteAllowlist: {
32+
surface: "Slack channels",
33+
openBehavior: "with no channel allowlist; any channel can trigger (mention-gated)",
34+
remediation:
35+
'Set channels.slack.groupPolicy="allowlist" and configure channels.slack.channels',
36+
},
37+
});
38+
39+
export const slackSecurityAdapter = {
40+
resolveDmPolicy: resolveSlackDmPolicy,
41+
collectWarnings: collectSlackSecurityWarnings,
42+
collectAuditFindings: collectSlackSecurityAuditFindings,
43+
} satisfies NonNullable<ChannelPlugin<ResolvedSlackAccount>["security"]>;

0 commit comments

Comments
 (0)