mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 16:01:01 +00:00
fix: keep channel status off plugin runtimes
This commit is contained in:
@@ -78,6 +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.
|
||||
- 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.
|
||||
|
||||
@@ -4,14 +4,12 @@ import {
|
||||
createAccountScopedAllowlistNameResolver,
|
||||
createNestedAllowlistOverrideResolver,
|
||||
} from "openclaw/plugin-sdk/allowlist-config-edit";
|
||||
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
import type {
|
||||
ChannelMessageActionAdapter,
|
||||
ChannelMessageToolDiscovery,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
|
||||
import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
|
||||
import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy";
|
||||
import {
|
||||
createChannelDirectoryAdapter,
|
||||
createRuntimeDirectoryLiveAdapter,
|
||||
@@ -65,6 +63,7 @@ import {
|
||||
import { resolveDiscordOutboundSessionRoute } from "./outbound-session-route.js";
|
||||
import type { DiscordProbe } from "./probe.js";
|
||||
import { getDiscordRuntime } from "./runtime.js";
|
||||
import { discordSecurityAdapter } from "./security.js";
|
||||
import { normalizeExplicitDiscordSessionKey } from "./session-key-normalization.js";
|
||||
import { discordSetupAdapter } from "./setup-adapter.js";
|
||||
import { createDiscordPluginBase, discordConfigAdapter } from "./shared.js";
|
||||
@@ -89,9 +88,6 @@ let discordCarbonModuleCache: DiscordCarbonModule | null = null;
|
||||
const loadDiscordDirectoryConfigModule = createLazyRuntimeModule(
|
||||
() => import("./directory-config.js"),
|
||||
);
|
||||
const loadDiscordSecurityAuditModule = createLazyRuntimeModule(
|
||||
() => import("./security-audit.runtime.js"),
|
||||
);
|
||||
const loadDiscordResolveChannelsModule = createLazyRuntimeModule(
|
||||
() => import("./resolve-channels.js"),
|
||||
);
|
||||
@@ -218,18 +214,6 @@ function resolveDiscordStartupDelayMs(cfg: OpenClawConfig, accountId: string): n
|
||||
return startupIndex <= 0 ? 0 : startupIndex * DISCORD_ACCOUNT_STARTUP_STAGGER_MS;
|
||||
}
|
||||
|
||||
const resolveDiscordDmPolicy = createScopedDmSecurityResolver<ResolvedDiscordAccount>({
|
||||
channelKey: "discord",
|
||||
resolvePolicy: (account) => account.config.dm?.policy,
|
||||
resolveAllowFrom: (account) => account.config.dm?.allowFrom,
|
||||
allowFromPathSuffix: "dm.",
|
||||
normalizeEntry: (raw) =>
|
||||
raw
|
||||
.trim()
|
||||
.replace(/^(discord|user):/i, "")
|
||||
.replace(/^<@!?(\d+)>$/, "$1"),
|
||||
});
|
||||
|
||||
function formatDiscordIntents(intents?: {
|
||||
messageContent?: string;
|
||||
guildMembers?: string;
|
||||
@@ -286,26 +270,6 @@ const resolveDiscordAllowlistNames = createAccountScopedAllowlistNameResolver({
|
||||
(await loadDiscordResolveUsersModule()).resolveDiscordUserAllowlist({ token, entries }),
|
||||
});
|
||||
|
||||
const collectDiscordSecurityWarnings =
|
||||
createOpenProviderConfiguredRouteWarningCollector<ResolvedDiscordAccount>({
|
||||
providerConfigPresent: (cfg) => cfg.channels?.discord !== undefined,
|
||||
resolveGroupPolicy: (account) => account.config.groupPolicy,
|
||||
resolveRouteAllowlistConfigured: (account) =>
|
||||
Object.keys(account.config.guilds ?? {}).length > 0,
|
||||
configureRouteAllowlist: {
|
||||
surface: "Discord guilds",
|
||||
openScope: "any channel not explicitly denied",
|
||||
groupPolicyPath: "channels.discord.groupPolicy",
|
||||
routeAllowlistPath: "channels.discord.guilds.<id>.channels",
|
||||
},
|
||||
missingRouteAllowlist: {
|
||||
surface: "Discord guilds",
|
||||
openBehavior: "with no guild/channel allowlist; any channel can trigger (mention-gated)",
|
||||
remediation:
|
||||
'Set channels.discord.groupPolicy="allowlist" and configure channels.discord.guilds.<id>.channels',
|
||||
},
|
||||
});
|
||||
|
||||
function normalizeDiscordAcpConversationId(conversationId: string) {
|
||||
const normalized = conversationId.trim();
|
||||
return normalized ? { conversationId: normalized } : null;
|
||||
@@ -829,12 +793,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe>
|
||||
},
|
||||
},
|
||||
},
|
||||
security: {
|
||||
resolveDmPolicy: resolveDiscordDmPolicy,
|
||||
collectWarnings: collectDiscordSecurityWarnings,
|
||||
collectAuditFindings: async (params) =>
|
||||
(await loadDiscordSecurityAuditModule()).collectDiscordSecurityAuditFindings(params),
|
||||
},
|
||||
security: discordSecurityAdapter,
|
||||
threading: {
|
||||
scopedAccountReplyToMode: {
|
||||
resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }),
|
||||
|
||||
@@ -18,4 +18,12 @@ describe("createDiscordPluginBase", () => {
|
||||
}),
|
||||
).toBe("status");
|
||||
});
|
||||
|
||||
it("exposes security checks on the setup surface", () => {
|
||||
const plugin = createDiscordPluginBase({ setup: {} as never });
|
||||
|
||||
expect(plugin.security?.resolveDmPolicy).toBeTypeOf("function");
|
||||
expect(plugin.security?.collectWarnings).toBeTypeOf("function");
|
||||
expect(plugin.security?.collectAuditFindings).toBeTypeOf("function");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
collectUnsupportedSecretRefConfigCandidates,
|
||||
unsupportedSecretRefSurfacePatterns,
|
||||
} from "./security-contract.js";
|
||||
import { discordSecurityAdapter } from "./security.js";
|
||||
import { deriveLegacySessionChatType } from "./session-contract.js";
|
||||
|
||||
export const DISCORD_CHANNEL = "discord" as const;
|
||||
@@ -82,6 +83,7 @@ export function createDiscordPluginBase(params: {
|
||||
| "config"
|
||||
| "setup"
|
||||
| "messaging"
|
||||
| "security"
|
||||
| "secrets"
|
||||
> {
|
||||
return {
|
||||
@@ -125,6 +127,7 @@ export function createDiscordPluginBase(params: {
|
||||
messaging: {
|
||||
deriveLegacySessionChatType,
|
||||
},
|
||||
security: discordSecurityAdapter,
|
||||
secrets: {
|
||||
secretTargetRegistryEntries,
|
||||
unsupportedSecretRefSurfacePatterns,
|
||||
@@ -146,6 +149,7 @@ export function createDiscordPluginBase(params: {
|
||||
| "config"
|
||||
| "setup"
|
||||
| "messaging"
|
||||
| "security"
|
||||
| "secrets"
|
||||
>;
|
||||
}
|
||||
|
||||
@@ -3,13 +3,9 @@ import {
|
||||
createAccountScopedAllowlistNameResolver,
|
||||
createFlatAllowlistOverrideResolver,
|
||||
} from "openclaw/plugin-sdk/allowlist-config-edit";
|
||||
import {
|
||||
adaptScopedAccountAccessor,
|
||||
createScopedDmSecurityResolver,
|
||||
} from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
import { adaptScopedAccountAccessor } from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
|
||||
import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
|
||||
import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy";
|
||||
import {
|
||||
createChannelDirectoryAdapter,
|
||||
createRuntimeDirectoryLiveAdapter,
|
||||
@@ -62,7 +58,7 @@ import type { SlackProbe } from "./probe.js";
|
||||
import { resolveSlackReplyBlocks } from "./reply-blocks.js";
|
||||
import { getOptionalSlackRuntime, getSlackRuntime } from "./runtime.js";
|
||||
import { fetchSlackScopes } from "./scopes.js";
|
||||
import { collectSlackSecurityAuditFindings } from "./security-audit.js";
|
||||
import { slackSecurityAdapter } from "./security.js";
|
||||
import { slackSetupAdapter } from "./setup-core.js";
|
||||
import { slackSetupWizard } from "./setup-surface.js";
|
||||
import {
|
||||
@@ -74,18 +70,6 @@ import {
|
||||
import { parseSlackTarget } from "./target-parsing.js";
|
||||
import { buildSlackThreadingToolContext } from "./threading-tool-context.js";
|
||||
|
||||
const resolveSlackDmPolicy = createScopedDmSecurityResolver<ResolvedSlackAccount>({
|
||||
channelKey: "slack",
|
||||
resolvePolicy: (account) => account.dm?.policy,
|
||||
resolveAllowFrom: (account) => account.dm?.allowFrom,
|
||||
allowFromPathSuffix: "dm.",
|
||||
normalizeEntry: (raw) =>
|
||||
raw
|
||||
.trim()
|
||||
.replace(/^(slack|user):/i, "")
|
||||
.trim(),
|
||||
});
|
||||
|
||||
async function resolveSlackHandleAction() {
|
||||
return (
|
||||
getOptionalSlackRuntime()?.channel?.slack?.handleSlackAction ??
|
||||
@@ -289,26 +273,6 @@ const resolveSlackAllowlistNames = createAccountScopedAllowlistNameResolver({
|
||||
(await loadSlackResolveUsersModule()).resolveSlackUserAllowlist({ token, entries }),
|
||||
});
|
||||
|
||||
const collectSlackSecurityWarnings =
|
||||
createOpenProviderConfiguredRouteWarningCollector<ResolvedSlackAccount>({
|
||||
providerConfigPresent: (cfg) => cfg.channels?.slack !== undefined,
|
||||
resolveGroupPolicy: (account) => account.config.groupPolicy,
|
||||
resolveRouteAllowlistConfigured: (account) =>
|
||||
Boolean(account.config.channels) && Object.keys(account.config.channels ?? {}).length > 0,
|
||||
configureRouteAllowlist: {
|
||||
surface: "Slack channels",
|
||||
openScope: "any channel not explicitly denied",
|
||||
groupPolicyPath: "channels.slack.groupPolicy",
|
||||
routeAllowlistPath: "channels.slack.channels",
|
||||
},
|
||||
missingRouteAllowlist: {
|
||||
surface: "Slack channels",
|
||||
openBehavior: "with no channel allowlist; any channel can trigger (mention-gated)",
|
||||
remediation:
|
||||
'Set channels.slack.groupPolicy="allowlist" and configure channels.slack.channels',
|
||||
},
|
||||
});
|
||||
|
||||
export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = createChatChannelPlugin<
|
||||
ResolvedSlackAccount,
|
||||
SlackProbe
|
||||
@@ -554,11 +518,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
|
||||
},
|
||||
},
|
||||
},
|
||||
security: {
|
||||
resolveDmPolicy: resolveSlackDmPolicy,
|
||||
collectWarnings: collectSlackSecurityWarnings,
|
||||
collectAuditFindings: collectSlackSecurityAuditFindings,
|
||||
},
|
||||
security: slackSecurityAdapter,
|
||||
threading: {
|
||||
scopedAccountReplyToMode: {
|
||||
resolveAccount: adaptScopedAccountAccessor(resolveSlackAccount),
|
||||
|
||||
@@ -21,6 +21,17 @@ describe("createSlackPluginBase", () => {
|
||||
}),
|
||||
).toBe("tts");
|
||||
});
|
||||
|
||||
it("exposes security checks on the setup surface", () => {
|
||||
const plugin = createSlackPluginBase({
|
||||
setup: {} as never,
|
||||
setupWizard: {} as never,
|
||||
});
|
||||
|
||||
expect(plugin.security?.resolveDmPolicy).toBeTypeOf("function");
|
||||
expect(plugin.security?.collectWarnings).toBeTypeOf("function");
|
||||
expect(plugin.security?.collectAuditFindings).toBeTypeOf("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setSlackChannelAllowlist", () => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { SlackChannelConfigSchema } from "./config-schema.js";
|
||||
import { slackDoctor } from "./doctor.js";
|
||||
import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js";
|
||||
import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js";
|
||||
import { slackSecurityAdapter } from "./security.js";
|
||||
|
||||
export const SLACK_CHANNEL = "slack" as const;
|
||||
|
||||
@@ -175,6 +176,7 @@ export function createSlackPluginBase(params: {
|
||||
| "configSchema"
|
||||
| "config"
|
||||
| "setup"
|
||||
| "security"
|
||||
| "secrets"
|
||||
> {
|
||||
return {
|
||||
@@ -224,6 +226,7 @@ export function createSlackPluginBase(params: {
|
||||
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
|
||||
},
|
||||
reload: { configPrefixes: ["channels.slack"] },
|
||||
security: slackSecurityAdapter,
|
||||
configSchema: SlackChannelConfigSchema,
|
||||
config: {
|
||||
...slackConfigAdapter,
|
||||
@@ -261,6 +264,7 @@ export function createSlackPluginBase(params: {
|
||||
| "configSchema"
|
||||
| "config"
|
||||
| "setup"
|
||||
| "security"
|
||||
| "secrets"
|
||||
>;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { ChannelMessageActionAdapter } from "openclaw/plugin-sdk/channel-co
|
||||
import { clearAccountEntryFields, createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
|
||||
import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle";
|
||||
import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
|
||||
import { createAllowlistProviderRouteAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
|
||||
import { attachChannelToResult } from "openclaw/plugin-sdk/channel-send-result";
|
||||
import {
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
@@ -63,7 +62,7 @@ import type { TelegramProbe } from "./probe.js";
|
||||
import * as probeModule from "./probe.js";
|
||||
import { resolveTelegramReactionLevel } from "./reaction-level.js";
|
||||
import { getTelegramRuntime } from "./runtime.js";
|
||||
import { collectTelegramSecurityAuditFindings } from "./security-audit.js";
|
||||
import { telegramSecurityAdapter } from "./security.js";
|
||||
import { resolveTelegramSessionConversation } from "./session-conversation.js";
|
||||
import { telegramSetupAdapter } from "./setup-core.js";
|
||||
import { telegramSetupWizard } from "./setup-surface.js";
|
||||
@@ -581,27 +580,6 @@ const resolveTelegramAllowlistGroupOverrides = createNestedAllowlistOverrideReso
|
||||
resolveInnerEntries: (topicCfg) => topicCfg?.allowFrom,
|
||||
});
|
||||
|
||||
const collectTelegramSecurityWarnings =
|
||||
createAllowlistProviderRouteAllowlistWarningCollector<ResolvedTelegramAccount>({
|
||||
providerConfigPresent: (cfg) => cfg.channels?.telegram !== undefined,
|
||||
resolveGroupPolicy: (account) => account.config.groupPolicy,
|
||||
resolveRouteAllowlistConfigured: (account) =>
|
||||
Boolean(account.config.groups) && Object.keys(account.config.groups ?? {}).length > 0,
|
||||
restrictSenders: {
|
||||
surface: "Telegram groups",
|
||||
openScope: "any member in allowed groups",
|
||||
groupPolicyPath: "channels.telegram.groupPolicy",
|
||||
groupAllowFromPath: "channels.telegram.groupAllowFrom",
|
||||
},
|
||||
noRouteAllowlist: {
|
||||
surface: "Telegram groups",
|
||||
routeAllowlistPath: "channels.telegram.groups",
|
||||
routeScope: "group",
|
||||
groupPolicyPath: "channels.telegram.groupPolicy",
|
||||
groupAllowFromPath: "channels.telegram.groupAllowFrom",
|
||||
},
|
||||
});
|
||||
|
||||
export const telegramPlugin = createChatChannelPlugin({
|
||||
base: {
|
||||
...createTelegramPluginBase({
|
||||
@@ -993,17 +971,7 @@ export const telegramPlugin = createChatChannelPlugin({
|
||||
},
|
||||
},
|
||||
},
|
||||
security: {
|
||||
dm: {
|
||||
channelKey: "telegram",
|
||||
resolvePolicy: (account) => account.config.dmPolicy,
|
||||
resolveAllowFrom: (account) => account.config.allowFrom,
|
||||
policyPathSuffix: "dmPolicy",
|
||||
normalizeEntry: (raw) => raw.replace(/^(telegram|tg):/i, ""),
|
||||
},
|
||||
collectWarnings: collectTelegramSecurityWarnings,
|
||||
collectAuditFindings: collectTelegramSecurityAuditFindings,
|
||||
},
|
||||
security: telegramSecurityAdapter,
|
||||
threading: {
|
||||
topLevelReplyToMode: "telegram",
|
||||
buildToolContext: (params) => buildTelegramThreadingToolContext(params),
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { TelegramChannelConfigSchema } from "./config-schema.js";
|
||||
import { telegramDoctor } from "./doctor.js";
|
||||
import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js";
|
||||
import { telegramSecurityAdapter } from "./security.js";
|
||||
import { namedAccountPromotionKeys, singleAccountKeysToMove } from "./setup-contract.js";
|
||||
|
||||
export const TELEGRAM_CHANNEL = "telegram" as const;
|
||||
@@ -120,6 +121,7 @@ export function createTelegramPluginBase(params: {
|
||||
| "capabilities"
|
||||
| "commands"
|
||||
| "doctor"
|
||||
| "security"
|
||||
| "reload"
|
||||
| "configSchema"
|
||||
| "config"
|
||||
@@ -151,6 +153,7 @@ export function createTelegramPluginBase(params: {
|
||||
buildModelBrowseChannelData: buildTelegramModelBrowseChannelData,
|
||||
},
|
||||
doctor: telegramDoctor,
|
||||
security: telegramSecurityAdapter,
|
||||
reload: { configPrefixes: ["channels.telegram"] },
|
||||
configSchema: TelegramChannelConfigSchema,
|
||||
config: {
|
||||
@@ -240,6 +243,7 @@ export function createTelegramPluginBase(params: {
|
||||
| "capabilities"
|
||||
| "commands"
|
||||
| "doctor"
|
||||
| "security"
|
||||
| "reload"
|
||||
| "configSchema"
|
||||
| "config"
|
||||
|
||||
@@ -98,7 +98,7 @@ export function registerChannelsCli(program: Command) {
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
await runChannelsCommand(async () => {
|
||||
const { channelsListCommand } = await loadChannelsCommands();
|
||||
const { channelsListCommand } = await import("../commands/channels/list.js");
|
||||
await channelsListCommand(opts, defaultRuntime);
|
||||
});
|
||||
});
|
||||
@@ -111,7 +111,7 @@ export function registerChannelsCli(program: Command) {
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
await runChannelsCommand(async () => {
|
||||
const { channelsStatusCommand } = await loadChannelsCommands();
|
||||
const { channelsStatusCommand } = await import("../commands/channels/status.js");
|
||||
await channelsStatusCommand(opts, defaultRuntime);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,9 @@ export type CliRoutedCommandId =
|
||||
| "config-get"
|
||||
| "config-unset"
|
||||
| "models-list"
|
||||
| "models-status";
|
||||
| "models-status"
|
||||
| "channels-list"
|
||||
| "channels-status";
|
||||
|
||||
export type CliCommandPathPolicy = {
|
||||
bypassConfigGuard: boolean;
|
||||
@@ -39,16 +41,16 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
|
||||
{
|
||||
commandPath: ["status"],
|
||||
policy: {
|
||||
loadPlugins: "text-only",
|
||||
loadPlugins: "never",
|
||||
routeConfigGuard: "when-suppressed",
|
||||
ensureCliPath: false,
|
||||
},
|
||||
route: { id: "status", preloadPlugins: true },
|
||||
route: { id: "status" },
|
||||
},
|
||||
{
|
||||
commandPath: ["health"],
|
||||
policy: { loadPlugins: "text-only", ensureCliPath: false },
|
||||
route: { id: "health", preloadPlugins: true },
|
||||
policy: { loadPlugins: "never", ensureCliPath: false },
|
||||
route: { id: "health" },
|
||||
},
|
||||
{
|
||||
commandPath: ["gateway", "status"],
|
||||
@@ -126,4 +128,16 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
|
||||
exact: true,
|
||||
policy: { loadPlugins: "never" },
|
||||
},
|
||||
{
|
||||
commandPath: ["channels", "status"],
|
||||
exact: true,
|
||||
policy: { loadPlugins: "never" },
|
||||
route: { id: "channels-status" },
|
||||
},
|
||||
{
|
||||
commandPath: ["channels", "list"],
|
||||
exact: true,
|
||||
policy: { loadPlugins: "never" },
|
||||
route: { id: "channels-list" },
|
||||
},
|
||||
];
|
||||
|
||||
@@ -6,7 +6,7 @@ describe("command-path-policy", () => {
|
||||
expect(resolveCliCommandPathPolicy(["status"])).toEqual({
|
||||
bypassConfigGuard: false,
|
||||
routeConfigGuard: "when-suppressed",
|
||||
loadPlugins: "text-only",
|
||||
loadPlugins: "never",
|
||||
hideBanner: false,
|
||||
ensureCliPath: false,
|
||||
});
|
||||
@@ -27,6 +27,20 @@ describe("command-path-policy", () => {
|
||||
hideBanner: false,
|
||||
ensureCliPath: true,
|
||||
});
|
||||
expect(resolveCliCommandPathPolicy(["channels", "status"])).toEqual({
|
||||
bypassConfigGuard: false,
|
||||
routeConfigGuard: "never",
|
||||
loadPlugins: "never",
|
||||
hideBanner: false,
|
||||
ensureCliPath: true,
|
||||
});
|
||||
expect(resolveCliCommandPathPolicy(["channels", "list"])).toEqual({
|
||||
bypassConfigGuard: false,
|
||||
routeConfigGuard: "never",
|
||||
loadPlugins: "never",
|
||||
hideBanner: false,
|
||||
ensureCliPath: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves mixed startup-only rules", () => {
|
||||
|
||||
@@ -27,4 +27,47 @@ describe("command secret targets module import", () => {
|
||||
expect(() => mod.getChannelsCommandSecretTargetIds()).toThrow("registry touched too early");
|
||||
expect(listSecretTargetRegistryEntries).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("can resolve configured-channel status targets without the full registry", async () => {
|
||||
const listSecretTargetRegistryEntries = vi.fn(() => {
|
||||
throw new Error("registry touched too early");
|
||||
});
|
||||
const loadBundledChannelSecretContractApi = vi.fn((channelId: string) =>
|
||||
channelId === "telegram"
|
||||
? {
|
||||
secretTargetRegistryEntries: [
|
||||
{
|
||||
id: "channels.telegram.botToken",
|
||||
targetType: "channels.telegram.botToken",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.telegram.botToken",
|
||||
secretShape: "secret_input",
|
||||
expectedResolvedValue: "string",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
|
||||
vi.doMock("../secrets/target-registry.js", () => ({
|
||||
discoverConfigSecretTargetsByIds: vi.fn(() => []),
|
||||
listSecretTargetRegistryEntries,
|
||||
}));
|
||||
vi.doMock("../secrets/channel-contract-api.js", () => ({
|
||||
loadBundledChannelSecretContractApi,
|
||||
}));
|
||||
|
||||
const mod = await import("./command-secret-targets.js");
|
||||
const targets = mod.getStatusCommandSecretTargetIds({
|
||||
channels: { telegram: { botToken: "123456:ABCDEF" } },
|
||||
});
|
||||
|
||||
expect(targets.has("channels.telegram.botToken")).toBe(true);
|
||||
expect(targets.has("agents.defaults.memorySearch.remote.apiKey")).toBe(true);
|
||||
expect(loadBundledChannelSecretContractApi).toHaveBeenCalledWith("telegram");
|
||||
expect(listSecretTargetRegistryEntries).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { listPotentialConfiguredChannelIds } from "../channels/config-presence.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { normalizeOptionalAccountId } from "../routing/session-key.js";
|
||||
import { loadBundledChannelSecretContractApi } from "../secrets/channel-contract-api.js";
|
||||
import {
|
||||
discoverConfigSecretTargetsByIds,
|
||||
listSecretTargetRegistryEntries,
|
||||
@@ -67,12 +69,39 @@ type CommandSecretTargets = {
|
||||
|
||||
let cachedCommandSecretTargets: CommandSecretTargets | undefined;
|
||||
let cachedChannelSecretTargetIds: string[] | undefined;
|
||||
const cachedBundledChannelSecretTargetIds = new Map<string, string[] | null>();
|
||||
|
||||
function getChannelSecretTargetIds(): string[] {
|
||||
cachedChannelSecretTargetIds ??= idsByPrefix(["channels."]);
|
||||
return cachedChannelSecretTargetIds;
|
||||
}
|
||||
|
||||
function getBundledChannelSecretTargetIds(channelId: string): string[] {
|
||||
const normalizedChannelId = channelId.trim();
|
||||
if (!normalizedChannelId) {
|
||||
return [];
|
||||
}
|
||||
if (cachedBundledChannelSecretTargetIds.has(normalizedChannelId)) {
|
||||
return cachedBundledChannelSecretTargetIds.get(normalizedChannelId) ?? [];
|
||||
}
|
||||
const targetIds =
|
||||
loadBundledChannelSecretContractApi(normalizedChannelId)
|
||||
?.secretTargetRegistryEntries?.map((entry) => entry.id)
|
||||
.filter((id) => id.startsWith(`channels.${normalizedChannelId}.`))
|
||||
.toSorted() ?? null;
|
||||
cachedBundledChannelSecretTargetIds.set(normalizedChannelId, targetIds);
|
||||
return targetIds ?? [];
|
||||
}
|
||||
|
||||
function getConfiguredChannelSecretTargetIds(
|
||||
config: OpenClawConfig,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string[] {
|
||||
return listPotentialConfiguredChannelIds(config, env, { includePersistedAuthState: false })
|
||||
.toSorted()
|
||||
.flatMap((channelId) => getBundledChannelSecretTargetIds(channelId));
|
||||
}
|
||||
|
||||
function buildCommandSecretTargets(): CommandSecretTargets {
|
||||
const channelTargetIds = getChannelSecretTargetIds();
|
||||
return {
|
||||
@@ -155,6 +184,13 @@ export function getChannelsCommandSecretTargetIds(): Set<string> {
|
||||
return toTargetIdSet(getCommandSecretTargets().channels);
|
||||
}
|
||||
|
||||
export function getConfiguredChannelsCommandSecretTargetIds(
|
||||
config: OpenClawConfig,
|
||||
env?: NodeJS.ProcessEnv,
|
||||
): Set<string> {
|
||||
return toTargetIdSet(getConfiguredChannelSecretTargetIds(config, env));
|
||||
}
|
||||
|
||||
export function getModelsCommandSecretTargetIds(): Set<string> {
|
||||
return toTargetIdSet(STATIC_MODEL_TARGET_IDS);
|
||||
}
|
||||
@@ -168,8 +204,14 @@ export function getAgentRuntimeCommandSecretTargetIds(params?: {
|
||||
return toTargetIdSet(getCommandSecretTargets().agentRuntime);
|
||||
}
|
||||
|
||||
export function getStatusCommandSecretTargetIds(): Set<string> {
|
||||
return toTargetIdSet(getCommandSecretTargets().status);
|
||||
export function getStatusCommandSecretTargetIds(
|
||||
config?: OpenClawConfig,
|
||||
env?: NodeJS.ProcessEnv,
|
||||
): Set<string> {
|
||||
const channelTargetIds = config
|
||||
? getConfiguredChannelSecretTargetIds(config, env)
|
||||
: getChannelSecretTargetIds();
|
||||
return toTargetIdSet([...STATIC_STATUS_TARGET_IDS, ...channelTargetIds]);
|
||||
}
|
||||
|
||||
export function getSecurityAuditCommandSecretTargetIds(): Set<string> {
|
||||
|
||||
@@ -43,13 +43,31 @@ describe("command-startup-policy", () => {
|
||||
commandPath: ["status"],
|
||||
jsonOutputMode: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldLoadPluginsForCommandPath({
|
||||
commandPath: ["status"],
|
||||
jsonOutputMode: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldLoadPluginsForCommandPath({
|
||||
commandPath: ["health"],
|
||||
jsonOutputMode: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldLoadPluginsForCommandPath({
|
||||
commandPath: ["channels", "status"],
|
||||
jsonOutputMode: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldLoadPluginsForCommandPath({
|
||||
commandPath: ["channels", "list"],
|
||||
jsonOutputMode: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldLoadPluginsForCommandPath({
|
||||
commandPath: ["channels", "add"],
|
||||
|
||||
@@ -209,7 +209,7 @@ describe("registerPreActionHooks", () => {
|
||||
runtime: runtimeMock,
|
||||
commandPath: ["status"],
|
||||
});
|
||||
expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({ scope: "channels" });
|
||||
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
|
||||
expect(processTitleSetSpy).toHaveBeenCalledWith("openclaw-status");
|
||||
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -242,3 +242,22 @@ export function parseModelsStatusRouteArgs(argv: string[]) {
|
||||
probe: hasFlag(argv, "--probe"),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseChannelsListRouteArgs(argv: string[]) {
|
||||
return {
|
||||
json: hasFlag(argv, "--json"),
|
||||
usage: !hasFlag(argv, "--no-usage"),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseChannelsStatusRouteArgs(argv: string[]) {
|
||||
const timeout = parseOptionalFlagValue(argv, "--timeout");
|
||||
if (!timeout.ok) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
json: hasFlag(argv, "--json"),
|
||||
probe: hasFlag(argv, "--probe"),
|
||||
timeout: timeout.value,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import {
|
||||
parseAgentsListRouteArgs,
|
||||
parseChannelsListRouteArgs,
|
||||
parseChannelsStatusRouteArgs,
|
||||
parseConfigGetRouteArgs,
|
||||
parseConfigUnsetRouteArgs,
|
||||
parseGatewayStatusRouteArgs,
|
||||
@@ -123,4 +125,18 @@ export const routedCommandDefinitions = {
|
||||
await modelsStatusCommand(args, defaultRuntime);
|
||||
},
|
||||
}),
|
||||
"channels-list": defineRoutedCommand({
|
||||
parseArgs: parseChannelsListRouteArgs,
|
||||
runParsedArgs: async (args) => {
|
||||
const { channelsListCommand } = await import("../../commands/channels/list.js");
|
||||
await channelsListCommand(args, defaultRuntime);
|
||||
},
|
||||
}),
|
||||
"channels-status": defineRoutedCommand({
|
||||
parseArgs: parseChannelsStatusRouteArgs,
|
||||
runParsedArgs: async (args) => {
|
||||
const { channelsStatusCommand } = await import("../../commands/channels/status.js");
|
||||
await channelsStatusCommand(args, defaultRuntime);
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -7,6 +7,8 @@ const modelsListCommandMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const modelsStatusCommandMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const runDaemonStatusMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const statusJsonCommandMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const channelsListCommandMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const channelsStatusCommandMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
|
||||
vi.mock("../config-cli.js", () => ({
|
||||
runConfigGet: runConfigGetMock,
|
||||
@@ -26,6 +28,14 @@ vi.mock("../../commands/status-json.js", () => ({
|
||||
statusJsonCommand: statusJsonCommandMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../commands/channels/list.js", () => ({
|
||||
channelsListCommand: channelsListCommandMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../commands/channels/status.js", () => ({
|
||||
channelsStatusCommand: channelsStatusCommandMock,
|
||||
}));
|
||||
|
||||
describe("program routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -42,20 +52,48 @@ describe("program routes", () => {
|
||||
await expect(route?.run(argv)).resolves.toBe(false);
|
||||
}
|
||||
|
||||
it("matches status route and preloads plugins only for text output", () => {
|
||||
it("matches status route without plugin preload", () => {
|
||||
const route = expectRoute(["status"]);
|
||||
expect(typeof route?.loadPlugins).toBe("function");
|
||||
const shouldLoad = route?.loadPlugins as (argv: string[]) => boolean;
|
||||
expect(shouldLoad(["node", "openclaw", "status"])).toBe(true);
|
||||
expect(shouldLoad(["node", "openclaw", "status", "--json"])).toBe(false);
|
||||
expect(route?.loadPlugins).toBeUndefined();
|
||||
});
|
||||
|
||||
it("matches health route and preloads plugins only for text output", () => {
|
||||
it("matches health route without plugin preload", () => {
|
||||
const route = expectRoute(["health"]);
|
||||
expect(typeof route?.loadPlugins).toBe("function");
|
||||
const shouldLoad = route?.loadPlugins as (argv: string[]) => boolean;
|
||||
expect(shouldLoad(["node", "openclaw", "health"])).toBe(true);
|
||||
expect(shouldLoad(["node", "openclaw", "health", "--json"])).toBe(false);
|
||||
expect(route?.loadPlugins).toBeUndefined();
|
||||
});
|
||||
|
||||
it("matches channel read-only routes without plugin preload", () => {
|
||||
expect(expectRoute(["channels", "list"])?.loadPlugins).toBeUndefined();
|
||||
expect(expectRoute(["channels", "status"])?.loadPlugins).toBeUndefined();
|
||||
});
|
||||
|
||||
it("passes parsed channel read-only route flags through", async () => {
|
||||
const listRoute = expectRoute(["channels", "list"]);
|
||||
await expect(
|
||||
listRoute?.run(["node", "openclaw", "channels", "list", "--json", "--no-usage"]),
|
||||
).resolves.toBe(true);
|
||||
expect(channelsListCommandMock).toHaveBeenCalledWith(
|
||||
{ json: true, usage: false },
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
const statusRoute = expectRoute(["channels", "status"]);
|
||||
await expect(
|
||||
statusRoute?.run([
|
||||
"node",
|
||||
"openclaw",
|
||||
"channels",
|
||||
"status",
|
||||
"--json",
|
||||
"--probe",
|
||||
"--timeout",
|
||||
"5000",
|
||||
]),
|
||||
).resolves.toBe(true);
|
||||
expect(channelsStatusCommandMock).toHaveBeenCalledWith(
|
||||
{ json: true, probe: true, timeout: "5000" },
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("matches gateway status route without plugin preload", () => {
|
||||
|
||||
@@ -6,9 +6,14 @@ const activeChannelPlugins = vi.hoisted(() => [] as ChannelPlugin[]);
|
||||
|
||||
vi.mock("../channels/plugins/index.js", () => ({
|
||||
listChannelPlugins: () => activeChannelPlugins,
|
||||
getLoadedChannelPlugin: (id: string) => activeChannelPlugins.find((plugin) => plugin.id === id),
|
||||
getChannelPlugin: (id: string) => activeChannelPlugins.find((plugin) => plugin.id === id),
|
||||
}));
|
||||
|
||||
vi.mock("../channels/plugins/read-only.js", () => ({
|
||||
listReadOnlyChannelPluginsForConfig: () => activeChannelPlugins,
|
||||
}));
|
||||
|
||||
vi.mock("../channels/plugins/status.js", () => ({
|
||||
buildReadOnlySourceChannelAccountSnapshot: async ({
|
||||
accountId,
|
||||
|
||||
@@ -8,7 +8,7 @@ const mocks = vi.hoisted(() => ({
|
||||
effectiveConfig: config,
|
||||
diagnostics: [],
|
||||
})),
|
||||
loadAuthProfileStore: vi.fn(),
|
||||
loadAuthProfileStoreWithoutExternalProfiles: vi.fn(),
|
||||
listChannelPlugins: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
@@ -25,7 +25,7 @@ vi.mock("../cli/command-secret-targets.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../agents/auth-profiles.js", () => ({
|
||||
loadAuthProfileStore: mocks.loadAuthProfileStore,
|
||||
loadAuthProfileStoreWithoutExternalProfiles: mocks.loadAuthProfileStoreWithoutExternalProfiles,
|
||||
}));
|
||||
|
||||
vi.mock("../channels/plugins/index.js", () => ({
|
||||
@@ -35,13 +35,13 @@ vi.mock("../channels/plugins/index.js", () => ({
|
||||
import { channelsListCommand } from "./channels/list.js";
|
||||
|
||||
describe("channels list auth profiles", () => {
|
||||
it("includes external auth profiles in JSON output", async () => {
|
||||
it("includes local auth profiles in JSON output without loading external profiles", async () => {
|
||||
const runtime = createTestRuntime();
|
||||
mocks.readConfigFileSnapshot.mockResolvedValue({
|
||||
...baseConfigSnapshot,
|
||||
config: {},
|
||||
});
|
||||
mocks.loadAuthProfileStore.mockReturnValue({
|
||||
mocks.loadAuthProfileStoreWithoutExternalProfiles.mockReturnValue({
|
||||
version: 1,
|
||||
profiles: {
|
||||
"anthropic:default": {
|
||||
|
||||
@@ -33,6 +33,10 @@ vi.mock("../config/config.js", () => ({
|
||||
readConfigFileSnapshot: () => mocks.readConfigFileSnapshot(),
|
||||
}));
|
||||
|
||||
vi.mock("../channels/config-presence.js", () => ({
|
||||
listPotentialConfiguredChannelIds: () => ["discord"],
|
||||
}));
|
||||
|
||||
vi.mock("./channels/shared.js", () => ({
|
||||
requireValidConfigSnapshot: (runtime: unknown) => mocks.requireValidConfigSnapshot(runtime),
|
||||
formatChannelAccountLabel: ({
|
||||
@@ -82,6 +86,10 @@ vi.mock("../channels/plugins/index.js", () => ({
|
||||
(mocks.listChannelPlugins() as Array<{ id: string }>).find((plugin) => plugin.id === channel),
|
||||
}));
|
||||
|
||||
vi.mock("../channels/plugins/read-only.js", () => ({
|
||||
listReadOnlyChannelPluginsForConfig: () => mocks.listChannelPlugins(),
|
||||
}));
|
||||
|
||||
vi.mock("../channels/account-snapshot-fields.js", () => ({
|
||||
hasConfiguredUnavailableCredentialStatus: (account: Record<string, unknown>) =>
|
||||
Object.values(account).includes("configured_unavailable"),
|
||||
@@ -119,7 +127,7 @@ vi.mock("../channels/plugins/status.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../cli/command-secret-targets.js", () => ({
|
||||
getChannelsCommandSecretTargetIds: () => [],
|
||||
getConfiguredChannelsCommandSecretTargetIds: () => [],
|
||||
}));
|
||||
|
||||
vi.mock("../infra/channels-status-issues.js", () => ({
|
||||
@@ -237,4 +245,27 @@ describe("channelsStatusCommand SecretRef fallback flow", () => {
|
||||
expect(joined).not.toContain("secret unavailable in this command path");
|
||||
expect(joined).not.toContain("token:config (unavailable)");
|
||||
});
|
||||
|
||||
it("keeps JSON fallback structured without rendering config-only text", async () => {
|
||||
mocks.callGateway.mockRejectedValue(new Error("gateway closed"));
|
||||
mocks.requireValidConfigSnapshot.mockResolvedValue({ secretResolved: false, channels: {} });
|
||||
mocks.resolveCommandConfigWithSecrets.mockResolvedValue({
|
||||
resolvedConfig: { secretResolved: true, channels: {} },
|
||||
effectiveConfig: { secretResolved: true, channels: {} },
|
||||
diagnostics: [],
|
||||
});
|
||||
const { runtime, logs } = createRuntimeCapture();
|
||||
|
||||
await channelsStatusCommand({ json: true, probe: false }, runtime as never);
|
||||
|
||||
expect(mocks.listChannelPlugins).not.toHaveBeenCalled();
|
||||
const payload = JSON.parse(logs.at(-1) ?? "{}");
|
||||
expect(payload).toEqual(
|
||||
expect.objectContaining({
|
||||
gatewayReachable: false,
|
||||
configOnly: true,
|
||||
configuredChannels: ["discord"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { loadAuthProfileStore } from "../../agents/auth-profiles.js";
|
||||
import { loadAuthProfileStoreWithoutExternalProfiles } from "../../agents/auth-profiles.js";
|
||||
import { isChannelVisibleInConfiguredLists } from "../../channels/plugins/exposure.js";
|
||||
import { listChannelPlugins } from "../../channels/plugins/index.js";
|
||||
import { listReadOnlyChannelPluginsForConfig } from "../../channels/plugins/read-only.js";
|
||||
import { buildChannelAccountSnapshot } from "../../channels/plugins/status.js";
|
||||
import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js";
|
||||
import type { ChannelAccountSnapshot } from "../../channels/plugins/types.public.js";
|
||||
@@ -94,7 +94,7 @@ async function loadUsageWithProgress(
|
||||
try {
|
||||
return await withProgress(
|
||||
{ label: "Fetching usage snapshot…", indeterminate: true, enabled: true },
|
||||
async () => await loadProviderUsageSummary(),
|
||||
async () => await loadProviderUsageSummary({ skipPluginAuthWithoutCredentialSource: true }),
|
||||
);
|
||||
} catch (err) {
|
||||
runtime.error(String(err));
|
||||
@@ -112,9 +112,9 @@ export async function channelsListCommand(
|
||||
}
|
||||
const includeUsage = opts.usage !== false;
|
||||
|
||||
const plugins = listChannelPlugins();
|
||||
const plugins = listReadOnlyChannelPluginsForConfig(cfg);
|
||||
|
||||
const authStore = loadAuthProfileStore();
|
||||
const authStore = loadAuthProfileStoreWithoutExternalProfiles();
|
||||
const authProfiles = Object.entries(authStore.profiles).map(([profileId, profile]) => ({
|
||||
id: profileId,
|
||||
provider: profile.provider,
|
||||
@@ -122,7 +122,9 @@ export async function channelsListCommand(
|
||||
isExternal: false,
|
||||
}));
|
||||
if (opts.json) {
|
||||
const usage = includeUsage ? await loadProviderUsageSummary() : undefined;
|
||||
const usage = includeUsage
|
||||
? await loadProviderUsageSummary({ skipPluginAuthWithoutCredentialSource: true })
|
||||
: undefined;
|
||||
const chat: Record<string, string[]> = {};
|
||||
for (const plugin of plugins) {
|
||||
chat[plugin.id] = plugin.config.listAccountIds(cfg);
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { hasConfiguredUnavailableCredentialStatus } from "../../channels/account-snapshot-fields.js";
|
||||
import { type ChannelId, getChannelPlugin } from "../../channels/plugins/index.js";
|
||||
import { getBundledChannelSetupPlugin } from "../../channels/plugins/bundled.js";
|
||||
import {
|
||||
type ChannelId,
|
||||
getChannelPlugin,
|
||||
getLoadedChannelPlugin,
|
||||
} from "../../channels/plugins/index.js";
|
||||
import { resolveCommandConfigWithSecrets } from "../../cli/command-config-resolution.js";
|
||||
import type { CommandSecretResolutionMode } from "../../cli/command-secret-gateway.js";
|
||||
import { getChannelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js";
|
||||
@@ -46,7 +51,10 @@ export function formatAccountLabel(params: { accountId: string; name?: string })
|
||||
}
|
||||
|
||||
export const channelLabel = (channel: ChatChannel) => {
|
||||
const plugin = getChannelPlugin(channel);
|
||||
const plugin =
|
||||
getLoadedChannelPlugin(channel) ??
|
||||
getBundledChannelSetupPlugin(channel) ??
|
||||
getChannelPlugin(channel);
|
||||
return plugin?.meta.label ?? channel;
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
hasConfiguredUnavailableCredentialStatus,
|
||||
hasResolvedCredentialValue,
|
||||
} from "../../channels/account-snapshot-fields.js";
|
||||
import { listChannelPlugins } from "../../channels/plugins/index.js";
|
||||
import { listReadOnlyChannelPluginsForConfig } from "../../channels/plugins/read-only.js";
|
||||
import {
|
||||
buildChannelAccountSnapshot,
|
||||
buildReadOnlySourceChannelAccountSnapshot,
|
||||
@@ -47,7 +47,7 @@ export async function formatConfigChannelsStatusLines(
|
||||
return buildChannelAccountLine(provider, account, bits);
|
||||
});
|
||||
|
||||
const plugins = listChannelPlugins();
|
||||
const plugins = listReadOnlyChannelPluginsForConfig(cfg);
|
||||
const sourceConfig = opts?.sourceConfig ?? cfg;
|
||||
for (const plugin of plugins) {
|
||||
const accountIds = plugin.config.listAccountIds(cfg);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { listChannelPlugins } from "../../channels/plugins/index.js";
|
||||
import { listPotentialConfiguredChannelIds } from "../../channels/config-presence.js";
|
||||
import { resolveCommandConfigWithSecrets } from "../../cli/command-config-resolution.js";
|
||||
import { formatCliCommand } from "../../cli/command-format.js";
|
||||
import { getChannelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js";
|
||||
import { getConfiguredChannelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js";
|
||||
import { withProgress } from "../../cli/progress.js";
|
||||
import { readConfigFileSnapshot } from "../../config/config.js";
|
||||
import { callGateway } from "../../gateway/call.js";
|
||||
@@ -109,20 +109,19 @@ export function formatGatewayChannelsStatusLines(payload: Record<string, unknown
|
||||
return buildChannelAccountLine(provider, account, bits);
|
||||
});
|
||||
|
||||
const plugins = listChannelPlugins();
|
||||
const accountsByChannel = payload.channelAccounts as Record<string, unknown> | undefined;
|
||||
const accountPayloads: Partial<Record<string, Array<Record<string, unknown>>>> = {};
|
||||
for (const plugin of plugins) {
|
||||
const raw = accountsByChannel?.[plugin.id];
|
||||
for (const channelId of Object.keys(accountsByChannel ?? {}).toSorted()) {
|
||||
const raw = accountsByChannel?.[channelId];
|
||||
if (Array.isArray(raw)) {
|
||||
accountPayloads[plugin.id] = raw as Array<Record<string, unknown>>;
|
||||
accountPayloads[channelId] = raw as Array<Record<string, unknown>>;
|
||||
}
|
||||
}
|
||||
|
||||
for (const plugin of plugins) {
|
||||
const accounts = accountPayloads[plugin.id];
|
||||
for (const channelId of Object.keys(accountPayloads).toSorted()) {
|
||||
const accounts = accountPayloads[channelId];
|
||||
if (accounts && accounts.length > 0) {
|
||||
lines.push(...accountLines(plugin.id, accounts));
|
||||
lines.push(...accountLines(channelId, accounts));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,12 +181,27 @@ export async function channelsStatusCommand(
|
||||
const { resolvedConfig } = await resolveCommandConfigWithSecrets({
|
||||
config: cfg,
|
||||
commandName: "channels status",
|
||||
targetIds: getChannelsCommandSecretTargetIds(),
|
||||
targetIds: getConfiguredChannelsCommandSecretTargetIds(cfg),
|
||||
mode: "read_only_status",
|
||||
runtime,
|
||||
});
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
const mode = cfg.gateway?.mode === "remote" ? "remote" : "local";
|
||||
if (opts.json) {
|
||||
writeRuntimeJson(runtime, {
|
||||
gatewayReachable: false,
|
||||
error: String(err),
|
||||
configOnly: true,
|
||||
config: {
|
||||
path: snapshot.path,
|
||||
mode,
|
||||
},
|
||||
configuredChannels: listPotentialConfiguredChannelIds(resolvedConfig, process.env, {
|
||||
includePersistedAuthState: false,
|
||||
}).toSorted(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
runtime.log(
|
||||
(
|
||||
await formatConfigChannelsStatusLines(
|
||||
|
||||
@@ -3,11 +3,11 @@ import type { PluginCompatibilityNotice } from "../plugins/status.js";
|
||||
import { createCompatibilityNotice } from "../plugins/status.test-helpers.js";
|
||||
import { requireValidConfigSnapshot } from "./config-validation.js";
|
||||
|
||||
const { readConfigFileSnapshot, buildPluginCompatibilityNotices } = vi.hoisted(() => ({
|
||||
const { readConfigFileSnapshot, buildPluginCompatibilitySnapshotNotices } = vi.hoisted(() => ({
|
||||
readConfigFileSnapshot: vi.fn(),
|
||||
buildPluginCompatibilityNotices: vi.fn<(_params?: unknown) => PluginCompatibilityNotice[]>(
|
||||
() => [],
|
||||
),
|
||||
buildPluginCompatibilitySnapshotNotices: vi.fn<
|
||||
(_params?: unknown) => PluginCompatibilityNotice[]
|
||||
>(() => []),
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
@@ -15,7 +15,7 @@ vi.mock("../config/config.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/status.js", () => ({
|
||||
buildPluginCompatibilityNotices,
|
||||
buildPluginCompatibilitySnapshotNotices,
|
||||
formatPluginCompatibilityNotice: (notice: { pluginId: string; message: string }) =>
|
||||
`${notice.pluginId} ${notice.message}`,
|
||||
}));
|
||||
@@ -32,7 +32,7 @@ describe("requireValidConfigSnapshot", () => {
|
||||
config: { plugins: {} },
|
||||
issues: [],
|
||||
});
|
||||
buildPluginCompatibilityNotices.mockReturnValue([
|
||||
buildPluginCompatibilitySnapshotNotices.mockReturnValue([
|
||||
createCompatibilityNotice({ pluginId: "legacy-plugin", code: "legacy-before-agent-start" }),
|
||||
]);
|
||||
}
|
||||
@@ -54,7 +54,7 @@ describe("requireValidConfigSnapshot", () => {
|
||||
expect(config).toEqual({ plugins: {} });
|
||||
expect(runtime.error).not.toHaveBeenCalled();
|
||||
expect(runtime.exit).not.toHaveBeenCalled();
|
||||
expect(buildPluginCompatibilityNotices).not.toHaveBeenCalled();
|
||||
expect(buildPluginCompatibilitySnapshotNotices).not.toHaveBeenCalled();
|
||||
expect(runtime.log).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from "../config/config.js";
|
||||
import { formatConfigIssueLines } from "../config/issue-format.js";
|
||||
import {
|
||||
buildPluginCompatibilityNotices,
|
||||
buildPluginCompatibilitySnapshotNotices,
|
||||
formatPluginCompatibilityNotice,
|
||||
} from "../plugins/status.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
@@ -29,7 +29,7 @@ export async function requireValidConfigFileSnapshot(
|
||||
if (opts?.includeCompatibilityAdvisory !== true) {
|
||||
return snapshot;
|
||||
}
|
||||
const compatibility = buildPluginCompatibilityNotices({ config: snapshot.config });
|
||||
const compatibility = buildPluginCompatibilitySnapshotNotices({ config: snapshot.config });
|
||||
if (compatibility.length > 0) {
|
||||
runtime.log(
|
||||
[
|
||||
|
||||
@@ -11,11 +11,21 @@ async function loadNoteRuntime() {
|
||||
return import("../terminal/note.js");
|
||||
}
|
||||
|
||||
function hasConfigOAuthProfiles(cfg: OpenClawConfig): boolean {
|
||||
return Object.values(cfg.auth?.profiles ?? {}).some((profile) => profile?.mode === "oauth");
|
||||
}
|
||||
|
||||
export async function maybeRepairLegacyOAuthProfileIds(
|
||||
cfg: OpenClawConfig,
|
||||
prompter: DoctorPrompter,
|
||||
): Promise<OpenClawConfig> {
|
||||
if (!hasConfigOAuthProfiles(cfg)) {
|
||||
return cfg;
|
||||
}
|
||||
const store = ensureAuthProfileStore();
|
||||
if (Object.keys(store.profiles).length === 0) {
|
||||
return cfg;
|
||||
}
|
||||
let nextCfg = cfg;
|
||||
const { resolvePluginProviders } = await loadProviderRuntime();
|
||||
const providers = resolvePluginProviders({
|
||||
|
||||
@@ -63,6 +63,16 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("maybeRepairLegacyOAuthProfileIds", () => {
|
||||
it("skips provider loading when config has no legacy OAuth profiles", async () => {
|
||||
const cfg = { channels: { telegram: { enabled: true } } } as OpenClawConfig;
|
||||
|
||||
const next = await maybeRepairLegacyOAuthProfileIds(cfg, makePrompter(true));
|
||||
|
||||
expect(next).toBe(cfg);
|
||||
expect(resolvePluginProvidersMock).not.toHaveBeenCalled();
|
||||
expect(repairMocks.repairOAuthProfileIdMismatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("repairs provider-owned legacy OAuth profile ids", async () => {
|
||||
authProfileStoreMock.store = {
|
||||
version: 1,
|
||||
|
||||
36
src/commands/doctor-auth.profile-health.test.ts
Normal file
36
src/commands/doctor-auth.profile-health.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { DoctorPrompter } from "./doctor-prompter.js";
|
||||
|
||||
const authProfileMocks = vi.hoisted(() => ({
|
||||
ensureAuthProfileStore: vi.fn(() => {
|
||||
throw new Error("unexpected auth profile load");
|
||||
}),
|
||||
hasAnyAuthProfileStoreSource: vi.fn(() => false),
|
||||
resolveApiKeyForProfile: vi.fn(),
|
||||
resolveProfileUnusableUntilForDisplay: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../agents/auth-profiles.js", () => ({
|
||||
ensureAuthProfileStore: authProfileMocks.ensureAuthProfileStore,
|
||||
hasAnyAuthProfileStoreSource: authProfileMocks.hasAnyAuthProfileStoreSource,
|
||||
resolveApiKeyForProfile: authProfileMocks.resolveApiKeyForProfile,
|
||||
resolveProfileUnusableUntilForDisplay: authProfileMocks.resolveProfileUnusableUntilForDisplay,
|
||||
}));
|
||||
|
||||
vi.mock("../terminal/note.js", () => ({ note: vi.fn() }));
|
||||
|
||||
import { noteAuthProfileHealth } from "./doctor-auth.js";
|
||||
|
||||
describe("noteAuthProfileHealth", () => {
|
||||
it("skips external auth profile resolution when no auth source exists", async () => {
|
||||
await noteAuthProfileHealth({
|
||||
cfg: { channels: { telegram: { enabled: true } } } as OpenClawConfig,
|
||||
prompter: {} as DoctorPrompter,
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
|
||||
expect(authProfileMocks.hasAnyAuthProfileStoreSource).toHaveBeenCalledOnce();
|
||||
expect(authProfileMocks.ensureAuthProfileStore).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import {
|
||||
type AuthCredentialReasonCode,
|
||||
ensureAuthProfileStore,
|
||||
hasAnyAuthProfileStoreSource,
|
||||
resolveApiKeyForProfile,
|
||||
resolveProfileUnusableUntilForDisplay,
|
||||
} from "../agents/auth-profiles.js";
|
||||
@@ -207,6 +208,12 @@ export async function noteAuthProfileHealth(params: {
|
||||
prompter: DoctorPrompter;
|
||||
allowKeychainPrompt: boolean;
|
||||
}): Promise<void> {
|
||||
if (
|
||||
Object.keys(params.cfg.auth?.profiles ?? {}).length === 0 &&
|
||||
!hasAnyAuthProfileStoreSource()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const store = ensureAuthProfileStore(undefined, {
|
||||
allowKeychainPrompt: params.allowKeychainPrompt,
|
||||
});
|
||||
|
||||
@@ -4,7 +4,10 @@ import path from "node:path";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { CLAUDE_CLI_PROFILE_ID } from "../agents/auth-profiles/constants.js";
|
||||
import { resolveAuthStorePathForDisplay } from "../agents/auth-profiles/paths.js";
|
||||
import { ensureAuthProfileStore } from "../agents/auth-profiles/store.js";
|
||||
import {
|
||||
ensureAuthProfileStore,
|
||||
hasAnyAuthProfileStoreSource,
|
||||
} from "../agents/auth-profiles/store.js";
|
||||
import type {
|
||||
AuthProfileStore,
|
||||
OAuthCredential,
|
||||
@@ -195,13 +198,18 @@ export function noteClaudeCliHealth(
|
||||
workspaceDir?: string;
|
||||
},
|
||||
) {
|
||||
const store = deps?.store ?? ensureAuthProfileStore(undefined, { allowKeychainPrompt: false });
|
||||
const hasConfigSignals = hasClaudeCliConfigSignals(cfg);
|
||||
const store =
|
||||
deps?.store ??
|
||||
(hasConfigSignals || hasAnyAuthProfileStoreSource()
|
||||
? ensureAuthProfileStore(undefined, { allowKeychainPrompt: false })
|
||||
: ({ version: 1, profiles: {} } as AuthProfileStore));
|
||||
const readClaudeCliCredentials =
|
||||
deps?.readClaudeCliCredentials ??
|
||||
(() => readClaudeCliCredentialsCached({ allowKeychainPrompt: false }));
|
||||
const credential = readClaudeCliCredentials();
|
||||
|
||||
if (!hasClaudeCliConfigSignals(cfg) && !hasClaudeCliStoreSignals(store) && !credential) {
|
||||
if (!hasConfigSignals && !hasClaudeCliStoreSignals(store) && !credential) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ const resolveAgentDir = vi.hoisted(() => vi.fn(() => "/tmp/agent-default"));
|
||||
const resolveAgentWorkspaceDir = vi.hoisted(() => vi.fn(() => "/tmp/agent-default/workspace"));
|
||||
const resolveMemorySearchConfig = vi.hoisted(() => vi.fn());
|
||||
const resolveApiKeyForProvider = vi.hoisted(() => vi.fn());
|
||||
const resolveActiveMemoryBackendConfig = vi.hoisted(() => vi.fn());
|
||||
const hasAnyAuthProfileStoreSource = vi.hoisted(() => vi.fn(() => true));
|
||||
const getActiveMemorySearchManager = vi.hoisted(() => vi.fn());
|
||||
type CheckQmdBinaryAvailability = typeof checkQmdBinaryAvailabilityFn;
|
||||
const checkQmdBinaryAvailability = vi.hoisted(() =>
|
||||
@@ -37,10 +37,15 @@ vi.mock("../agents/memory-search.js", () => ({
|
||||
|
||||
vi.mock("../agents/model-auth.js", () => ({
|
||||
resolveApiKeyForProvider,
|
||||
resolveEnvApiKey: vi.fn(() => null),
|
||||
resolveUsableCustomProviderApiKey: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("../agents/auth-profiles.js", () => ({
|
||||
hasAnyAuthProfileStoreSource,
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/memory-runtime.js", () => ({
|
||||
resolveActiveMemoryBackendConfig,
|
||||
getActiveMemorySearchManager,
|
||||
}));
|
||||
|
||||
@@ -145,8 +150,8 @@ describe("noteMemorySearchHealth", () => {
|
||||
resolveMemorySearchConfig.mockReset();
|
||||
resolveApiKeyForProvider.mockReset();
|
||||
resolveApiKeyForProvider.mockRejectedValue(new Error("missing key"));
|
||||
resolveActiveMemoryBackendConfig.mockReset();
|
||||
resolveActiveMemoryBackendConfig.mockReturnValue({ backend: "builtin", citations: "auto" });
|
||||
hasAnyAuthProfileStoreSource.mockReset();
|
||||
hasAnyAuthProfileStoreSource.mockReturnValue(true);
|
||||
getActiveMemorySearchManager.mockReset();
|
||||
getActiveMemorySearchManager.mockResolvedValue({
|
||||
manager: {
|
||||
@@ -215,18 +220,14 @@ describe("noteMemorySearchHealth", () => {
|
||||
});
|
||||
|
||||
it("does not warn when QMD backend is active", async () => {
|
||||
resolveActiveMemoryBackendConfig.mockReturnValue({
|
||||
backend: "qmd",
|
||||
citations: "auto",
|
||||
qmd: { command: "qmd" },
|
||||
});
|
||||
const qmdCfg = { memory: { backend: "qmd", qmd: { command: "qmd" } } } as OpenClawConfig;
|
||||
resolveMemorySearchConfig.mockReturnValue({
|
||||
provider: "auto",
|
||||
local: {},
|
||||
remote: {},
|
||||
});
|
||||
|
||||
await noteMemorySearchHealth(cfg, {});
|
||||
await noteMemorySearchHealth(qmdCfg, {});
|
||||
|
||||
expect(note).not.toHaveBeenCalled();
|
||||
expect(checkQmdBinaryAvailability).toHaveBeenCalledWith({
|
||||
@@ -237,11 +238,7 @@ describe("noteMemorySearchHealth", () => {
|
||||
});
|
||||
|
||||
it("warns when QMD backend is active but the qmd binary is unavailable", async () => {
|
||||
resolveActiveMemoryBackendConfig.mockReturnValue({
|
||||
backend: "qmd",
|
||||
citations: "auto",
|
||||
qmd: { command: "qmd" },
|
||||
});
|
||||
const qmdCfg = { memory: { backend: "qmd", qmd: { command: "qmd" } } } as OpenClawConfig;
|
||||
checkQmdBinaryAvailability.mockResolvedValueOnce({
|
||||
available: false,
|
||||
error: "spawn qmd ENOENT",
|
||||
@@ -252,7 +249,7 @@ describe("noteMemorySearchHealth", () => {
|
||||
remote: {},
|
||||
});
|
||||
|
||||
await noteMemorySearchHealth(cfg, {});
|
||||
await noteMemorySearchHealth(qmdCfg, {});
|
||||
|
||||
expect(note).toHaveBeenCalledTimes(1);
|
||||
const message = String(note.mock.calls[0]?.[0] ?? "");
|
||||
@@ -460,7 +457,29 @@ describe("noteMemorySearchHealth", () => {
|
||||
expect(note).toHaveBeenCalledTimes(1);
|
||||
const providerCalls = resolveApiKeyForProvider.mock.calls as Array<[{ provider: string }]>;
|
||||
const providersChecked = providerCalls.map(([arg]) => arg.provider);
|
||||
expect(providersChecked).toEqual(["openai"]);
|
||||
expect(providersChecked).toEqual([
|
||||
"github-copilot",
|
||||
"openai",
|
||||
"google",
|
||||
"voyage",
|
||||
"mistral",
|
||||
"amazon-bedrock",
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips auth-profile probing in auto mode when no auth store exists", async () => {
|
||||
hasAnyAuthProfileStoreSource.mockReturnValue(false);
|
||||
resolveMemorySearchConfig.mockReturnValue({
|
||||
provider: "auto",
|
||||
local: {},
|
||||
remote: {},
|
||||
});
|
||||
|
||||
await noteMemorySearchHealth(cfg);
|
||||
|
||||
const providerCalls = resolveApiKeyForProvider.mock.calls as Array<[{ provider: string }]>;
|
||||
const providersChecked = providerCalls.map(([arg]) => arg.provider);
|
||||
expect(providersChecked).toEqual(["amazon-bedrock"]);
|
||||
});
|
||||
|
||||
it("uses runtime-derived env var hints for explicit providers", async () => {
|
||||
|
||||
@@ -4,8 +4,13 @@ import {
|
||||
resolveAgentWorkspaceDir,
|
||||
resolveDefaultAgentId,
|
||||
} from "../agents/agent-scope.js";
|
||||
import { hasAnyAuthProfileStoreSource } from "../agents/auth-profiles.js";
|
||||
import { resolveMemorySearchConfig } from "../agents/memory-search.js";
|
||||
import { resolveApiKeyForProvider } from "../agents/model-auth.js";
|
||||
import {
|
||||
resolveApiKeyForProvider,
|
||||
resolveEnvApiKey,
|
||||
resolveUsableCustomProviderApiKey,
|
||||
} from "../agents/model-auth.js";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
@@ -15,29 +20,19 @@ import { hasConfiguredMemorySecretInput } from "../memory-host-sdk/secret.js";
|
||||
import {
|
||||
auditDreamingArtifacts,
|
||||
auditShortTermPromotionArtifacts,
|
||||
getBuiltinMemoryEmbeddingProviderDoctorMetadata,
|
||||
listBuiltinAutoSelectMemoryEmbeddingProviderDoctorMetadata,
|
||||
repairDreamingArtifacts,
|
||||
repairShortTermPromotionArtifacts,
|
||||
type DreamingArtifactsAuditSummary,
|
||||
type ShortTermAuditSummary,
|
||||
} from "../plugin-sdk/memory-core-engine-runtime.js";
|
||||
import {
|
||||
getActiveMemorySearchManager,
|
||||
resolveActiveMemoryBackendConfig,
|
||||
} from "../plugins/memory-runtime.js";
|
||||
import { getActiveMemorySearchManager } from "../plugins/memory-runtime.js";
|
||||
import { getProviderEnvVars } from "../secrets/provider-env-vars.js";
|
||||
import { normalizeOptionalString } from "../shared/string-coerce.js";
|
||||
import { note } from "../terminal/note.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import type { DoctorPrompter } from "./doctor-prompter.js";
|
||||
import { isRecord } from "./doctor/shared/legacy-config-record-shared.js";
|
||||
|
||||
function resolveSuggestedRemoteMemoryProvider(): string | undefined {
|
||||
return listBuiltinAutoSelectMemoryEmbeddingProviderDoctorMetadata().find(
|
||||
(provider) => provider.transport === "remote",
|
||||
)?.providerId;
|
||||
}
|
||||
|
||||
type RuntimeMemoryAuditContext = {
|
||||
workspaceDir?: string;
|
||||
backend?: string;
|
||||
@@ -45,6 +40,94 @@ type RuntimeMemoryAuditContext = {
|
||||
qmdCollections?: number;
|
||||
};
|
||||
|
||||
type MemoryEmbeddingProviderDoctorMetadata = {
|
||||
providerId: string;
|
||||
authProviderId: string;
|
||||
transport: "local" | "remote";
|
||||
autoSelectPriority?: number;
|
||||
};
|
||||
|
||||
const BUNDLED_MEMORY_EMBEDDING_PROVIDER_DOCTOR_METADATA: MemoryEmbeddingProviderDoctorMetadata[] = [
|
||||
{
|
||||
providerId: "github-copilot",
|
||||
authProviderId: "github-copilot",
|
||||
transport: "remote",
|
||||
autoSelectPriority: 15,
|
||||
},
|
||||
{
|
||||
providerId: "openai",
|
||||
authProviderId: "openai",
|
||||
transport: "remote",
|
||||
autoSelectPriority: 20,
|
||||
},
|
||||
{
|
||||
providerId: "gemini",
|
||||
authProviderId: "google",
|
||||
transport: "remote",
|
||||
autoSelectPriority: 30,
|
||||
},
|
||||
{
|
||||
providerId: "voyage",
|
||||
authProviderId: "voyage",
|
||||
transport: "remote",
|
||||
autoSelectPriority: 40,
|
||||
},
|
||||
{
|
||||
providerId: "mistral",
|
||||
authProviderId: "mistral",
|
||||
transport: "remote",
|
||||
autoSelectPriority: 50,
|
||||
},
|
||||
{
|
||||
providerId: "bedrock",
|
||||
authProviderId: "amazon-bedrock",
|
||||
transport: "remote",
|
||||
autoSelectPriority: 60,
|
||||
},
|
||||
];
|
||||
|
||||
function resolveMemoryEmbeddingProviderDoctorMetadata(
|
||||
providerId: string,
|
||||
): (MemoryEmbeddingProviderDoctorMetadata & { envVars: string[] }) | null {
|
||||
const metadata =
|
||||
BUNDLED_MEMORY_EMBEDDING_PROVIDER_DOCTOR_METADATA.find(
|
||||
(candidate) => candidate.providerId === providerId,
|
||||
) ?? null;
|
||||
if (!metadata) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...metadata,
|
||||
envVars: getProviderEnvVars(metadata.authProviderId),
|
||||
};
|
||||
}
|
||||
|
||||
function listAutoSelectMemoryEmbeddingProviderDoctorMetadata(): Array<
|
||||
MemoryEmbeddingProviderDoctorMetadata & { envVars: string[] }
|
||||
> {
|
||||
return BUNDLED_MEMORY_EMBEDDING_PROVIDER_DOCTOR_METADATA.filter(
|
||||
(provider) => typeof provider.autoSelectPriority === "number",
|
||||
)
|
||||
.toSorted((a, b) => (a.autoSelectPriority ?? 0) - (b.autoSelectPriority ?? 0))
|
||||
.map((provider) => ({
|
||||
providerId: provider.providerId,
|
||||
authProviderId: provider.authProviderId,
|
||||
transport: provider.transport,
|
||||
autoSelectPriority: provider.autoSelectPriority,
|
||||
envVars: getProviderEnvVars(provider.authProviderId),
|
||||
}));
|
||||
}
|
||||
|
||||
function resolveSuggestedRemoteMemoryProvider(): string | undefined {
|
||||
return listAutoSelectMemoryEmbeddingProviderDoctorMetadata().find(
|
||||
(provider) => provider.transport === "remote",
|
||||
)?.providerId;
|
||||
}
|
||||
|
||||
function resolveConfiguredQmdBackendConfig(cfg: OpenClawConfig): OpenClawConfig["memory"] | null {
|
||||
return cfg.memory?.backend === "qmd" ? cfg.memory : null;
|
||||
}
|
||||
|
||||
async function resolveRuntimeMemoryAuditContext(
|
||||
cfg: OpenClawConfig,
|
||||
): Promise<RuntimeMemoryAuditContext | null> {
|
||||
@@ -242,21 +325,17 @@ export async function noteMemorySearchHealth(
|
||||
|
||||
// QMD backend handles embeddings internally (e.g. embeddinggemma) — no
|
||||
// separate embedding provider is needed. Skip the provider check entirely.
|
||||
const backendConfig = resolveActiveMemoryBackendConfig({ cfg, agentId });
|
||||
if (!backendConfig) {
|
||||
note("No active memory plugin is registered for the current config.", "Memory search");
|
||||
return;
|
||||
}
|
||||
if (backendConfig.backend === "qmd") {
|
||||
const qmdBackendConfig = resolveConfiguredQmdBackendConfig(cfg);
|
||||
if (qmdBackendConfig) {
|
||||
const qmdCheck = await checkQmdBinaryAvailability({
|
||||
command: backendConfig.qmd?.command ?? "qmd",
|
||||
command: qmdBackendConfig.qmd?.command ?? "qmd",
|
||||
env: process.env,
|
||||
cwd: resolveAgentWorkspaceDir(cfg, agentId),
|
||||
});
|
||||
if (!qmdCheck.available) {
|
||||
note(
|
||||
[
|
||||
`QMD memory backend is configured, but the qmd binary could not be started (${backendConfig.qmd?.command ?? "qmd"}).`,
|
||||
`QMD memory backend is configured, but the qmd binary could not be started (${qmdBackendConfig.qmd?.command ?? "qmd"}).`,
|
||||
qmdCheck.error ? `Probe error: ${qmdCheck.error}` : null,
|
||||
"",
|
||||
"Fix (pick one):",
|
||||
@@ -373,7 +452,7 @@ export async function noteMemorySearchHealth(
|
||||
if (hasLocalEmbeddings(resolved.local)) {
|
||||
return;
|
||||
}
|
||||
const autoSelectProviders = listBuiltinAutoSelectMemoryEmbeddingProviderDoctorMetadata().filter(
|
||||
const autoSelectProviders = listAutoSelectMemoryEmbeddingProviderDoctorMetadata().filter(
|
||||
(provider) => provider.transport === "remote",
|
||||
);
|
||||
for (const provider of autoSelectProviders) {
|
||||
@@ -450,10 +529,20 @@ async function hasApiKeyForProvider(
|
||||
cfg: OpenClawConfig,
|
||||
agentDir: string,
|
||||
): Promise<boolean> {
|
||||
const metadata = getBuiltinMemoryEmbeddingProviderDoctorMetadata(provider);
|
||||
const metadata = resolveMemoryEmbeddingProviderDoctorMetadata(provider);
|
||||
const authProviderId = metadata?.authProviderId ?? provider;
|
||||
if (
|
||||
resolveEnvApiKey(authProviderId) ||
|
||||
resolveUsableCustomProviderApiKey({ cfg, provider: authProviderId })
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (authProviderId !== "amazon-bedrock" && !hasAnyAuthProfileStoreSource(agentDir)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await resolveApiKeyForProvider({
|
||||
provider: metadata?.authProviderId ?? provider,
|
||||
provider: authProviderId,
|
||||
cfg,
|
||||
agentDir,
|
||||
});
|
||||
@@ -464,7 +553,7 @@ async function hasApiKeyForProvider(
|
||||
}
|
||||
|
||||
function resolvePrimaryMemoryProviderEnvVar(provider: string): string {
|
||||
const metadata = getBuiltinMemoryEmbeddingProviderDoctorMetadata(provider);
|
||||
const metadata = resolveMemoryEmbeddingProviderDoctorMetadata(provider);
|
||||
return metadata?.envVars[0] ?? `${provider.toUpperCase()}_API_KEY`;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const mocks = vi.hoisted(() => ({
|
||||
resolveAgentWorkspaceDir: vi.fn(),
|
||||
resolveDefaultAgentId: vi.fn(),
|
||||
buildWorkspaceSkillStatus: vi.fn(),
|
||||
buildPluginDiagnosticsReport: vi.fn(),
|
||||
buildPluginSnapshotReport: vi.fn(),
|
||||
buildPluginCompatibilityWarnings: vi.fn(),
|
||||
listTaskFlowRecords: vi.fn<() => unknown[]>(() => []),
|
||||
listTasksForFlowId: vi.fn<(flowId: string) => unknown[]>((_flowId: string) => []),
|
||||
@@ -27,7 +27,7 @@ vi.mock("../agents/skills-status.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/status.js", () => ({
|
||||
buildPluginDiagnosticsReport: (...args: unknown[]) => mocks.buildPluginDiagnosticsReport(...args),
|
||||
buildPluginSnapshotReport: (...args: unknown[]) => mocks.buildPluginSnapshotReport(...args),
|
||||
buildPluginCompatibilityWarnings: (...args: unknown[]) =>
|
||||
mocks.buildPluginCompatibilityWarnings(...args),
|
||||
}));
|
||||
@@ -53,7 +53,7 @@ async function runNoteWorkspaceStatusForTest(
|
||||
mocks.buildWorkspaceSkillStatus.mockReturnValue({
|
||||
skills: [],
|
||||
});
|
||||
mocks.buildPluginDiagnosticsReport.mockReturnValue({
|
||||
mocks.buildPluginSnapshotReport.mockReturnValue({
|
||||
workspaceDir: "/workspace",
|
||||
...loadResult,
|
||||
});
|
||||
@@ -85,7 +85,7 @@ describe("noteWorkspaceStatus", () => {
|
||||
}),
|
||||
);
|
||||
try {
|
||||
expect(mocks.buildPluginDiagnosticsReport).toHaveBeenCalledWith({
|
||||
expect(mocks.buildPluginSnapshotReport).toHaveBeenCalledWith({
|
||||
config: {},
|
||||
workspaceDir: "/workspace",
|
||||
});
|
||||
@@ -183,7 +183,7 @@ describe("noteWorkspaceStatus", () => {
|
||||
"legacy-plugin still uses legacy before_agent_start",
|
||||
]);
|
||||
try {
|
||||
expect(mocks.buildPluginDiagnosticsReport).toHaveBeenCalledWith({
|
||||
expect(mocks.buildPluginSnapshotReport).toHaveBeenCalledWith({
|
||||
config: {},
|
||||
workspaceDir: "/workspace",
|
||||
});
|
||||
|
||||
@@ -2,10 +2,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent
|
||||
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import {
|
||||
buildPluginCompatibilityWarnings,
|
||||
buildPluginDiagnosticsReport,
|
||||
} from "../plugins/status.js";
|
||||
import { buildPluginCompatibilityWarnings, buildPluginSnapshotReport } from "../plugins/status.js";
|
||||
import { listTasksForFlowId } from "../tasks/runtime-internal.js";
|
||||
import { listTaskFlowRecords } from "../tasks/task-flow-runtime-internal.js";
|
||||
import { note } from "../terminal/note.js";
|
||||
@@ -72,7 +69,7 @@ export function noteWorkspaceStatus(cfg: OpenClawConfig) {
|
||||
"Skills status",
|
||||
);
|
||||
|
||||
const pluginRegistry = buildPluginDiagnosticsReport({
|
||||
const pluginRegistry = buildPluginSnapshotReport({
|
||||
config: cfg,
|
||||
workspaceDir,
|
||||
});
|
||||
|
||||
@@ -139,6 +139,6 @@ describe("healthCommand (coverage)", () => {
|
||||
[" Gateway target: ws://127.0.0.1:18789"],
|
||||
]);
|
||||
expect(buildGatewayConnectionDetailsMock).toHaveBeenCalled();
|
||||
expect(logWebSelfIdMock).toHaveBeenCalled();
|
||||
expect(logWebSelfIdMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
|
||||
import { listChannelPlugins } from "../channels/plugins/index.js";
|
||||
import { listReadOnlyChannelPluginsForConfig } from "../channels/plugins/read-only.js";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
|
||||
import type { ChannelAccountSnapshot } from "../channels/plugins/types.public.js";
|
||||
import { inspectReadOnlyChannelAccount } from "../channels/read-only-account-inspect.js";
|
||||
@@ -247,10 +247,11 @@ export async function getHealthSnapshot(params?: {
|
||||
const cappedTimeout = timeoutMs === undefined ? DEFAULT_TIMEOUT_MS : Math.max(50, timeoutMs);
|
||||
const doProbe = params?.probe !== false;
|
||||
const channels: Record<string, ChannelHealthSummary> = {};
|
||||
const channelOrder = listChannelPlugins().map((plugin) => plugin.id);
|
||||
const plugins = listReadOnlyChannelPluginsForConfig(cfg);
|
||||
const channelOrder = plugins.map((plugin) => plugin.id);
|
||||
const channelLabels: Record<string, string> = {};
|
||||
|
||||
for (const plugin of listChannelPlugins()) {
|
||||
for (const plugin of plugins) {
|
||||
channelLabels[plugin.id] = plugin.meta.label ?? plugin.id;
|
||||
const accountIds = plugin.config.listAccountIds(cfg);
|
||||
const defaultAccountId = resolveChannelDefaultAccountId({
|
||||
@@ -447,9 +448,10 @@ export async function healthCommand(
|
||||
? resolvedAgents
|
||||
: resolvedAgents.filter((agent) => agent.agentId === defaultAgentId);
|
||||
const channelBindings = buildChannelAccountBindings(cfg);
|
||||
const displayPlugins = listReadOnlyChannelPluginsForConfig(cfg);
|
||||
if (debugEnabled) {
|
||||
runtime.log(info("[debug] local channel accounts"));
|
||||
for (const plugin of listChannelPlugins()) {
|
||||
for (const plugin of displayPlugins) {
|
||||
const accountIds = plugin.config.listAccountIds(cfg);
|
||||
const defaultAccountId = resolveChannelDefaultAccountId({
|
||||
plugin,
|
||||
@@ -496,7 +498,7 @@ export async function healthCommand(
|
||||
}
|
||||
}
|
||||
const channelAccountFallbacks = Object.fromEntries(
|
||||
listChannelPlugins().map((plugin) => {
|
||||
displayPlugins.map((plugin) => {
|
||||
const accountIds = plugin.config.listAccountIds(cfg);
|
||||
const defaultAccountId = resolveChannelDefaultAccountId({
|
||||
plugin,
|
||||
@@ -547,7 +549,7 @@ export async function healthCommand(
|
||||
for (const line of channelLines) {
|
||||
runtime.log(styleHealthChannelLine(line, rich));
|
||||
}
|
||||
for (const plugin of listChannelPlugins()) {
|
||||
for (const plugin of displayPlugins) {
|
||||
const channelSummary = summary.channels?.[plugin.id];
|
||||
if (!channelSummary || channelSummary.linked !== true) {
|
||||
continue;
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
formatChannelAllowFrom,
|
||||
} from "../../channels/account-summary.js";
|
||||
import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js";
|
||||
import { listChannelPlugins } from "../../channels/plugins/index.js";
|
||||
import { listReadOnlyChannelPluginsForConfig } from "../../channels/plugins/read-only.js";
|
||||
import { formatChannelStatusState } from "../../channels/plugins/status-state.js";
|
||||
import type {
|
||||
ChannelAccountSnapshot,
|
||||
@@ -205,7 +205,7 @@ export async function buildChannelsTable(
|
||||
rows: Array<Record<string, string>>;
|
||||
}> = [];
|
||||
|
||||
for (const plugin of listChannelPlugins()) {
|
||||
for (const plugin of listReadOnlyChannelPluginsForConfig(cfg)) {
|
||||
const accountIds = plugin.config.listAccountIds(cfg);
|
||||
const defaultAccountId = resolveChannelDefaultAccountId({
|
||||
plugin,
|
||||
|
||||
@@ -110,6 +110,7 @@ describe("statusJsonCommand", () => {
|
||||
deep: false,
|
||||
includeFilesystem: true,
|
||||
includeChannelSecurity: true,
|
||||
plugins: expect.any(Array),
|
||||
});
|
||||
expect(logs).toHaveLength(1);
|
||||
expect(JSON.parse(logs[0] ?? "{}")).toHaveProperty("securityAudit.summary.critical", 1);
|
||||
|
||||
@@ -57,6 +57,7 @@ describe("status-runtime-shared", () => {
|
||||
deep: false,
|
||||
includeFilesystem: true,
|
||||
includeChannelSecurity: true,
|
||||
plugins: expect.any(Array),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -244,6 +245,7 @@ describe("status-runtime-shared", () => {
|
||||
deep: false,
|
||||
includeFilesystem: true,
|
||||
includeChannelSecurity: true,
|
||||
plugins: expect.any(Array),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { listReadOnlyChannelPluginsForConfig } from "../channels/plugins/read-only.js";
|
||||
import type { OpenClawConfig } from "../config/types.js";
|
||||
import type { HeartbeatEventPayload } from "../infra/heartbeat-events.js";
|
||||
import type { HealthSummary } from "./health.js";
|
||||
@@ -33,6 +34,7 @@ export async function resolveStatusSecurityAudit(params: {
|
||||
deep: false,
|
||||
includeFilesystem: true,
|
||||
includeChannelSecurity: true,
|
||||
plugins: listReadOnlyChannelPluginsForConfig(params.config),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ import type { OpenClawConfig } from "../config/config.js";
|
||||
|
||||
const pluginRegistry = vi.hoisted(() => ({ list: [] as unknown[] }));
|
||||
|
||||
vi.mock("../channels/plugins/index.js", () => ({
|
||||
listChannelPlugins: () => pluginRegistry.list,
|
||||
vi.mock("../channels/plugins/read-only.js", () => ({
|
||||
listReadOnlyChannelPluginsForConfig: () => pluginRegistry.list,
|
||||
}));
|
||||
|
||||
vi.mock("../channels/read-only-account-inspect.js", () => ({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { listChannelPlugins } from "../channels/plugins/index.js";
|
||||
import { listReadOnlyChannelPluginsForConfig } from "../channels/plugins/read-only.js";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
|
||||
import type { ChannelAccountSnapshot } from "../channels/plugins/types.public.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
@@ -15,7 +15,7 @@ export type LinkChannelContext = {
|
||||
export async function resolveLinkChannelContext(
|
||||
cfg: OpenClawConfig,
|
||||
): Promise<LinkChannelContext | null> {
|
||||
for (const plugin of listChannelPlugins()) {
|
||||
for (const plugin of listReadOnlyChannelPluginsForConfig(cfg)) {
|
||||
const { defaultAccountId, account, enabled, configured } =
|
||||
await resolveDefaultChannelAccountContext(plugin, cfg, {
|
||||
mode: "read_only",
|
||||
|
||||
@@ -71,7 +71,10 @@ describe("executeStatusScanFromOverview", () => {
|
||||
});
|
||||
|
||||
expect(resolveMemoryPluginStatus).toHaveBeenCalledWith(overview.cfg);
|
||||
expect(resolveStatusSummaryFromOverview).toHaveBeenCalledWith({ overview });
|
||||
expect(resolveStatusSummaryFromOverview).toHaveBeenCalledWith({
|
||||
overview,
|
||||
includeChannelSummary: undefined,
|
||||
});
|
||||
expect(resolveMemory).toHaveBeenCalledWith({
|
||||
cfg: overview.cfg,
|
||||
agentStatus: overview.agentStatus,
|
||||
|
||||
@@ -12,6 +12,9 @@ import {
|
||||
export async function executeStatusScanFromOverview(params: {
|
||||
overview: StatusScanOverviewResult;
|
||||
runtime?: RuntimeEnv;
|
||||
summary?: {
|
||||
includeChannelSummary?: boolean;
|
||||
};
|
||||
resolveMemory: (args: {
|
||||
cfg: StatusScanOverviewResult["cfg"];
|
||||
agentStatus: StatusScanOverviewResult["agentStatus"];
|
||||
@@ -30,7 +33,10 @@ export async function executeStatusScanFromOverview(params: {
|
||||
memoryPlugin,
|
||||
...(params.runtime ? { runtime: params.runtime } : {}),
|
||||
}),
|
||||
resolveStatusSummaryFromOverview({ overview: params.overview }),
|
||||
resolveStatusSummaryFromOverview({
|
||||
overview: params.overview,
|
||||
includeChannelSummary: params.summary?.includeChannelSummary,
|
||||
}),
|
||||
]);
|
||||
|
||||
return buildStatusScanResult({
|
||||
|
||||
@@ -168,7 +168,9 @@ export async function collectStatusScanOverview(params: {
|
||||
).resolveCommandConfigWithSecrets({
|
||||
config: loadedConfig,
|
||||
commandName: params.commandName,
|
||||
targetIds: (await loadCommandSecretTargetsModule()).getStatusCommandSecretTargetIds(),
|
||||
targetIds: (await loadCommandSecretTargetsModule()).getStatusCommandSecretTargetIds(
|
||||
loadedConfig,
|
||||
),
|
||||
mode: "read_only_status",
|
||||
...(params.runtime ? { runtime: params.runtime } : {}),
|
||||
}),
|
||||
@@ -279,6 +281,7 @@ export async function collectStatusScanOverview(params: {
|
||||
|
||||
export async function resolveStatusSummaryFromOverview(params: {
|
||||
overview: Pick<StatusScanOverviewResult, "skipColdStartNetworkChecks" | "cfg" | "sourceConfig">;
|
||||
includeChannelSummary?: boolean;
|
||||
}) {
|
||||
if (params.overview.skipColdStartNetworkChecks) {
|
||||
return buildColdStartStatusSummary();
|
||||
@@ -287,6 +290,7 @@ export async function resolveStatusSummaryFromOverview(params: {
|
||||
getStatusSummary({
|
||||
config: params.overview.cfg,
|
||||
sourceConfig: params.overview.sourceConfig,
|
||||
includeChannelSummary: params.includeChannelSummary,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,22 +50,16 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("scanStatusJsonFast", () => {
|
||||
it("routes plugin logs to stderr during deferred plugin loading", async () => {
|
||||
it("does not preload configured channel plugins for the lean JSON path", async () => {
|
||||
mocks.hasPotentialConfiguredChannels.mockReturnValue(true);
|
||||
|
||||
let stderrDuringLoad = false;
|
||||
mocks.ensurePluginRegistryLoaded.mockImplementation(() => {
|
||||
stderrDuringLoad = loggingStateRef.forceConsoleToStderr;
|
||||
});
|
||||
|
||||
await scanStatusJsonFast({}, {} as never);
|
||||
|
||||
expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalled();
|
||||
expect(stderrDuringLoad).toBe(true);
|
||||
expect(mocks.ensurePluginRegistryLoaded).not.toHaveBeenCalled();
|
||||
expect(loggingStateRef.forceConsoleToStderr).toBe(false);
|
||||
});
|
||||
|
||||
it("preloads configured channel plugins from the resolved snapshot while preserving source activation config", async () => {
|
||||
it("keeps resolved and source channel configs available without loading runtime plugins", async () => {
|
||||
mocks.hasPotentialConfiguredChannels.mockReturnValue(true);
|
||||
applyStatusScanDefaults(mocks, {
|
||||
hasConfiguredChannels: true,
|
||||
@@ -92,22 +86,8 @@ describe("scanStatusJsonFast", () => {
|
||||
|
||||
await scanStatusJsonFast({}, {} as never);
|
||||
|
||||
expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
scope: "configured-channels",
|
||||
config: expect.objectContaining({ marker: "resolved-snapshot" }),
|
||||
activationSourceConfig: expect.objectContaining({
|
||||
channels: expect.objectContaining({
|
||||
telegram: expect.objectContaining({
|
||||
botToken: expect.objectContaining({
|
||||
source: "file",
|
||||
id: "/telegram/bot-token",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mocks.ensurePluginRegistryLoaded).not.toHaveBeenCalled();
|
||||
expect(mocks.resolveCommandSecretRefsViaGateway).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips plugin compatibility loading even when configured channels are present", async () => {
|
||||
@@ -118,6 +98,16 @@ describe("scanStatusJsonFast", () => {
|
||||
expect(mocks.buildPluginCompatibilityNotices).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the fast JSON summary off the channel plugin summary path", async () => {
|
||||
mocks.hasPotentialConfiguredChannels.mockReturnValue(true);
|
||||
|
||||
await scanStatusJsonFast({}, {} as never);
|
||||
|
||||
expect(mocks.getStatusSummary).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ includeChannelSummary: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips memory inspection for the lean status --json fast path", async () => {
|
||||
const result = await scanStatusJsonFast({}, {} as never);
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { hasPotentialConfiguredChannels } from "../channels/config-presence.js";
|
||||
import { ensureCliPluginRegistryLoaded } from "../cli/plugin-registry-loader.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { executeStatusScanFromOverview } from "./status.scan-execute.ts";
|
||||
import {
|
||||
@@ -12,6 +11,7 @@ import type { StatusScanResult } from "./status.scan-result.ts";
|
||||
type StatusJsonScanPolicy = {
|
||||
commandName: string;
|
||||
allowMissingConfigFastPath?: boolean;
|
||||
includeChannelSummary?: boolean;
|
||||
resolveHasConfiguredChannels: (
|
||||
cfg: Parameters<typeof hasPotentialConfiguredChannels>[0],
|
||||
) => boolean;
|
||||
@@ -35,18 +35,12 @@ export async function scanStatusJsonWithPolicy(
|
||||
resolveHasConfiguredChannels: policy.resolveHasConfiguredChannels,
|
||||
includeChannelsData: false,
|
||||
});
|
||||
if (overview.hasConfiguredChannels) {
|
||||
await ensureCliPluginRegistryLoaded({
|
||||
scope: "configured-channels",
|
||||
routeLogsToStderr: true,
|
||||
config: overview.cfg,
|
||||
activationSourceConfig: overview.sourceConfig,
|
||||
});
|
||||
}
|
||||
|
||||
return await executeStatusScanFromOverview({
|
||||
overview,
|
||||
runtime,
|
||||
summary: {
|
||||
includeChannelSummary: policy.includeChannelSummary,
|
||||
},
|
||||
resolveMemory: policy.resolveMemory,
|
||||
channelIssues: [],
|
||||
channels: { rows: [], details: [] },
|
||||
@@ -64,6 +58,7 @@ export async function scanStatusJsonFast(
|
||||
return await scanStatusJsonWithPolicy(opts, runtime, {
|
||||
commandName: "status --json",
|
||||
allowMissingConfigFastPath: true,
|
||||
includeChannelSummary: false,
|
||||
resolveHasConfiguredChannels: (cfg) =>
|
||||
hasPotentialConfiguredChannels(cfg, process.env, {
|
||||
includePersistedAuthState: false,
|
||||
|
||||
@@ -109,9 +109,13 @@ export function createStatusPluginRegistryModuleMock(
|
||||
|
||||
export function createStatusPluginStatusModuleMock(
|
||||
mocks: Pick<StatusScanSharedMocks, "buildPluginCompatibilityNotices">,
|
||||
): { buildPluginCompatibilityNotices: StatusScanSharedMocks["buildPluginCompatibilityNotices"] } {
|
||||
): {
|
||||
buildPluginCompatibilityNotices: StatusScanSharedMocks["buildPluginCompatibilityNotices"];
|
||||
buildPluginCompatibilitySnapshotNotices: StatusScanSharedMocks["buildPluginCompatibilityNotices"];
|
||||
} {
|
||||
return {
|
||||
buildPluginCompatibilityNotices: mocks.buildPluginCompatibilityNotices,
|
||||
buildPluginCompatibilitySnapshotNotices: mocks.buildPluginCompatibilityNotices,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -186,7 +186,7 @@ describe("scanStatus", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preloads configured channel plugins for status --json when channel config exists", async () => {
|
||||
it("keeps status --json on read-only channel metadata when channel config exists", async () => {
|
||||
configureScanStatus({
|
||||
hasConfiguredChannels: true,
|
||||
sourceConfig: createStatusScanConfig({
|
||||
@@ -204,13 +204,7 @@ describe("scanStatus", () => {
|
||||
|
||||
await scanStatus({ json: true }, {} as never);
|
||||
|
||||
expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
scope: "configured-channels",
|
||||
config: expect.objectContaining({ marker: "resolved-preload" }),
|
||||
activationSourceConfig: expect.objectContaining({ marker: "source-preload" }),
|
||||
}),
|
||||
);
|
||||
expect(mocks.ensurePluginRegistryLoaded).not.toHaveBeenCalled();
|
||||
// Verify plugin logs were routed to stderr during loading and restored after
|
||||
expect(loggingStateRef.forceConsoleToStderr).toBe(false);
|
||||
expect(mocks.probeGateway).toHaveBeenCalledWith(
|
||||
@@ -221,7 +215,7 @@ describe("scanStatus", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("preloads configured channel plugins for status --json when channel auth is env-only", async () => {
|
||||
it("keeps status --json on read-only channel metadata when channel auth is env-only", async () => {
|
||||
configureScanStatus({
|
||||
hasConfiguredChannels: true,
|
||||
sourceConfig: createStatusScanConfig({
|
||||
@@ -239,12 +233,6 @@ describe("scanStatus", () => {
|
||||
await scanStatus({ json: true }, {} as never);
|
||||
});
|
||||
|
||||
expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
scope: "configured-channels",
|
||||
config: expect.objectContaining({ marker: "resolved-env-only" }),
|
||||
activationSourceConfig: expect.objectContaining({ marker: "source-env-only" }),
|
||||
}),
|
||||
);
|
||||
expect(mocks.ensurePluginRegistryLoaded).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { hasPotentialConfiguredChannels } from "../channels/config-presence.js";
|
||||
import { withProgress } from "../cli/progress.js";
|
||||
import { buildPluginCompatibilityNotices } from "../plugins/status.js";
|
||||
import { buildPluginCompatibilitySnapshotNotices } from "../plugins/status.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { executeStatusScanFromOverview } from "./status.scan-execute.ts";
|
||||
import { resolveStatusMemoryStatusSnapshot } from "./status.scan-memory.ts";
|
||||
@@ -59,7 +59,7 @@ export async function scanStatus(
|
||||
});
|
||||
|
||||
progress.setLabel("Checking plugins…");
|
||||
const pluginCompatibility = buildPluginCompatibilityNotices({ config: overview.cfg });
|
||||
const pluginCompatibility = buildPluginCompatibilitySnapshotNotices({ config: overview.cfg });
|
||||
progress.tick();
|
||||
|
||||
progress.setLabel("Checking memory and sessions…");
|
||||
|
||||
@@ -150,6 +150,16 @@ describe("getStatusSummary", () => {
|
||||
expect(resolveLinkChannelContext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips channel summary imports when explicitly disabled", async () => {
|
||||
const summary = await getStatusSummary({ includeChannelSummary: false });
|
||||
|
||||
expect(summary.channelSummary).toEqual([]);
|
||||
expect(summary.linkChannel).toBeUndefined();
|
||||
expect(statusSummaryMocks.hasPotentialConfiguredChannels).not.toHaveBeenCalled();
|
||||
expect(buildChannelSummary).not.toHaveBeenCalled();
|
||||
expect(resolveLinkChannelContext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not trigger async context warmup while building status summaries", async () => {
|
||||
await getStatusSummary();
|
||||
|
||||
|
||||
@@ -105,11 +105,12 @@ export function redactSensitiveStatusSummary(summary: StatusSummary): StatusSumm
|
||||
export async function getStatusSummary(
|
||||
options: {
|
||||
includeSensitive?: boolean;
|
||||
includeChannelSummary?: boolean;
|
||||
config?: OpenClawConfig;
|
||||
sourceConfig?: OpenClawConfig;
|
||||
} = {},
|
||||
): Promise<StatusSummary> {
|
||||
const { includeSensitive = true } = options;
|
||||
const { includeSensitive = true, includeChannelSummary = true } = options;
|
||||
const {
|
||||
classifySessionKey,
|
||||
resolveConfiguredStatusModelRef,
|
||||
@@ -117,7 +118,7 @@ export async function getStatusSummary(
|
||||
resolveSessionModelRef,
|
||||
} = await loadStatusSummaryRuntimeModule();
|
||||
const cfg = options.config ?? (await loadConfigIoModule()).loadConfig();
|
||||
const needsChannelPlugins = hasPotentialConfiguredChannels(cfg);
|
||||
const needsChannelPlugins = includeChannelSummary && hasPotentialConfiguredChannels(cfg);
|
||||
const linkContext = needsChannelPlugins
|
||||
? await loadLinkChannelModule().then(({ resolveLinkChannelContext }) =>
|
||||
resolveLinkChannelContext(cfg),
|
||||
|
||||
@@ -417,14 +417,18 @@ async function runGatewayHealthChecks(ctx: DoctorHealthFlowContext): Promise<voi
|
||||
}
|
||||
|
||||
async function runMemorySearchHealthContribution(ctx: DoctorHealthFlowContext): Promise<void> {
|
||||
await maybeRepairMemoryRecallHealth({
|
||||
cfg: ctx.cfg,
|
||||
prompter: ctx.prompter,
|
||||
});
|
||||
if (ctx.prompter.shouldRepair) {
|
||||
await maybeRepairMemoryRecallHealth({
|
||||
cfg: ctx.cfg,
|
||||
prompter: ctx.prompter,
|
||||
});
|
||||
}
|
||||
await noteMemorySearchHealth(ctx.cfg, {
|
||||
gatewayMemoryProbe: ctx.gatewayMemoryProbe ?? { checked: false, ready: false },
|
||||
});
|
||||
await noteMemoryRecallHealth(ctx.cfg);
|
||||
if (ctx.options.deep === true) {
|
||||
await noteMemoryRecallHealth(ctx.cfg);
|
||||
}
|
||||
}
|
||||
|
||||
async function runDevicePairingHealth(ctx: DoctorHealthFlowContext): Promise<void> {
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
buildChannelAccountSnapshot,
|
||||
formatChannelAllowFrom,
|
||||
} from "../channels/account-summary.js";
|
||||
import { listChannelPlugins } from "../channels/plugins/index.js";
|
||||
import { listReadOnlyChannelPluginsForConfig } from "../channels/plugins/read-only.js";
|
||||
import { formatChannelStatusState } from "../channels/plugins/status-state.js";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
|
||||
import type { ChannelAccountSnapshot } from "../channels/plugins/types.public.js";
|
||||
@@ -113,7 +113,7 @@ export async function buildChannelSummary(
|
||||
resolved.colorize && color ? color(value) : value;
|
||||
const sourceConfig = options?.sourceConfig ?? effective;
|
||||
|
||||
for (const plugin of listChannelPlugins()) {
|
||||
for (const plugin of listReadOnlyChannelPluginsForConfig(effective)) {
|
||||
const accountIds = plugin.config.listAccountIds(effective);
|
||||
const defaultAccountId =
|
||||
plugin.config.defaultAccountId?.(effective) ?? accountIds[0] ?? DEFAULT_ACCOUNT_ID;
|
||||
|
||||
@@ -122,6 +122,8 @@ vi.mock("../agents/auth-profiles.js", () => {
|
||||
return {
|
||||
clearRuntimeAuthProfileStoreSnapshots: () => {},
|
||||
ensureAuthProfileStore: (agentDir?: string) => readStore(agentDir),
|
||||
hasAnyAuthProfileStoreSource: (agentDir?: string) =>
|
||||
Boolean(agentDir && nodeFs.existsSync(path.join(agentDir, "auth-profiles.json"))),
|
||||
dedupeProfileIds,
|
||||
listProfilesForProvider,
|
||||
resolveApiKeyForProfile,
|
||||
|
||||
@@ -10,6 +10,7 @@ const ensureAuthProfileStoreMock = vi.fn(() => ({
|
||||
vi.mock("../agents/auth-profiles.js", () => ({
|
||||
dedupeProfileIds: (profileIds: string[]) => [...new Set(profileIds)],
|
||||
ensureAuthProfileStore: () => ensureAuthProfileStoreMock(),
|
||||
hasAnyAuthProfileStoreSource: () => false,
|
||||
listProfilesForProvider: () => [],
|
||||
resolveApiKeyForProfile: async () => null,
|
||||
resolveAuthProfileOrder: () => [],
|
||||
@@ -55,4 +56,17 @@ describe("resolveProviderAuths plugin boundary", () => {
|
||||
]);
|
||||
expect(ensureAuthProfileStoreMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips plugin usage auth when requested and no direct credential source exists", async () => {
|
||||
await expect(
|
||||
resolveProviderAuths({
|
||||
providers: ["zai"],
|
||||
skipPluginAuthWithoutCredentialSource: true,
|
||||
env: {},
|
||||
}),
|
||||
).resolves.toEqual([]);
|
||||
|
||||
expect(resolveProviderUsageAuthWithPluginMock).not.toHaveBeenCalled();
|
||||
expect(ensureAuthProfileStoreMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import {
|
||||
dedupeProfileIds,
|
||||
ensureAuthProfileStore,
|
||||
hasAnyAuthProfileStoreSource,
|
||||
listProfilesForProvider,
|
||||
resolveApiKeyForProfile,
|
||||
resolveAuthProfileOrder,
|
||||
} from "../agents/auth-profiles.js";
|
||||
import { resolveEnvApiKey } from "../agents/model-auth-env.js";
|
||||
import { isNonSecretApiKeyMarker } from "../agents/model-auth-markers.js";
|
||||
import { resolveUsableCustomProviderApiKey } from "../agents/model-auth.js";
|
||||
import { normalizeProviderId } from "../agents/model-selection.js";
|
||||
@@ -25,6 +27,7 @@ type UsageAuthState = {
|
||||
cfg: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
agentDir?: string;
|
||||
allowAuthProfileStore: boolean;
|
||||
store?: AuthStore;
|
||||
};
|
||||
|
||||
@@ -35,7 +38,7 @@ function resolveUsageAuthStore(state: UsageAuthState): AuthStore {
|
||||
return state.store;
|
||||
}
|
||||
|
||||
function resolveProviderApiKeyFromConfigAndStore(params: {
|
||||
function resolveProviderApiKeyFromConfig(params: {
|
||||
state: UsageAuthState;
|
||||
providerIds: string[];
|
||||
envDirect?: Array<string | undefined>;
|
||||
@@ -46,14 +49,31 @@ function resolveProviderApiKeyFromConfigAndStore(params: {
|
||||
}
|
||||
|
||||
for (const providerId of params.providerIds) {
|
||||
const envKey = resolveEnvApiKey(providerId, params.state.env)?.apiKey;
|
||||
if (envKey) {
|
||||
return envKey;
|
||||
}
|
||||
const key = resolveUsableCustomProviderApiKey({
|
||||
cfg: params.state.cfg,
|
||||
provider: providerId,
|
||||
env: params.state.env,
|
||||
})?.apiKey;
|
||||
if (key) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveProviderApiKeyFromConfigAndStore(params: {
|
||||
state: UsageAuthState;
|
||||
providerIds: string[];
|
||||
envDirect?: Array<string | undefined>;
|
||||
}): string | undefined {
|
||||
const configKey = resolveProviderApiKeyFromConfig(params);
|
||||
if (configKey || !params.state.allowAuthProfileStore) {
|
||||
return configKey;
|
||||
}
|
||||
|
||||
const normalizedProviderIds = new Set(
|
||||
params.providerIds.map((providerId) => normalizeProviderId(providerId)).filter(Boolean),
|
||||
@@ -92,6 +112,9 @@ async function resolveOAuthToken(params: {
|
||||
state: UsageAuthState;
|
||||
provider: string;
|
||||
}): Promise<ProviderAuth | null> {
|
||||
if (!params.state.allowAuthProfileStore) {
|
||||
return null;
|
||||
}
|
||||
const store = resolveUsageAuthStore(params.state);
|
||||
const order = resolveAuthProfileOrder({
|
||||
cfg: params.state.cfg,
|
||||
@@ -208,26 +231,45 @@ export async function resolveProviderAuths(params: {
|
||||
agentDir?: string;
|
||||
config?: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
skipPluginAuthWithoutCredentialSource?: boolean;
|
||||
}): Promise<ProviderAuth[]> {
|
||||
if (params.auth) {
|
||||
return params.auth;
|
||||
}
|
||||
|
||||
const state: UsageAuthState = {
|
||||
const stateBase = {
|
||||
cfg: params.config ?? loadConfig(),
|
||||
env: params.env ?? process.env,
|
||||
agentDir: params.agentDir,
|
||||
};
|
||||
const hasDirectCredentialSource = params.providers.some((provider) =>
|
||||
Boolean(
|
||||
resolveProviderApiKeyFromConfig({
|
||||
state: { ...stateBase, allowAuthProfileStore: false },
|
||||
providerIds: [provider],
|
||||
}),
|
||||
),
|
||||
);
|
||||
const allowAuthProfileStore =
|
||||
!params.skipPluginAuthWithoutCredentialSource ||
|
||||
hasDirectCredentialSource ||
|
||||
hasAnyAuthProfileStoreSource(params.agentDir);
|
||||
const state: UsageAuthState = {
|
||||
...stateBase,
|
||||
allowAuthProfileStore,
|
||||
};
|
||||
const auths: ProviderAuth[] = [];
|
||||
|
||||
for (const provider of params.providers) {
|
||||
const pluginAuth = await resolveProviderUsageAuthViaPlugin({
|
||||
state,
|
||||
provider,
|
||||
});
|
||||
if (pluginAuth) {
|
||||
auths.push(pluginAuth);
|
||||
continue;
|
||||
if (!params.skipPluginAuthWithoutCredentialSource || allowAuthProfileStore) {
|
||||
const pluginAuth = await resolveProviderUsageAuthViaPlugin({
|
||||
state,
|
||||
provider,
|
||||
});
|
||||
if (pluginAuth) {
|
||||
auths.push(pluginAuth);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const fallbackAuth = await resolveProviderUsageAuthFallback({
|
||||
state,
|
||||
|
||||
@@ -40,6 +40,7 @@ type UsageSummaryOptions = {
|
||||
config?: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
fetch?: typeof fetch;
|
||||
skipPluginAuthWithoutCredentialSource?: boolean;
|
||||
};
|
||||
|
||||
async function fetchProviderUsageSnapshot(params: {
|
||||
@@ -96,6 +97,7 @@ export async function loadProviderUsageSummary(
|
||||
agentDir: opts.agentDir,
|
||||
config,
|
||||
env,
|
||||
skipPluginAuthWithoutCredentialSource: opts.skipPluginAuthWithoutCredentialSource,
|
||||
});
|
||||
if (auths.length === 0) {
|
||||
return { updatedAt: now, providers: [] };
|
||||
|
||||
@@ -406,6 +406,18 @@ export function buildPluginCompatibilityNotices(params?: {
|
||||
return buildAllPluginInspectReports(params).flatMap((inspect) => inspect.compatibility);
|
||||
}
|
||||
|
||||
export function buildPluginCompatibilitySnapshotNotices(params?: {
|
||||
config?: OpenClawConfig;
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): PluginCompatibilityNotice[] {
|
||||
const report = buildPluginSnapshotReport(params);
|
||||
return buildPluginCompatibilityNotices({
|
||||
...params,
|
||||
report,
|
||||
});
|
||||
}
|
||||
|
||||
export function formatPluginCompatibilityNotice(notice: PluginCompatibilityNotice): string {
|
||||
return `${notice.pluginId} ${notice.message}`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import path from "node:path";
|
||||
import { resolveSandboxConfigForAgent } from "../agents/sandbox/config.js";
|
||||
import { hasPotentialConfiguredChannels } from "../channels/config-presence.js";
|
||||
import {
|
||||
hasPotentialConfiguredChannels,
|
||||
listPotentialConfiguredChannelIds,
|
||||
} from "../channels/config-presence.js";
|
||||
import type { listChannelPlugins } from "../channels/plugins/index.js";
|
||||
import type { ConfigFileSnapshot, OpenClawConfig } from "../config/config.js";
|
||||
import { resolveConfigPath, resolveStateDir } from "../config/paths.js";
|
||||
@@ -352,6 +355,14 @@ async function collectPluginSecurityAuditFindings(
|
||||
requestedPluginIds.add(normalized);
|
||||
}
|
||||
}
|
||||
if (context.includeChannelSecurity && context.plugins !== undefined) {
|
||||
for (const channelId of listPotentialConfiguredChannelIds(
|
||||
context.sourceConfig,
|
||||
context.env,
|
||||
)) {
|
||||
requestedPluginIds.delete(channelId);
|
||||
}
|
||||
}
|
||||
if (requestedPluginIds.size === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user