diff --git a/CHANGELOG.md b/CHANGELOG.md index e6709696d9d..40e71b5e552 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 875595a0b9c..38946de245b 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -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({ - 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({ - 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..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..channels', - }, - }); - function normalizeDiscordAcpConversationId(conversationId: string) { const normalized = conversationId.trim(); return normalized ? { conversationId: normalized } : null; @@ -829,12 +793,7 @@ export const discordPlugin: ChannelPlugin }, }, }, - security: { - resolveDmPolicy: resolveDiscordDmPolicy, - collectWarnings: collectDiscordSecurityWarnings, - collectAuditFindings: async (params) => - (await loadDiscordSecurityAuditModule()).collectDiscordSecurityAuditFindings(params), - }, + security: discordSecurityAdapter, threading: { scopedAccountReplyToMode: { resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }), diff --git a/extensions/discord/src/shared.test.ts b/extensions/discord/src/shared.test.ts index 3d6af28d4fa..7a00a12c563 100644 --- a/extensions/discord/src/shared.test.ts +++ b/extensions/discord/src/shared.test.ts @@ -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"); + }); }); diff --git a/extensions/discord/src/shared.ts b/extensions/discord/src/shared.ts index 5aebe5d6f94..f9776a7e011 100644 --- a/extensions/discord/src/shared.ts +++ b/extensions/discord/src/shared.ts @@ -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" >; } diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 3a1100b9208..e00adaa12a0 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -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({ - 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({ - 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 = createChatChannelPlugin< ResolvedSlackAccount, SlackProbe @@ -554,11 +518,7 @@ export const slackPlugin: ChannelPlugin = crea }, }, }, - security: { - resolveDmPolicy: resolveSlackDmPolicy, - collectWarnings: collectSlackSecurityWarnings, - collectAuditFindings: collectSlackSecurityAuditFindings, - }, + security: slackSecurityAdapter, threading: { scopedAccountReplyToMode: { resolveAccount: adaptScopedAccountAccessor(resolveSlackAccount), diff --git a/extensions/slack/src/shared.test.ts b/extensions/slack/src/shared.test.ts index c2ba08091bf..a21f23ab2fa 100644 --- a/extensions/slack/src/shared.test.ts +++ b/extensions/slack/src/shared.test.ts @@ -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", () => { diff --git a/extensions/slack/src/shared.ts b/extensions/slack/src/shared.ts index a3f24c9d1c2..0c50325cd1d 100644 --- a/extensions/slack/src/shared.ts +++ b/extensions/slack/src/shared.ts @@ -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" >; } diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index a98d95698df..772efa11c9f 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -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({ - 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), diff --git a/extensions/telegram/src/shared.ts b/extensions/telegram/src/shared.ts index 361f95030e0..b6395739fbd 100644 --- a/extensions/telegram/src/shared.ts +++ b/extensions/telegram/src/shared.ts @@ -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" diff --git a/src/cli/channels-cli.ts b/src/cli/channels-cli.ts index af777eafd6e..d4ad1fef45b 100644 --- a/src/cli/channels-cli.ts +++ b/src/cli/channels-cli.ts @@ -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); }); }); diff --git a/src/cli/command-catalog.ts b/src/cli/command-catalog.ts index 4244d1ec3e3..521e7922ab3 100644 --- a/src/cli/command-catalog.ts +++ b/src/cli/command-catalog.ts @@ -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" }, + }, ]; diff --git a/src/cli/command-path-policy.test.ts b/src/cli/command-path-policy.test.ts index 7b1beb64e97..a8019f2ca4f 100644 --- a/src/cli/command-path-policy.test.ts +++ b/src/cli/command-path-policy.test.ts @@ -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", () => { diff --git a/src/cli/command-secret-targets.import.test.ts b/src/cli/command-secret-targets.import.test.ts index f9d18befdf3..7d60da3a176 100644 --- a/src/cli/command-secret-targets.import.test.ts +++ b/src/cli/command-secret-targets.import.test.ts @@ -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(); + }); }); diff --git a/src/cli/command-secret-targets.ts b/src/cli/command-secret-targets.ts index 5e419d81a58..a057ee00577 100644 --- a/src/cli/command-secret-targets.ts +++ b/src/cli/command-secret-targets.ts @@ -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(); 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 { return toTargetIdSet(getCommandSecretTargets().channels); } +export function getConfiguredChannelsCommandSecretTargetIds( + config: OpenClawConfig, + env?: NodeJS.ProcessEnv, +): Set { + return toTargetIdSet(getConfiguredChannelSecretTargetIds(config, env)); +} + export function getModelsCommandSecretTargetIds(): Set { return toTargetIdSet(STATIC_MODEL_TARGET_IDS); } @@ -168,8 +204,14 @@ export function getAgentRuntimeCommandSecretTargetIds(params?: { return toTargetIdSet(getCommandSecretTargets().agentRuntime); } -export function getStatusCommandSecretTargetIds(): Set { - return toTargetIdSet(getCommandSecretTargets().status); +export function getStatusCommandSecretTargetIds( + config?: OpenClawConfig, + env?: NodeJS.ProcessEnv, +): Set { + const channelTargetIds = config + ? getConfiguredChannelSecretTargetIds(config, env) + : getChannelSecretTargetIds(); + return toTargetIdSet([...STATIC_STATUS_TARGET_IDS, ...channelTargetIds]); } export function getSecurityAuditCommandSecretTargetIds(): Set { diff --git a/src/cli/command-startup-policy.test.ts b/src/cli/command-startup-policy.test.ts index f75845d9f65..051c0bbbdcf 100644 --- a/src/cli/command-startup-policy.test.ts +++ b/src/cli/command-startup-policy.test.ts @@ -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"], diff --git a/src/cli/program/preaction.test.ts b/src/cli/program/preaction.test.ts index cb476f929e6..5c85fd32c5c 100644 --- a/src/cli/program/preaction.test.ts +++ b/src/cli/program/preaction.test.ts @@ -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(); diff --git a/src/cli/program/route-args.ts b/src/cli/program/route-args.ts index 6f914030213..9843cfd299d 100644 --- a/src/cli/program/route-args.ts +++ b/src/cli/program/route-args.ts @@ -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, + }; +} diff --git a/src/cli/program/routed-command-definitions.ts b/src/cli/program/routed-command-definitions.ts index d774c1d9d0c..ee356251ae3 100644 --- a/src/cli/program/routed-command-definitions.ts +++ b/src/cli/program/routed-command-definitions.ts @@ -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); + }, + }), }; diff --git a/src/cli/program/routes.test.ts b/src/cli/program/routes.test.ts index ab5d710e1f0..faa1ae02827 100644 --- a/src/cli/program/routes.test.ts +++ b/src/cli/program/routes.test.ts @@ -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", () => { diff --git a/src/commands/channels.config-only-status-output.test.ts b/src/commands/channels.config-only-status-output.test.ts index 2264a0abce5..bb25a406b94 100644 --- a/src/commands/channels.config-only-status-output.test.ts +++ b/src/commands/channels.config-only-status-output.test.ts @@ -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, diff --git a/src/commands/channels.list.auth-profiles.test.ts b/src/commands/channels.list.auth-profiles.test.ts index a1e5e72a7b1..fe48a5c4d34 100644 --- a/src/commands/channels.list.auth-profiles.test.ts +++ b/src/commands/channels.list.auth-profiles.test.ts @@ -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": { diff --git a/src/commands/channels.status.command-flow.test.ts b/src/commands/channels.status.command-flow.test.ts index f342da4c349..6004a57713b 100644 --- a/src/commands/channels.status.command-flow.test.ts +++ b/src/commands/channels.status.command-flow.test.ts @@ -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) => 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"], + }), + ); + }); }); diff --git a/src/commands/channels/list.ts b/src/commands/channels/list.ts index 66285adf482..54419609a93 100644 --- a/src/commands/channels/list.ts +++ b/src/commands/channels/list.ts @@ -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 = {}; for (const plugin of plugins) { chat[plugin.id] = plugin.config.listAccountIds(cfg); diff --git a/src/commands/channels/shared.ts b/src/commands/channels/shared.ts index 318ee1ad62a..178e6f591a2 100644 --- a/src/commands/channels/shared.ts +++ b/src/commands/channels/shared.ts @@ -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; }; diff --git a/src/commands/channels/status-config-format.ts b/src/commands/channels/status-config-format.ts index 6b4910711cf..42a0ef0302e 100644 --- a/src/commands/channels/status-config-format.ts +++ b/src/commands/channels/status-config-format.ts @@ -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); diff --git a/src/commands/channels/status.ts b/src/commands/channels/status.ts index 0ac22f18eea..828cac14c6a 100644 --- a/src/commands/channels/status.ts +++ b/src/commands/channels/status.ts @@ -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 | undefined; const accountPayloads: Partial>>> = {}; - 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>; + accountPayloads[channelId] = raw as Array>; } } - 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( diff --git a/src/commands/config-validation.test.ts b/src/commands/config-validation.test.ts index 20bd6476b6b..8c9f2055207 100644 --- a/src/commands/config-validation.test.ts +++ b/src/commands/config-validation.test.ts @@ -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(); }); diff --git a/src/commands/config-validation.ts b/src/commands/config-validation.ts index 4a80ab1f6ad..ec939841540 100644 --- a/src/commands/config-validation.ts +++ b/src/commands/config-validation.ts @@ -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( [ diff --git a/src/commands/doctor-auth-legacy-oauth.ts b/src/commands/doctor-auth-legacy-oauth.ts index cbafb738c48..a33b3df90a4 100644 --- a/src/commands/doctor-auth-legacy-oauth.ts +++ b/src/commands/doctor-auth-legacy-oauth.ts @@ -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 { + 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({ diff --git a/src/commands/doctor-auth.deprecated-cli-profiles.test.ts b/src/commands/doctor-auth.deprecated-cli-profiles.test.ts index b091d6953e0..5b82da8d859 100644 --- a/src/commands/doctor-auth.deprecated-cli-profiles.test.ts +++ b/src/commands/doctor-auth.deprecated-cli-profiles.test.ts @@ -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, diff --git a/src/commands/doctor-auth.profile-health.test.ts b/src/commands/doctor-auth.profile-health.test.ts new file mode 100644 index 00000000000..1e5d60d37ca --- /dev/null +++ b/src/commands/doctor-auth.profile-health.test.ts @@ -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(); + }); +}); diff --git a/src/commands/doctor-auth.ts b/src/commands/doctor-auth.ts index 033be87f58f..863c69ff274 100644 --- a/src/commands/doctor-auth.ts +++ b/src/commands/doctor-auth.ts @@ -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 { + if ( + Object.keys(params.cfg.auth?.profiles ?? {}).length === 0 && + !hasAnyAuthProfileStoreSource() + ) { + return; + } const store = ensureAuthProfileStore(undefined, { allowKeychainPrompt: params.allowKeychainPrompt, }); diff --git a/src/commands/doctor-claude-cli.ts b/src/commands/doctor-claude-cli.ts index 23acd79ea34..d0936520531 100644 --- a/src/commands/doctor-claude-cli.ts +++ b/src/commands/doctor-claude-cli.ts @@ -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; } diff --git a/src/commands/doctor-memory-search.test.ts b/src/commands/doctor-memory-search.test.ts index a5b9256faa6..34f5d78f2db 100644 --- a/src/commands/doctor-memory-search.test.ts +++ b/src/commands/doctor-memory-search.test.ts @@ -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 () => { diff --git a/src/commands/doctor-memory-search.ts b/src/commands/doctor-memory-search.ts index 5f1a3de8853..93a039757ee 100644 --- a/src/commands/doctor-memory-search.ts +++ b/src/commands/doctor-memory-search.ts @@ -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 { @@ -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 { - 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`; } diff --git a/src/commands/doctor-workspace-status.test.ts b/src/commands/doctor-workspace-status.test.ts index 454c89a44d0..77ad5daefef 100644 --- a/src/commands/doctor-workspace-status.test.ts +++ b/src/commands/doctor-workspace-status.test.ts @@ -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", }); diff --git a/src/commands/doctor-workspace-status.ts b/src/commands/doctor-workspace-status.ts index aeb2a232bde..d3bd7595863 100644 --- a/src/commands/doctor-workspace-status.ts +++ b/src/commands/doctor-workspace-status.ts @@ -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, }); diff --git a/src/commands/health.command.coverage.test.ts b/src/commands/health.command.coverage.test.ts index cd40e7bf4d7..09a400dbb0f 100644 --- a/src/commands/health.command.coverage.test.ts +++ b/src/commands/health.command.coverage.test.ts @@ -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(); }); }); diff --git a/src/commands/health.ts b/src/commands/health.ts index 4766d022377..b40867c1725 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -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 = {}; - const channelOrder = listChannelPlugins().map((plugin) => plugin.id); + const plugins = listReadOnlyChannelPluginsForConfig(cfg); + const channelOrder = plugins.map((plugin) => plugin.id); const channelLabels: Record = {}; - 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; diff --git a/src/commands/status-all/channels.ts b/src/commands/status-all/channels.ts index 16694dab6d5..db7bdb438e8 100644 --- a/src/commands/status-all/channels.ts +++ b/src/commands/status-all/channels.ts @@ -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>; }> = []; - for (const plugin of listChannelPlugins()) { + for (const plugin of listReadOnlyChannelPluginsForConfig(cfg)) { const accountIds = plugin.config.listAccountIds(cfg); const defaultAccountId = resolveChannelDefaultAccountId({ plugin, diff --git a/src/commands/status-json.test.ts b/src/commands/status-json.test.ts index 06ab265b255..49da0cea473 100644 --- a/src/commands/status-json.test.ts +++ b/src/commands/status-json.test.ts @@ -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); diff --git a/src/commands/status-runtime-shared.test.ts b/src/commands/status-runtime-shared.test.ts index cebd13d8f96..19aab5165f5 100644 --- a/src/commands/status-runtime-shared.test.ts +++ b/src/commands/status-runtime-shared.test.ts @@ -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), }); }); }); diff --git a/src/commands/status-runtime-shared.ts b/src/commands/status-runtime-shared.ts index 2b107a254cb..fabd50cea27 100644 --- a/src/commands/status-runtime-shared.ts +++ b/src/commands/status-runtime-shared.ts @@ -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), }); } diff --git a/src/commands/status.link-channel.test.ts b/src/commands/status.link-channel.test.ts index 1671a63f89e..c1e7b2585f0 100644 --- a/src/commands/status.link-channel.test.ts +++ b/src/commands/status.link-channel.test.ts @@ -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", () => ({ diff --git a/src/commands/status.link-channel.ts b/src/commands/status.link-channel.ts index 5d6f833a876..01b744c69ac 100644 --- a/src/commands/status.link-channel.ts +++ b/src/commands/status.link-channel.ts @@ -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 { - for (const plugin of listChannelPlugins()) { + for (const plugin of listReadOnlyChannelPluginsForConfig(cfg)) { const { defaultAccountId, account, enabled, configured } = await resolveDefaultChannelAccountContext(plugin, cfg, { mode: "read_only", diff --git a/src/commands/status.scan-execute.test.ts b/src/commands/status.scan-execute.test.ts index 875c8f32b7a..0c0f7b7c799 100644 --- a/src/commands/status.scan-execute.test.ts +++ b/src/commands/status.scan-execute.test.ts @@ -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, diff --git a/src/commands/status.scan-execute.ts b/src/commands/status.scan-execute.ts index 4fca852bbbf..982a1ef70eb 100644 --- a/src/commands/status.scan-execute.ts +++ b/src/commands/status.scan-execute.ts @@ -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({ diff --git a/src/commands/status.scan-overview.ts b/src/commands/status.scan-overview.ts index 78925186a21..2cbc075828b 100644 --- a/src/commands/status.scan-overview.ts +++ b/src/commands/status.scan-overview.ts @@ -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; + 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, }), ); } diff --git a/src/commands/status.scan.fast-json.test.ts b/src/commands/status.scan.fast-json.test.ts index 268872367df..102b2ae6a70 100644 --- a/src/commands/status.scan.fast-json.test.ts +++ b/src/commands/status.scan.fast-json.test.ts @@ -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); diff --git a/src/commands/status.scan.fast-json.ts b/src/commands/status.scan.fast-json.ts index 4c4bc7bb686..13053e4c431 100644 --- a/src/commands/status.scan.fast-json.ts +++ b/src/commands/status.scan.fast-json.ts @@ -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[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, diff --git a/src/commands/status.scan.test-helpers.ts b/src/commands/status.scan.test-helpers.ts index 6bf5e632a67..eb7e1327168 100644 --- a/src/commands/status.scan.test-helpers.ts +++ b/src/commands/status.scan.test-helpers.ts @@ -109,9 +109,13 @@ export function createStatusPluginRegistryModuleMock( export function createStatusPluginStatusModuleMock( mocks: Pick, -): { buildPluginCompatibilityNotices: StatusScanSharedMocks["buildPluginCompatibilityNotices"] } { +): { + buildPluginCompatibilityNotices: StatusScanSharedMocks["buildPluginCompatibilityNotices"]; + buildPluginCompatibilitySnapshotNotices: StatusScanSharedMocks["buildPluginCompatibilityNotices"]; +} { return { buildPluginCompatibilityNotices: mocks.buildPluginCompatibilityNotices, + buildPluginCompatibilitySnapshotNotices: mocks.buildPluginCompatibilityNotices, }; } diff --git a/src/commands/status.scan.test.ts b/src/commands/status.scan.test.ts index ed613fee804..2bddd125580 100644 --- a/src/commands/status.scan.test.ts +++ b/src/commands/status.scan.test.ts @@ -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(); }); }); diff --git a/src/commands/status.scan.ts b/src/commands/status.scan.ts index 74e4bfc9a1d..a58164ca4e5 100644 --- a/src/commands/status.scan.ts +++ b/src/commands/status.scan.ts @@ -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…"); diff --git a/src/commands/status.summary.test.ts b/src/commands/status.summary.test.ts index 1d31caaa1c8..e5e3348096b 100644 --- a/src/commands/status.summary.test.ts +++ b/src/commands/status.summary.test.ts @@ -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(); diff --git a/src/commands/status.summary.ts b/src/commands/status.summary.ts index 2e567d39b7e..7ba77ea14c2 100644 --- a/src/commands/status.summary.ts +++ b/src/commands/status.summary.ts @@ -105,11 +105,12 @@ export function redactSensitiveStatusSummary(summary: StatusSummary): StatusSumm export async function getStatusSummary( options: { includeSensitive?: boolean; + includeChannelSummary?: boolean; config?: OpenClawConfig; sourceConfig?: OpenClawConfig; } = {}, ): Promise { - 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), diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index 75413e76212..40c92a8f588 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -417,14 +417,18 @@ async function runGatewayHealthChecks(ctx: DoctorHealthFlowContext): Promise { - 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 { diff --git a/src/infra/channel-summary.ts b/src/infra/channel-summary.ts index c31af337515..653c521f734 100644 --- a/src/infra/channel-summary.ts +++ b/src/infra/channel-summary.ts @@ -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; diff --git a/src/infra/provider-usage.auth.normalizes-keys.test.ts b/src/infra/provider-usage.auth.normalizes-keys.test.ts index 080b5f91c3f..b3ff24fe27f 100644 --- a/src/infra/provider-usage.auth.normalizes-keys.test.ts +++ b/src/infra/provider-usage.auth.normalizes-keys.test.ts @@ -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, diff --git a/src/infra/provider-usage.auth.plugin.test.ts b/src/infra/provider-usage.auth.plugin.test.ts index 471ac76c2f5..0b5da3a19a8 100644 --- a/src/infra/provider-usage.auth.plugin.test.ts +++ b/src/infra/provider-usage.auth.plugin.test.ts @@ -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(); + }); }); diff --git a/src/infra/provider-usage.auth.ts b/src/infra/provider-usage.auth.ts index 40c26d2b854..801323a4004 100644 --- a/src/infra/provider-usage.auth.ts +++ b/src/infra/provider-usage.auth.ts @@ -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; @@ -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 { + 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 { + 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 { 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, diff --git a/src/infra/provider-usage.load.ts b/src/infra/provider-usage.load.ts index 90b2d890439..567aa9e1282 100644 --- a/src/infra/provider-usage.load.ts +++ b/src/infra/provider-usage.load.ts @@ -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: [] }; diff --git a/src/plugins/status.ts b/src/plugins/status.ts index d65eeb1046a..9af274e6662 100644 --- a/src/plugins/status.ts +++ b/src/plugins/status.ts @@ -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}`; } diff --git a/src/security/audit.ts b/src/security/audit.ts index c0241aceb70..27a4d8b5483 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -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 []; }