diff --git a/CHANGELOG.md b/CHANGELOG.md index e6709696d9d..7748e270235 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai - Auth/commands: require owner identity (an owner-candidate match or internal `operator.admin`) for owner-enforced commands instead of treating wildcard channel `allowFrom` or empty owner-candidate lists as sufficient, so non-owner senders can no longer reach owner-only commands through a permissive fallback when `enforceOwnerForCommands=true` and `commands.ownerAllowFrom` is unset. (#69774) Thanks @drobison00. - Control UI/CSP: tighten `img-src` to `'self' data:` only, and make Control UI avatar helpers drop remote `http(s)` and protocol-relative URLs so the UI falls back to the built-in logo/badge instead of issuing arbitrary remote image fetches. Same-origin avatar routes (relative paths) and `data:image/...` avatars still render. (#69773) +- CLI/channels: keep `status`, `health`, `channels list`, and `channels status` on read-only channel metadata when Telegram, Slack, Discord, or third-party channel plugins are configured, avoiding full bundled plugin runtime imports on those cold paths. Fixes #69042. (#69479) Thanks @gumadeiras. ## 2026.4.20 diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index afdc0f59c26..32b6ea28615 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -510,7 +510,7 @@ Important examples: | Field | What it means | | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | | `openclaw.extensions` | Declares native plugin entrypoints. | -| `openclaw.setupEntry` | Lightweight setup-only entrypoint used during onboarding and deferred channel startup. | +| `openclaw.setupEntry` | Lightweight setup-only entrypoint used during onboarding, deferred channel startup, and read-only channel status/SecretRef discovery. | | `openclaw.channel` | Cheap channel catalog metadata like labels, docs paths, aliases, and selection copy. | | `openclaw.channel.configuredState` | Lightweight configured-state checker metadata that can answer "does env-only setup already exist?" without loading the full channel runtime. | | `openclaw.channel.persistedAuthState` | Lightweight persisted-auth checker metadata that can answer "is anything already signed in?" without loading the full channel runtime. | @@ -524,6 +524,12 @@ Important examples: registry loading. Invalid values are rejected; newer-but-valid values skip the plugin on older hosts. +Channel plugins should provide `openclaw.setupEntry` when status, channel list, +or SecretRef scans need to identify configured accounts without loading the full +runtime. The setup entry should expose channel metadata plus setup-safe config, +status, and secrets adapters; keep network clients, gateway listeners, and +transport runtimes in the main extension entrypoint. + `openclaw.install.allowInvalidConfigRecovery` is intentionally narrow. It does not make arbitrary broken configs installable. Today it only allows install flows to recover from specific stale bundled-plugin upgrade failures, such as a diff --git a/docs/plugins/sdk-channel-plugins.md b/docs/plugins/sdk-channel-plugins.md index 8a7091af4ff..49e428757f9 100644 --- a/docs/plugins/sdk-channel-plugins.md +++ b/docs/plugins/sdk-channel-plugins.md @@ -139,6 +139,14 @@ If your channel supports env-driven setup or auth and generic startup/config flows should know those env names before runtime loads, declare them in the plugin manifest with `channelEnvVars`. Keep channel runtime `envVars` or local constants for operator-facing copy only. + +If your channel can appear in `status`, `channels list`, `channels status`, or +SecretRef scans before the plugin runtime starts, add `openclaw.setupEntry` in +`package.json`. That entrypoint should be safe to import in read-only command +paths and should return the channel metadata, setup-safe config adapter, status +adapter, and channel secret target metadata needed for those summaries. Do not +start clients, listeners, or transport runtimes from the setup entry. + `createOptionalChannelSetupWizard`, `DEFAULT_ACCOUNT_ID`, `createTopLevelChannelDmPolicy`, `setSetupChannelEnabled`, and `splitSetupEntries` diff --git a/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/security.ts b/extensions/discord/src/security.ts new file mode 100644 index 00000000000..586c9f26820 --- /dev/null +++ b/extensions/discord/src/security.ts @@ -0,0 +1,52 @@ +import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; +import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy"; +import type { ResolvedDiscordAccount } from "./accounts.js"; +import type { ChannelPlugin } from "./channel-api.js"; + +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"), +}); + +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', + }, + }); + +let discordSecurityAuditModulePromise: + | Promise + | undefined; + +async function loadDiscordSecurityAuditModule() { + discordSecurityAuditModulePromise ??= import("./security-audit.runtime.js"); + return await discordSecurityAuditModulePromise; +} + +export const discordSecurityAdapter = { + resolveDmPolicy: resolveDiscordDmPolicy, + collectWarnings: collectDiscordSecurityWarnings, + collectAuditFindings: async (params) => + (await loadDiscordSecurityAuditModule()).collectDiscordSecurityAuditFindings(params), +} satisfies NonNullable["security"]>; 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/minimax/openclaw.plugin.json b/extensions/minimax/openclaw.plugin.json index 211a25ac72c..9484fe65fb4 100644 --- a/extensions/minimax/openclaw.plugin.json +++ b/extensions/minimax/openclaw.plugin.json @@ -6,7 +6,7 @@ "autoEnableWhenConfiguredProviders": ["minimax", "minimax-portal"], "nonSecretAuthMarkers": ["minimax-oauth"], "providerAuthEnvVars": { - "minimax": ["MINIMAX_API_KEY"], + "minimax": ["MINIMAX_CODE_PLAN_KEY", "MINIMAX_CODING_API_KEY", "MINIMAX_API_KEY"], "minimax-portal": ["MINIMAX_OAUTH_TOKEN", "MINIMAX_API_KEY"] }, "providerAuthChoices": [ 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/security.ts b/extensions/slack/src/security.ts new file mode 100644 index 00000000000..6360fc8d74e --- /dev/null +++ b/extensions/slack/src/security.ts @@ -0,0 +1,43 @@ +import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; +import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy"; +import type { ResolvedSlackAccount } from "./accounts.js"; +import type { ChannelPlugin } from "./channel-api.js"; +import { collectSlackSecurityAuditFindings } from "./security-audit.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(), +}); + +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 slackSecurityAdapter = { + resolveDmPolicy: resolveSlackDmPolicy, + collectWarnings: collectSlackSecurityWarnings, + collectAuditFindings: collectSlackSecurityAuditFindings, +} satisfies NonNullable["security"]>; 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/security.ts b/extensions/telegram/src/security.ts new file mode 100644 index 00000000000..15aac107e59 --- /dev/null +++ b/extensions/telegram/src/security.ts @@ -0,0 +1,40 @@ +import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; +import type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core"; +import { createAllowlistProviderRouteAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy"; +import type { ResolvedTelegramAccount } from "./accounts.js"; +import { collectTelegramSecurityAuditFindings } from "./security-audit.js"; + +const resolveTelegramDmPolicy = createScopedDmSecurityResolver({ + channelKey: "telegram", + resolvePolicy: (account) => account.config.dmPolicy, + resolveAllowFrom: (account) => account.config.allowFrom, + policyPathSuffix: "dmPolicy", + normalizeEntry: (raw) => raw.replace(/^(telegram|tg):/i, ""), +}); + +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 telegramSecurityAdapter = { + resolveDmPolicy: resolveTelegramDmPolicy, + collectWarnings: collectTelegramSecurityWarnings, + collectAuditFindings: collectTelegramSecurityAuditFindings, +} satisfies NonNullable["security"]>; 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/agents/auth-profiles.ts b/src/agents/auth-profiles.ts index 51f50bdf1b9..ae2d36e6055 100644 --- a/src/agents/auth-profiles.ts +++ b/src/agents/auth-profiles.ts @@ -27,6 +27,7 @@ export { export { clearRuntimeAuthProfileStoreSnapshots, ensureAuthProfileStore, + ensureAuthProfileStoreWithoutExternalProfiles, hasAnyAuthProfileStoreSource, loadAuthProfileStoreForSecretsRuntime, loadAuthProfileStoreWithoutExternalProfiles, diff --git a/src/agents/auth-profiles/store.ts b/src/agents/auth-profiles/store.ts index 0fb29f683be..eff573180c6 100644 --- a/src/agents/auth-profiles/store.ts +++ b/src/agents/auth-profiles/store.ts @@ -298,23 +298,30 @@ export function loadAuthProfileStoreWithoutExternalProfiles(agentDir?: string): export function ensureAuthProfileStore( agentDir?: string, options?: { allowKeychainPrompt?: boolean }, +): AuthProfileStore { + return overlayExternalAuthProfiles( + ensureAuthProfileStoreWithoutExternalProfiles(agentDir, options), + { agentDir }, + ); +} + +export function ensureAuthProfileStoreWithoutExternalProfiles( + agentDir?: string, + options?: { allowKeychainPrompt?: boolean }, ): AuthProfileStore { const runtimeStore = resolveRuntimeAuthProfileStore(agentDir); if (runtimeStore) { - return overlayExternalAuthProfiles(runtimeStore, { agentDir }); + return runtimeStore; } - const store = loadAuthProfileStoreForAgent(agentDir, options); const authPath = resolveAuthStorePath(agentDir); const mainAuthPath = resolveAuthStorePath(); if (!agentDir || authPath === mainAuthPath) { - return overlayExternalAuthProfiles(store, { agentDir }); + return store; } const mainStore = loadAuthProfileStoreForAgent(undefined, options); - const merged = mergeAuthProfileStores(mainStore, store); - - return overlayExternalAuthProfiles(merged, { agentDir }); + return mergeAuthProfileStores(mainStore, store); } export function findPersistedAuthProfileCredential(params: { diff --git a/src/channels/plugins/read-only.test.ts b/src/channels/plugins/read-only.test.ts new file mode 100644 index 00000000000..a8c61df5116 --- /dev/null +++ b/src/channels/plugins/read-only.test.ts @@ -0,0 +1,460 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterAll, afterEach, describe, expect, it } from "vitest"; +import { + cleanupPluginLoaderFixturesForTest, + EMPTY_PLUGIN_SCHEMA, + makeTempDir, + resetPluginLoaderTestStateForTest, + useNoBundledPlugins, +} from "../../plugins/loader.test-fixtures.js"; +import { listReadOnlyChannelPluginsForConfig } from "./read-only.js"; + +function writeExternalSetupChannelPlugin( + options: { + setupEntry?: boolean; + pluginDir?: string; + pluginId?: string; + channelId?: string; + manifestChannelIds?: string[]; + setupChannelId?: string; + } = {}, +) { + useNoBundledPlugins(); + const pluginDir = options.pluginDir ?? makeTempDir(); + const pluginId = options.pluginId ?? "external-chat"; + const channelId = options.channelId ?? "external-chat"; + const manifestChannelIds = options.manifestChannelIds ?? [channelId]; + const setupChannelId = options.setupChannelId ?? channelId; + const fullMarker = path.join(pluginDir, "full-loaded.txt"); + const setupMarker = path.join(pluginDir, "setup-loaded.txt"); + const setupEntry = options.setupEntry !== false; + + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify( + { + name: `@example/openclaw-${pluginId}`, + version: "1.0.0", + openclaw: { + extensions: ["./index.cjs"], + ...(setupEntry ? { setupEntry: "./setup-entry.cjs" } : {}), + }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify( + { + id: pluginId, + configSchema: EMPTY_PLUGIN_SCHEMA, + channels: manifestChannelIds, + channelEnvVars: { + [channelId]: ["EXTERNAL_CHAT_TOKEN"], + }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "index.cjs"), + `require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8"); +module.exports = { + id: ${JSON.stringify(pluginId)}, + register(api) { + api.registerChannel({ + plugin: { + id: ${JSON.stringify(channelId)}, + meta: { + id: ${JSON.stringify(channelId)}, + label: "External Chat", + selectionLabel: "External Chat", + docsPath: ${JSON.stringify(`/channels/${channelId}`)}, + blurb: "full entry", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => ["default"], + resolveAccount: (cfg) => ({ + accountId: "default", + token: cfg.channels?.[${JSON.stringify(channelId)}]?.token ?? "configured", + }), + }, + outbound: { deliveryMode: "direct" }, + secrets: { + secretTargetRegistryEntries: [ + { + id: ${JSON.stringify(`channels.${channelId}.token`)}, + targetType: "channel", + configFile: "openclaw.json", + pathPattern: ${JSON.stringify(`channels.${channelId}.token`)}, + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, + ], + }, + }, + }); + }, +};`, + "utf-8", + ); + if (setupEntry) { + fs.writeFileSync( + path.join(pluginDir, "setup-entry.cjs"), + `require("node:fs").writeFileSync(${JSON.stringify(setupMarker)}, "loaded", "utf-8"); +module.exports = { + plugin: { + id: ${JSON.stringify(setupChannelId)}, + meta: { + id: ${JSON.stringify(setupChannelId)}, + label: "External Chat", + selectionLabel: "External Chat", + docsPath: ${JSON.stringify(`/channels/${setupChannelId}`)}, + blurb: "setup entry", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => ["default"], + resolveAccount: (cfg) => ({ + accountId: "default", + token: cfg.channels?.[${JSON.stringify(setupChannelId)}]?.token ?? "configured", + }), + }, + outbound: { deliveryMode: "direct" }, + secrets: { + secretTargetRegistryEntries: [ + { + id: ${JSON.stringify(`channels.${setupChannelId}.token`)}, + targetType: "channel", + configFile: "openclaw.json", + pathPattern: ${JSON.stringify(`channels.${setupChannelId}.token`)}, + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, + ], + }, + }, +};`, + "utf-8", + ); + } + + return { pluginDir, fullMarker, setupMarker }; +} + +afterEach(() => { + resetPluginLoaderTestStateForTest(); +}); + +afterAll(() => { + cleanupPluginLoaderFixturesForTest(); +}); + +describe("listReadOnlyChannelPluginsForConfig", () => { + it("loads configured external channel setup metadata without importing full runtime", () => { + const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin(); + const plugins = listReadOnlyChannelPluginsForConfig( + { + channels: { + "external-chat": { token: "configured" }, + }, + plugins: { + load: { paths: [pluginDir] }, + allow: ["external-chat"], + }, + } as never, + { + env: { ...process.env }, + includePersistedAuthState: false, + }, + ); + + const plugin = plugins.find((entry) => entry.id === "external-chat"); + expect(plugin?.meta.blurb).toBe("setup entry"); + expect( + plugin?.secrets?.secretTargetRegistryEntries?.some( + (entry) => entry.id === "channels.external-chat.token", + ), + ).toBe(true); + expect(fs.existsSync(setupMarker)).toBe(true); + expect(fs.existsSync(fullMarker)).toBe(false); + }); + + it("matches setup-only plugins by manifest-owned channel ids when plugin id differs", () => { + const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin({ + pluginId: "external-chat-plugin", + channelId: "external-chat", + setupChannelId: "external-chat-plugin", + }); + const plugins = listReadOnlyChannelPluginsForConfig( + { + channels: { + "external-chat": { token: "configured" }, + }, + plugins: { + load: { paths: [pluginDir] }, + allow: ["external-chat-plugin"], + }, + } as never, + { + env: { ...process.env }, + includePersistedAuthState: false, + }, + ); + + const plugin = plugins.find((entry) => entry.id === "external-chat"); + expect(plugin?.meta.id).toBe("external-chat"); + expect(plugin?.meta.blurb).toBe("setup entry"); + expect(fs.existsSync(setupMarker)).toBe(true); + expect(fs.existsSync(fullMarker)).toBe(false); + }); + + it("clones setup-only plugins for every configured owned channel when setup id matches one channel", () => { + const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin({ + pluginId: "external-chat-plugin", + channelId: "alpha-chat", + manifestChannelIds: ["alpha-chat", "beta-chat"], + setupChannelId: "alpha-chat", + }); + const plugins = listReadOnlyChannelPluginsForConfig( + { + channels: { + "alpha-chat": { token: "configured" }, + "beta-chat": { token: "configured" }, + }, + plugins: { + load: { paths: [pluginDir] }, + allow: ["external-chat-plugin"], + }, + } as never, + { + env: { ...process.env }, + includePersistedAuthState: false, + }, + ); + + const alphaPlugin = plugins.find((entry) => entry.id === "alpha-chat"); + const betaPlugin = plugins.find((entry) => entry.id === "beta-chat"); + expect(alphaPlugin?.meta.id).toBe("alpha-chat"); + expect(betaPlugin?.meta.id).toBe("beta-chat"); + expect(alphaPlugin?.meta.blurb).toBe("setup entry"); + expect(betaPlugin?.meta.blurb).toBe("setup entry"); + expect( + betaPlugin?.secrets?.secretTargetRegistryEntries?.some( + (entry) => entry.id === "channels.beta-chat.token", + ), + ).toBe(true); + expect( + betaPlugin?.config.resolveAccount({ + channels: { + "alpha-chat": { token: "alpha-token" }, + "beta-chat": { token: "beta-token" }, + }, + } as never), + ).toMatchObject({ token: "beta-token" }); + expect(fs.existsSync(setupMarker)).toBe(true); + expect(fs.existsSync(fullMarker)).toBe(false); + }); + + it("clones setup-only plugins when only another owned channel is configured", () => { + const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin({ + pluginId: "external-chat-plugin", + channelId: "alpha-chat", + manifestChannelIds: ["alpha-chat", "beta-chat"], + setupChannelId: "alpha-chat", + }); + const plugins = listReadOnlyChannelPluginsForConfig( + { + channels: { + "beta-chat": { token: "beta-token" }, + }, + plugins: { + load: { paths: [pluginDir] }, + allow: ["external-chat-plugin"], + }, + } as never, + { + env: { ...process.env }, + includePersistedAuthState: false, + }, + ); + + expect(plugins.some((entry) => entry.id === "alpha-chat")).toBe(false); + const betaPlugin = plugins.find((entry) => entry.id === "beta-chat"); + expect(betaPlugin?.meta.id).toBe("beta-chat"); + expect( + betaPlugin?.secrets?.secretTargetRegistryEntries?.some( + (entry) => entry.id === "channels.beta-chat.token", + ), + ).toBe(true); + expect( + betaPlugin?.config.resolveAccount({ + channels: { + "beta-chat": { token: "beta-token" }, + }, + } as never), + ).toMatchObject({ token: "beta-token" }); + expect(fs.existsSync(setupMarker)).toBe(true); + expect(fs.existsSync(fullMarker)).toBe(false); + }); + + it("keeps configured external channels visible when no setup entry exists", () => { + const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin({ + setupEntry: false, + }); + const plugins = listReadOnlyChannelPluginsForConfig( + { + channels: { + "external-chat": { token: "configured" }, + }, + plugins: { + load: { paths: [pluginDir] }, + allow: ["external-chat"], + }, + } as never, + { + env: { ...process.env }, + includePersistedAuthState: false, + }, + ); + + const plugin = plugins.find((entry) => entry.id === "external-chat"); + expect(plugin).toBeUndefined(); + expect(fs.existsSync(setupMarker)).toBe(false); + expect(fs.existsSync(fullMarker)).toBe(false); + }); + + it("uses external channel env vars as read-only configuration triggers", () => { + const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin({ + pluginId: "external-chat-plugin", + channelId: "external-chat", + }); + const plugins = listReadOnlyChannelPluginsForConfig( + { + plugins: { + load: { paths: [pluginDir] }, + allow: ["external-chat-plugin"], + }, + } as never, + { + env: { ...process.env, EXTERNAL_CHAT_TOKEN: "configured" }, + includePersistedAuthState: false, + }, + ); + + const plugin = plugins.find((entry) => entry.id === "external-chat"); + expect(plugin?.meta.blurb).toBe("setup entry"); + expect( + plugin?.secrets?.secretTargetRegistryEntries?.some( + (entry) => entry.id === "channels.external-chat.token", + ), + ).toBe(true); + expect(fs.existsSync(setupMarker)).toBe(true); + expect(fs.existsSync(fullMarker)).toBe(false); + }); + + it("accepts option-like env keys through the explicit env option", () => { + const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin({ + pluginId: "external-chat-plugin", + channelId: "external-chat", + }); + const plugins = listReadOnlyChannelPluginsForConfig( + { + plugins: { + load: { paths: [pluginDir] }, + allow: ["external-chat-plugin"], + }, + } as never, + { + env: { + ...process.env, + cache: "true", + env: "prod", + EXTERNAL_CHAT_TOKEN: "configured", + workspaceDir: "workspace-env-value", + }, + }, + ); + + const plugin = plugins.find((entry) => entry.id === "external-chat"); + expect(plugin?.meta.blurb).toBe("setup entry"); + expect(fs.existsSync(setupMarker)).toBe(true); + expect(fs.existsSync(fullMarker)).toBe(false); + }); + + it("discovers trusted external channel plugins from the default agent workspace", () => { + const workspaceDir = makeTempDir(); + const pluginDir = path.join(workspaceDir, ".openclaw", "extensions", "external-chat-plugin"); + fs.mkdirSync(pluginDir, { recursive: true }); + const { fullMarker, setupMarker } = writeExternalSetupChannelPlugin({ + pluginDir, + pluginId: "external-chat-plugin", + channelId: "external-chat", + }); + const plugins = listReadOnlyChannelPluginsForConfig( + { + agents: { + defaults: { + workspace: workspaceDir, + }, + }, + channels: { + "external-chat": { token: "configured" }, + }, + plugins: { + allow: ["external-chat-plugin"], + }, + } as never, + { + env: { ...process.env }, + includePersistedAuthState: false, + }, + ); + + const plugin = plugins.find((entry) => entry.id === "external-chat"); + expect(plugin?.meta.blurb).toBe("setup entry"); + expect(fs.existsSync(setupMarker)).toBe(true); + expect(fs.existsSync(fullMarker)).toBe(false); + }); + + it("ignores external setup plugins that export an unrequested channel id", () => { + const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin({ + pluginId: "external-chat-plugin", + channelId: "external-chat", + manifestChannelIds: ["external-chat"], + setupChannelId: "spoofed-chat", + }); + const plugins = listReadOnlyChannelPluginsForConfig( + { + channels: { + "external-chat": { token: "configured" }, + }, + plugins: { + load: { paths: [pluginDir] }, + allow: ["external-chat-plugin"], + }, + } as never, + { + env: { ...process.env }, + }, + ); + + expect(plugins.some((entry) => entry.id === "spoofed-chat")).toBe(false); + expect(plugins.some((entry) => entry.id === "external-chat")).toBe(false); + expect(fs.existsSync(setupMarker)).toBe(true); + expect(fs.existsSync(fullMarker)).toBe(false); + }); +}); diff --git a/src/channels/plugins/read-only.ts b/src/channels/plugins/read-only.ts new file mode 100644 index 00000000000..7bbe4ebc0d5 --- /dev/null +++ b/src/channels/plugins/read-only.ts @@ -0,0 +1,436 @@ +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { + listConfiguredChannelIdsForReadOnlyScope, + resolveDiscoverableScopedChannelPluginIds, +} from "../../plugins/channel-plugin-ids.js"; +import { loadOpenClawPlugins } from "../../plugins/loader.js"; +import { + loadPluginManifestRegistry, + type PluginManifestRecord, +} from "../../plugins/manifest-registry.js"; +import { getBundledChannelSetupPlugin } from "./bundled.js"; +import { listChannelPlugins } from "./registry.js"; +import type { ChannelPlugin } from "./types.plugin.js"; + +type ReadOnlyChannelPluginOptions = { + env?: NodeJS.ProcessEnv; + workspaceDir?: string; + activationSourceConfig?: OpenClawConfig; + includePersistedAuthState?: boolean; + cache?: boolean; +}; + +type ReadOnlyChannelPluginResolution = { + plugins: ChannelPlugin[]; + configuredChannelIds: string[]; + missingConfiguredChannelIds: string[]; +}; + +function addChannelPlugins( + byId: Map, + plugins: Iterable, + options?: { + onlyIds?: ReadonlySet; + allowOverwrite?: boolean; + }, +): void { + for (const plugin of plugins) { + if (!plugin) { + continue; + } + if (options?.onlyIds && !options.onlyIds.has(plugin.id)) { + continue; + } + if (options?.allowOverwrite === false && byId.has(plugin.id)) { + continue; + } + byId.set(plugin.id, plugin); + } +} + +function rebindChannelScopedString( + value: string, + sourceChannelId: string, + targetChannelId: string, +): string { + const sourcePrefix = `channels.${sourceChannelId}`; + if (value === sourcePrefix) { + return `channels.${targetChannelId}`; + } + if (value.startsWith(`${sourcePrefix}.`)) { + return `channels.${targetChannelId}${value.slice(sourcePrefix.length)}`; + } + return value; +} + +function rebindChannelConfig( + cfg: OpenClawConfig, + sourceChannelId: string, + targetChannelId: string, +): OpenClawConfig { + if (sourceChannelId === targetChannelId || !cfg.channels) { + return cfg; + } + return { + ...cfg, + channels: { + ...cfg.channels, + [sourceChannelId]: (cfg.channels as Record)[targetChannelId], + }, + }; +} + +function restoreReboundChannelConfig(params: { + original: OpenClawConfig; + updated: OpenClawConfig; + sourceChannelId: string; + targetChannelId: string; +}): OpenClawConfig { + if (params.sourceChannelId === params.targetChannelId || !params.updated.channels) { + return params.updated; + } + const nextChannels = { ...params.updated.channels }; + if (Object.prototype.hasOwnProperty.call(nextChannels, params.sourceChannelId)) { + nextChannels[params.targetChannelId] = nextChannels[params.sourceChannelId]; + } else { + delete nextChannels[params.targetChannelId]; + } + if ( + params.original.channels && + Object.prototype.hasOwnProperty.call(params.original.channels, params.sourceChannelId) + ) { + nextChannels[params.sourceChannelId] = params.original.channels[params.sourceChannelId]; + } else { + delete nextChannels[params.sourceChannelId]; + } + return { + ...params.updated, + channels: nextChannels, + }; +} + +function rebindChannelPluginConfig( + config: ChannelPlugin["config"], + sourceChannelId: string, + targetChannelId: string, +): ChannelPlugin["config"] { + const rebind = (cfg: OpenClawConfig) => + rebindChannelConfig(cfg, sourceChannelId, targetChannelId); + return { + ...config, + listAccountIds: (cfg) => config.listAccountIds(rebind(cfg)), + resolveAccount: (cfg, accountId) => config.resolveAccount(rebind(cfg), accountId), + inspectAccount: config.inspectAccount + ? (cfg, accountId) => config.inspectAccount?.(rebind(cfg), accountId) + : undefined, + defaultAccountId: config.defaultAccountId + ? (cfg) => config.defaultAccountId?.(rebind(cfg)) ?? "" + : undefined, + setAccountEnabled: config.setAccountEnabled + ? (params) => + restoreReboundChannelConfig({ + original: params.cfg, + updated: + config.setAccountEnabled?.({ ...params, cfg: rebind(params.cfg) }) ?? params.cfg, + sourceChannelId, + targetChannelId, + }) + : undefined, + deleteAccount: config.deleteAccount + ? (params) => + restoreReboundChannelConfig({ + original: params.cfg, + updated: config.deleteAccount?.({ ...params, cfg: rebind(params.cfg) }) ?? params.cfg, + sourceChannelId, + targetChannelId, + }) + : undefined, + isEnabled: config.isEnabled + ? (account, cfg) => config.isEnabled?.(account, rebind(cfg)) ?? false + : undefined, + disabledReason: config.disabledReason + ? (account, cfg) => config.disabledReason?.(account, rebind(cfg)) ?? "" + : undefined, + isConfigured: config.isConfigured + ? (account, cfg) => config.isConfigured?.(account, rebind(cfg)) ?? false + : undefined, + unconfiguredReason: config.unconfiguredReason + ? (account, cfg) => config.unconfiguredReason?.(account, rebind(cfg)) ?? "" + : undefined, + describeAccount: config.describeAccount + ? (account, cfg) => config.describeAccount!(account, rebind(cfg)) + : undefined, + resolveAllowFrom: config.resolveAllowFrom + ? (params) => config.resolveAllowFrom?.({ ...params, cfg: rebind(params.cfg) }) + : undefined, + formatAllowFrom: config.formatAllowFrom + ? (params) => config.formatAllowFrom?.({ ...params, cfg: rebind(params.cfg) }) ?? [] + : undefined, + hasConfiguredState: config.hasConfiguredState + ? (params) => config.hasConfiguredState?.({ ...params, cfg: rebind(params.cfg) }) ?? false + : undefined, + hasPersistedAuthState: config.hasPersistedAuthState + ? (params) => config.hasPersistedAuthState?.({ ...params, cfg: rebind(params.cfg) }) ?? false + : undefined, + resolveDefaultTo: config.resolveDefaultTo + ? (params) => config.resolveDefaultTo?.({ ...params, cfg: rebind(params.cfg) }) + : undefined, + }; +} + +function rebindChannelPluginSecrets( + secrets: ChannelPlugin["secrets"], + sourceChannelId: string, + targetChannelId: string, +): ChannelPlugin["secrets"] { + if (!secrets) { + return undefined; + } + return { + ...secrets, + secretTargetRegistryEntries: secrets.secretTargetRegistryEntries?.map((entry) => ({ + ...entry, + id: rebindChannelScopedString(entry.id, sourceChannelId, targetChannelId), + pathPattern: rebindChannelScopedString(entry.pathPattern, sourceChannelId, targetChannelId), + ...(entry.refPathPattern + ? { + refPathPattern: rebindChannelScopedString( + entry.refPathPattern, + sourceChannelId, + targetChannelId, + ), + } + : {}), + })), + unsupportedSecretRefSurfacePatterns: secrets.unsupportedSecretRefSurfacePatterns?.map( + (pattern) => rebindChannelScopedString(pattern, sourceChannelId, targetChannelId), + ), + collectRuntimeConfigAssignments: secrets.collectRuntimeConfigAssignments + ? (params) => + secrets.collectRuntimeConfigAssignments?.({ + ...params, + config: rebindChannelConfig(params.config, sourceChannelId, targetChannelId), + }) + : undefined, + }; +} + +function cloneChannelPluginForChannelId(plugin: ChannelPlugin, channelId: string): ChannelPlugin { + if (plugin.id === channelId && plugin.meta.id === channelId) { + return plugin; + } + const sourceChannelId = plugin.id; + return { + ...plugin, + id: channelId, + meta: { + ...plugin.meta, + id: channelId, + }, + config: rebindChannelPluginConfig(plugin.config, sourceChannelId, channelId), + secrets: rebindChannelPluginSecrets(plugin.secrets, sourceChannelId, channelId), + }; +} + +function addSetupChannelPlugins( + byId: Map, + setups: Iterable<{ + pluginId: string; + plugin: ChannelPlugin; + }>, + options: { + ownedChannelIdsByPluginId: ReadonlyMap; + ownedMissingChannelIdsByPluginId: ReadonlyMap; + }, +): void { + for (const setup of setups) { + const ownedMissingChannelIds = options.ownedMissingChannelIdsByPluginId.get(setup.pluginId); + if (!ownedMissingChannelIds || ownedMissingChannelIds.length === 0) { + continue; + } + if (ownedMissingChannelIds.includes(setup.plugin.id)) { + addChannelPlugins(byId, [setup.plugin], { + onlyIds: new Set(ownedMissingChannelIds), + allowOverwrite: false, + }); + addChannelPlugins( + byId, + ownedMissingChannelIds + .filter((channelId) => channelId !== setup.plugin.id) + .map((channelId) => cloneChannelPluginForChannelId(setup.plugin, channelId)), + { + onlyIds: new Set(ownedMissingChannelIds), + allowOverwrite: false, + }, + ); + continue; + } + const ownedChannelIds = options.ownedChannelIdsByPluginId.get(setup.pluginId) ?? []; + if (setup.plugin.id !== setup.pluginId && !ownedChannelIds.includes(setup.plugin.id)) { + continue; + } + addChannelPlugins( + byId, + ownedMissingChannelIds.map((channelId) => + cloneChannelPluginForChannelId(setup.plugin, channelId), + ), + { + onlyIds: new Set(ownedMissingChannelIds), + allowOverwrite: false, + }, + ); + } +} + +function resolveReadOnlyWorkspaceDir( + cfg: OpenClawConfig, + options: ReadOnlyChannelPluginOptions, +): string | undefined { + return options.workspaceDir ?? resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)); +} + +function listExternalChannelManifestRecords(params: { + cfg: OpenClawConfig; + workspaceDir?: string; + env: NodeJS.ProcessEnv; + cache?: boolean; +}): PluginManifestRecord[] { + return loadPluginManifestRegistry({ + config: params.cfg, + workspaceDir: params.workspaceDir, + env: params.env, + cache: params.cache, + }).plugins.filter((plugin) => plugin.origin !== "bundled" && plugin.channels.length > 0); +} + +function resolveExternalReadOnlyChannelPluginIds(params: { + cfg: OpenClawConfig; + activationSourceConfig?: OpenClawConfig; + channelIds: readonly string[]; + records: readonly PluginManifestRecord[]; + workspaceDir?: string; + env: NodeJS.ProcessEnv; + cache?: boolean; +}): string[] { + if (params.channelIds.length === 0) { + return []; + } + const candidatePluginIds = resolveDiscoverableScopedChannelPluginIds({ + config: params.cfg, + activationSourceConfig: params.activationSourceConfig, + channelIds: params.channelIds, + workspaceDir: params.workspaceDir, + env: params.env, + cache: params.cache, + }); + if (candidatePluginIds.length === 0) { + return []; + } + + const requestedChannelIds = new Set(params.channelIds); + const candidatePluginIdSet = new Set(candidatePluginIds); + return params.records + .filter( + (plugin) => + candidatePluginIdSet.has(plugin.id) && + plugin.channels.some((channelId) => requestedChannelIds.has(channelId)), + ) + .map((plugin) => plugin.id) + .toSorted((left, right) => left.localeCompare(right)); +} + +export function listReadOnlyChannelPluginsForConfig( + cfg: OpenClawConfig, + options?: ReadOnlyChannelPluginOptions, +): ChannelPlugin[] { + return resolveReadOnlyChannelPluginsForConfig(cfg, options).plugins; +} + +export function resolveReadOnlyChannelPluginsForConfig( + cfg: OpenClawConfig, + options: ReadOnlyChannelPluginOptions = {}, +): ReadOnlyChannelPluginResolution { + const env = options.env ?? process.env; + const workspaceDir = resolveReadOnlyWorkspaceDir(cfg, options); + const externalManifestRecords = listExternalChannelManifestRecords({ + cfg, + workspaceDir, + env, + cache: options.cache, + }); + const configuredChannelIds = [ + ...new Set( + listConfiguredChannelIdsForReadOnlyScope({ + config: cfg, + activationSourceConfig: options.activationSourceConfig ?? cfg, + workspaceDir, + env, + cache: options.cache, + includePersistedAuthState: options.includePersistedAuthState, + manifestRecords: externalManifestRecords, + }), + ), + ]; + const byId = new Map(); + + addChannelPlugins(byId, listChannelPlugins()); + + for (const channelId of configuredChannelIds) { + if (byId.has(channelId)) { + continue; + } + addChannelPlugins(byId, [getBundledChannelSetupPlugin(channelId)]); + } + + const missingConfiguredChannelIds = configuredChannelIds.filter( + (channelId) => !byId.has(channelId), + ); + const externalPluginIds = resolveExternalReadOnlyChannelPluginIds({ + cfg, + activationSourceConfig: options.activationSourceConfig ?? cfg, + channelIds: missingConfiguredChannelIds, + records: externalManifestRecords, + workspaceDir, + env, + cache: options.cache, + }); + if (externalPluginIds.length > 0) { + const missingChannelIdSet = new Set(missingConfiguredChannelIds); + const externalPluginIdSet = new Set(externalPluginIds); + const ownedChannelIdsByPluginId = new Map( + externalManifestRecords + .filter((record) => externalPluginIdSet.has(record.id)) + .map((record) => [record.id, record.channels] as const), + ); + const ownedMissingChannelIdsByPluginId = new Map( + [...ownedChannelIdsByPluginId].map( + ([pluginId, channelIds]) => + [pluginId, channelIds.filter((channelId) => missingChannelIdSet.has(channelId))] as const, + ), + ); + const registry = loadOpenClawPlugins({ + config: cfg, + activationSourceConfig: options.activationSourceConfig ?? cfg, + env, + workspaceDir, + cache: false, + activate: false, + includeSetupOnlyChannelPlugins: true, + forceSetupOnlyChannelPlugins: true, + requireSetupEntryForSetupOnlyChannelPlugins: true, + onlyPluginIds: externalPluginIds, + }); + addSetupChannelPlugins(byId, registry.channelSetups, { + ownedChannelIdsByPluginId, + ownedMissingChannelIdsByPluginId, + }); + } + + const plugins = [...byId.values()]; + return { + plugins, + configuredChannelIds, + missingConfiguredChannelIds: configuredChannelIds.filter((channelId) => !byId.has(channelId)), + }; +} 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..168100aa9f3 100644 --- a/src/cli/command-secret-targets.import.test.ts +++ b/src/cli/command-secret-targets.import.test.ts @@ -27,4 +27,110 @@ 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 listReadOnlyChannelPluginsForConfig = vi.fn(() => [ + { + id: "telegram", + secrets: { + 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, + }, + { + id: "channels.telegram.gatewayToken", + targetType: "gateway.auth.token", + configFile: "openclaw.json", + pathPattern: "gateway.auth.token", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, + { + id: "channels.telegram.gatewayTokenRef", + targetType: "channels.telegram.gatewayTokenRef", + configFile: "openclaw.json", + pathPattern: "channels.telegram.gatewayToken", + refPathPattern: "gateway.auth.token", + secretShape: "sibling_ref", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, + { + id: "channels.discord.token", + targetType: "channels.discord.token", + configFile: "openclaw.json", + pathPattern: "channels.discord.token", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, + ], + }, + }, + { + id: "external-chat", + secrets: { + secretTargetRegistryEntries: [ + { + id: "channels.external-chat.token", + targetType: "channels.external-chat.token", + configFile: "openclaw.json", + pathPattern: "channels.external-chat.token", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, + ], + }, + }, + ]); + + vi.doMock("../secrets/target-registry.js", () => ({ + discoverConfigSecretTargetsByIds: vi.fn(() => []), + listSecretTargetRegistryEntries, + })); + vi.doMock("../channels/plugins/read-only.js", () => ({ + listReadOnlyChannelPluginsForConfig, + })); + + const mod = await import("./command-secret-targets.js"); + const targets = mod.getStatusCommandSecretTargetIds({ + channels: { + "external-chat": { token: "configured" }, + telegram: { botToken: "123456:ABCDEF" }, + }, + }); + + expect(targets.has("channels.external-chat.token")).toBe(true); + expect(targets.has("channels.telegram.botToken")).toBe(true); + expect(targets.has("channels.discord.token")).toBe(false); + expect(targets.has("channels.telegram.gatewayToken")).toBe(false); + expect(targets.has("channels.telegram.gatewayTokenRef")).toBe(false); + expect(targets.has("agents.defaults.memorySearch.remote.apiKey")).toBe(true); + expect(listReadOnlyChannelPluginsForConfig).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ includePersistedAuthState: false }), + ); + expect(listSecretTargetRegistryEntries).not.toHaveBeenCalled(); + }); }); diff --git a/src/cli/command-secret-targets.ts b/src/cli/command-secret-targets.ts index 5e419d81a58..758a09c367e 100644 --- a/src/cli/command-secret-targets.ts +++ b/src/cli/command-secret-targets.ts @@ -1,3 +1,4 @@ +import { listReadOnlyChannelPluginsForConfig } from "../channels/plugins/read-only.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeOptionalAccountId } from "../routing/session-key.js"; import { @@ -73,6 +74,48 @@ function getChannelSecretTargetIds(): string[] { return cachedChannelSecretTargetIds; } +function isScopedChannelSecretTargetEntry(params: { + entry: { + id: string; + configFile?: string; + pathPattern?: string; + refPathPattern?: string; + }; + pluginChannelId: string; +}): boolean { + const channelId = normalizeOptionalString(params.pluginChannelId); + if (!channelId) { + return false; + } + const allowedPrefix = `channels.${channelId}.`; + return ( + params.entry.id.startsWith(allowedPrefix) && + params.entry.configFile === "openclaw.json" && + typeof params.entry.pathPattern === "string" && + params.entry.pathPattern.startsWith(allowedPrefix) && + (params.entry.refPathPattern === undefined || + params.entry.refPathPattern.startsWith(allowedPrefix)) + ); +} + +function getConfiguredChannelSecretTargetIds( + config: OpenClawConfig, + env: NodeJS.ProcessEnv = process.env, +): string[] { + const targetIds = new Set(); + for (const plugin of listReadOnlyChannelPluginsForConfig(config, { + env, + includePersistedAuthState: false, + })) { + for (const entry of plugin.secrets?.secretTargetRegistryEntries ?? []) { + if (isScopedChannelSecretTargetEntry({ entry, pluginChannelId: plugin.id })) { + targetIds.add(entry.id); + } + } + } + return [...targetIds].toSorted((left, right) => left.localeCompare(right)); +} + function buildCommandSecretTargets(): CommandSecretTargets { const channelTargetIds = getChannelSecretTargetIds(); return { @@ -155,6 +198,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 +218,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..e9d44f3e0a3 100644 --- a/src/commands/channels.status.command-flow.test.ts +++ b/src/commands/channels.status.command-flow.test.ts @@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({ readConfigFileSnapshot: vi.fn(async () => ({ path: "/tmp/openclaw.json" })), requireValidConfigSnapshot: vi.fn(), listChannelPlugins: vi.fn(), + listConfiguredChannelIdsForReadOnlyScope: vi.fn((_params: unknown) => ["discord"]), withProgress: vi.fn(async (_opts: unknown, run: () => Promise) => await run()), })); @@ -33,6 +34,11 @@ vi.mock("../config/config.js", () => ({ readConfigFileSnapshot: () => mocks.readConfigFileSnapshot(), })); +vi.mock("../plugins/channel-plugin-ids.js", () => ({ + listConfiguredChannelIdsForReadOnlyScope: (params: unknown) => + mocks.listConfiguredChannelIdsForReadOnlyScope(params), +})); + vi.mock("./channels/shared.js", () => ({ requireValidConfigSnapshot: (runtime: unknown) => mocks.requireValidConfigSnapshot(runtime), formatChannelAccountLabel: ({ @@ -82,6 +88,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 +129,7 @@ vi.mock("../channels/plugins/status.js", () => ({ })); vi.mock("../cli/command-secret-targets.js", () => ({ - getChannelsCommandSecretTargetIds: () => [], + getConfiguredChannelsCommandSecretTargetIds: () => [], })); vi.mock("../infra/channels-status-issues.js", () => ({ @@ -184,6 +194,8 @@ describe("channelsStatusCommand SecretRef fallback flow", () => { mocks.readConfigFileSnapshot.mockClear(); mocks.requireValidConfigSnapshot.mockReset(); mocks.listChannelPlugins.mockReset(); + mocks.listConfiguredChannelIdsForReadOnlyScope.mockClear(); + mocks.listConfiguredChannelIdsForReadOnlyScope.mockReturnValue(["discord"]); mocks.withProgress.mockClear(); mocks.listChannelPlugins.mockReturnValue([createTokenOnlyPlugin()]); }); @@ -237,4 +249,51 @@ 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 timeout after 3000ms", + "Gateway target: wss://user:pass@gateway.example.com/socket?token=secret-token&keep=visible", + "Gateway fallback: (wss://fallback-user:fallback-pass@[bad-host/socket?token=fallback-secret&keep=visible)", + "Source: env OPENCLAW_GATEWAY_URL", + ].join("\n"), + ), + ); + mocks.requireValidConfigSnapshot.mockResolvedValue({ secretResolved: false, channels: {} }); + mocks.resolveCommandConfigWithSecrets.mockResolvedValue({ + resolvedConfig: { secretResolved: true, channels: {} }, + effectiveConfig: { secretResolved: true, channels: {} }, + diagnostics: [], + }); + const { runtime, logs, errors } = createRuntimeCapture(); + + await channelsStatusCommand({ json: true, probe: false }, runtime as never); + + expect(mocks.listChannelPlugins).not.toHaveBeenCalled(); + expect(mocks.listConfiguredChannelIdsForReadOnlyScope).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ secretResolved: true }), + includePersistedAuthState: false, + }), + ); + const payload = JSON.parse(logs.at(-1) ?? "{}"); + expect(errors.join("\n")).not.toContain("user:pass"); + expect(errors.join("\n")).not.toContain("secret-token"); + expect(errors.join("\n")).not.toContain("fallback-user:fallback-pass"); + expect(errors.join("\n")).not.toContain("fallback-secret"); + expect(payload.error).toContain("Gateway target:"); + expect(payload.error).not.toContain("user:pass"); + expect(payload.error).not.toContain("secret-token"); + expect(payload.error).not.toContain("fallback-user:fallback-pass"); + expect(payload.error).not.toContain("fallback-secret"); + expect(payload).toEqual( + expect.objectContaining({ + gatewayReachable: false, + configOnly: true, + configuredChannels: ["discord"], + }), + ); + }); }); diff --git a/src/commands/channels.status.external-env.test.ts b/src/commands/channels.status.external-env.test.ts new file mode 100644 index 00000000000..94b97386bc6 --- /dev/null +++ b/src/commands/channels.status.external-env.test.ts @@ -0,0 +1,150 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + cleanupPluginLoaderFixturesForTest, + EMPTY_PLUGIN_SCHEMA, + makeTempDir, + resetPluginLoaderTestStateForTest, + useNoBundledPlugins, +} from "../plugins/loader.test-fixtures.js"; +import { withEnvAsync } from "../test-utils/env.js"; +import { channelsStatusCommand } from "./channels/status.js"; + +const mocks = vi.hoisted(() => ({ + callGateway: vi.fn(), + readConfigFileSnapshot: vi.fn(async () => ({ path: "/tmp/openclaw.json" })), + requireValidConfigSnapshot: vi.fn(), + resolveCommandConfigWithSecrets: vi.fn(), +})); + +vi.mock("../gateway/call.js", () => ({ + callGateway: (opts: unknown) => mocks.callGateway(opts), +})); + +vi.mock("../config/config.js", () => ({ + readConfigFileSnapshot: () => mocks.readConfigFileSnapshot(), +})); + +vi.mock("../cli/command-config-resolution.js", () => ({ + resolveCommandConfigWithSecrets: (opts: unknown) => mocks.resolveCommandConfigWithSecrets(opts), +})); + +vi.mock("./channels/shared.js", () => ({ + requireValidConfigSnapshot: (runtime: unknown) => mocks.requireValidConfigSnapshot(runtime), + formatChannelAccountLabel: ({ channel, accountId }: { channel: string; accountId: string }) => + `${channel} ${accountId}`, + appendBaseUrlBit: () => undefined, + appendEnabledConfiguredLinkedBits: () => undefined, + appendModeBit: () => undefined, + appendTokenSourceBits: () => undefined, + buildChannelAccountLine: () => "", +})); + +vi.mock("../cli/progress.js", () => ({ + withProgress: async (_opts: unknown, run: () => Promise) => await run(), +})); + +function writeExternalEnvChannelPlugin() { + useNoBundledPlugins(); + const pluginDir = makeTempDir(); + const fullMarker = path.join(pluginDir, "full-loaded.txt"); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify( + { + name: "@example/openclaw-external-env-channel", + version: "1.0.0", + openclaw: { + extensions: ["./index.cjs"], + }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify( + { + id: "external-env-channel-plugin", + configSchema: EMPTY_PLUGIN_SCHEMA, + channels: ["external-env-channel"], + channelEnvVars: { + "external-env-channel": ["EXTERNAL_ENV_CHANNEL_TOKEN"], + }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "index.cjs"), + `require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8");`, + "utf-8", + ); + return { pluginDir, fullMarker }; +} + +function createRuntimeCapture() { + const logs: string[] = []; + const errors: string[] = []; + const runtime = { + log: (message: unknown) => logs.push(String(message)), + error: (message: unknown) => errors.push(String(message)), + exit: (_code?: number) => undefined, + }; + return { runtime, logs, errors }; +} + +describe("channelsStatusCommand external env-only channel fallback", () => { + beforeEach(() => { + mocks.callGateway.mockReset(); + mocks.callGateway.mockRejectedValue(new Error("gateway closed")); + mocks.readConfigFileSnapshot.mockClear(); + mocks.requireValidConfigSnapshot.mockReset(); + mocks.resolveCommandConfigWithSecrets.mockReset(); + }); + + afterEach(() => { + resetPluginLoaderTestStateForTest(); + }); + + it("reports env-only external manifest channels in JSON fallback without full runtime load", async () => { + const { pluginDir, fullMarker } = writeExternalEnvChannelPlugin(); + const config = { + plugins: { + load: { paths: [pluginDir] }, + allow: ["external-env-channel-plugin"], + }, + } as OpenClawConfig; + mocks.requireValidConfigSnapshot.mockResolvedValue(config); + mocks.resolveCommandConfigWithSecrets.mockResolvedValue({ + resolvedConfig: config, + effectiveConfig: config, + diagnostics: [], + }); + const { runtime, logs } = createRuntimeCapture(); + + await withEnvAsync({ EXTERNAL_ENV_CHANNEL_TOKEN: "token" }, async () => { + await channelsStatusCommand({ json: true, probe: false }, runtime as never); + }); + + expect(fs.existsSync(fullMarker)).toBe(false); + const payload = JSON.parse(logs.at(-1) ?? "{}"); + expect(payload).toEqual( + expect.objectContaining({ + gatewayReachable: false, + configOnly: true, + configuredChannels: ["external-env-channel"], + }), + ); + }); +}); + +afterAll(() => { + cleanupPluginLoaderFixturesForTest(); +}); 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..c774b86e5c3 100644 --- a/src/commands/channels/status.ts +++ b/src/commands/channels/status.ts @@ -1,13 +1,15 @@ -import { listChannelPlugins } from "../../channels/plugins/index.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"; import { collectChannelStatusIssues } from "../../infra/channels-status-issues.js"; +import { formatErrorMessage } from "../../infra/errors.js"; import { formatTimeAgo } from "../../infra/format-time/format-relative.ts"; +import { listConfiguredChannelIdsForReadOnlyScope } from "../../plugins/channel-plugin-ids.js"; import { defaultRuntime, type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; +import { redactSensitiveUrlLikeString } from "../../shared/net/redact-sensitive-url.js"; import { formatDocsLink } from "../../terminal/links.js"; import { theme } from "../../terminal/theme.js"; import { @@ -29,6 +31,16 @@ export type ChannelsStatusOptions = { timeout?: string; }; +function redactGatewayUrlSecretsInText(text: string): string { + return text.replace(/\b(?:wss?|https?):\/\/[^\s"'<>]+/gi, (rawUrl) => { + return redactSensitiveUrlLikeString(rawUrl); + }); +} + +function formatChannelsStatusError(err: unknown): string { + return redactGatewayUrlSecretsInText(formatErrorMessage(err)); +} + export function formatGatewayChannelsStatusLines(payload: Record): string[] { const lines: string[] = []; lines.push(theme.success("Gateway reachable.")); @@ -109,20 +121,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)); } } @@ -174,7 +185,8 @@ export async function channelsStatusCommand( } runtime.log(formatGatewayChannelsStatusLines(payload).join("\n")); } catch (err) { - runtime.error(`Gateway not reachable: ${String(err)}`); + const safeError = formatChannelsStatusError(err); + runtime.error(`Gateway not reachable: ${safeError}`); const cfg = await requireValidConfigSnapshot(runtime); if (!cfg) { return; @@ -182,12 +194,29 @@ 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: safeError, + configOnly: true, + config: { + path: snapshot.path, + mode, + }, + configuredChannels: listConfiguredChannelIdsForReadOnlyScope({ + config: resolvedConfig, + env: process.env, + includePersistedAuthState: false, + }), + }); + 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..672d52e2351 100644 --- a/src/commands/doctor-memory-search.test.ts +++ b/src/commands/doctor-memory-search.test.ts @@ -10,8 +10,9 @@ 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()); +const resolveActiveMemoryBackendConfig = vi.hoisted(() => vi.fn()); type CheckQmdBinaryAvailability = typeof checkQmdBinaryAvailabilityFn; const checkQmdBinaryAvailability = vi.hoisted(() => vi.fn(async () => ({ available: true })), @@ -37,11 +38,17 @@ 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, + resolveActiveMemoryBackendConfig, })); vi.mock("../memory-host-sdk/engine-qmd.js", () => ({ @@ -145,9 +152,15 @@ 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(); + resolveActiveMemoryBackendConfig.mockReset(); + resolveActiveMemoryBackendConfig.mockImplementation(({ cfg }: { cfg: OpenClawConfig }) => + cfg.memory?.backend === "qmd" + ? { backend: "qmd", qmd: cfg.memory.qmd ?? {} } + : { backend: "builtin" }, + ); getActiveMemorySearchManager.mockResolvedValue({ manager: { status: () => ({ workspaceDir: "/tmp/agent-default/workspace", backend: "builtin" }), @@ -214,12 +227,8 @@ describe("noteMemorySearchHealth", () => { expect(note).not.toHaveBeenCalled(); }); - it("does not warn when QMD backend is active", async () => { - resolveActiveMemoryBackendConfig.mockReturnValue({ - backend: "qmd", - citations: "auto", - qmd: { command: "qmd" }, - }); + it("does not emit provider guidance when no memory runtime is active", async () => { + resolveActiveMemoryBackendConfig.mockReturnValue(null); resolveMemorySearchConfig.mockReturnValue({ provider: "auto", local: {}, @@ -228,6 +237,24 @@ describe("noteMemorySearchHealth", () => { await noteMemorySearchHealth(cfg, {}); + expect(resolveApiKeyForProvider).not.toHaveBeenCalled(); + expect(checkQmdBinaryAvailability).not.toHaveBeenCalled(); + expect(note).toHaveBeenCalledTimes(1); + expect(String(note.mock.calls[0]?.[0] ?? "")).toContain( + "No active memory plugin is registered", + ); + }); + + it("does not warn when QMD backend is active", async () => { + const qmdCfg = { memory: { backend: "qmd", qmd: { command: "qmd" } } } as OpenClawConfig; + resolveMemorySearchConfig.mockReturnValue({ + provider: "auto", + local: {}, + remote: {}, + }); + + await noteMemorySearchHealth(qmdCfg, {}); + expect(note).not.toHaveBeenCalled(); expect(checkQmdBinaryAvailability).toHaveBeenCalledWith({ command: "qmd", @@ -237,11 +264,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 +275,7 @@ describe("noteMemorySearchHealth", () => { remote: {}, }); - await noteMemorySearchHealth(cfg, {}); + await noteMemorySearchHealth(qmdCfg, {}); expect(note).toHaveBeenCalledTimes(1); const message = String(note.mock.calls[0]?.[0] ?? ""); @@ -460,7 +483,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..6f903642a13 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,8 +20,6 @@ import { hasConfiguredMemorySecretInput } from "../memory-host-sdk/secret.js"; import { auditDreamingArtifacts, auditShortTermPromotionArtifacts, - getBuiltinMemoryEmbeddingProviderDoctorMetadata, - listBuiltinAutoSelectMemoryEmbeddingProviderDoctorMetadata, repairDreamingArtifacts, repairShortTermPromotionArtifacts, type DreamingArtifactsAuditSummary, @@ -26,18 +29,13 @@ import { getActiveMemorySearchManager, resolveActiveMemoryBackendConfig, } 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 +43,90 @@ 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; +} + async function resolveRuntimeMemoryAuditContext( cfg: OpenClawConfig, ): Promise { @@ -373,7 +455,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 +532,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 +556,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..5415b2b72ab 100644 --- a/src/commands/status-runtime-shared.test.ts +++ b/src/commands/status-runtime-shared.test.ts @@ -16,6 +16,11 @@ const mocks = vi.hoisted(() => ({ callGateway: vi.fn(), getDaemonStatusSummary: vi.fn(), getNodeDaemonStatusSummary: vi.fn(), + resolveReadOnlyChannelPluginsForConfig: vi.fn(), +})); + +vi.mock("../channels/plugins/read-only.js", () => ({ + resolveReadOnlyChannelPluginsForConfig: mocks.resolveReadOnlyChannelPluginsForConfig, })); vi.mock("../infra/provider-usage.js", () => ({ @@ -43,6 +48,11 @@ describe("status-runtime-shared", () => { mocks.callGateway.mockResolvedValue({ ok: true }); mocks.getDaemonStatusSummary.mockResolvedValue({ label: "LaunchAgent" }); mocks.getNodeDaemonStatusSummary.mockResolvedValue({ label: "node" }); + mocks.resolveReadOnlyChannelPluginsForConfig.mockReturnValue({ + plugins: [{ id: "telegram" }], + configuredChannelIds: ["telegram"], + missingConfiguredChannelIds: [], + }); }); it("resolves the shared security audit payload", async () => { @@ -51,6 +61,32 @@ describe("status-runtime-shared", () => { sourceConfig: { gateway: {} }, }); + expect(mocks.runSecurityAudit).toHaveBeenCalledWith({ + config: { gateway: {} }, + sourceConfig: { gateway: {} }, + deep: false, + includeFilesystem: true, + includeChannelSecurity: true, + plugins: expect.any(Array), + }); + expect(mocks.resolveReadOnlyChannelPluginsForConfig).toHaveBeenCalledWith( + { gateway: {} }, + { activationSourceConfig: { gateway: {} } }, + ); + }); + + it("lets the security audit load configured channel plugins when read-only discovery is incomplete", async () => { + mocks.resolveReadOnlyChannelPluginsForConfig.mockReturnValue({ + plugins: [], + configuredChannelIds: ["external"], + missingConfiguredChannelIds: ["external"], + }); + + await resolveStatusSecurityAudit({ + config: { gateway: {} }, + sourceConfig: { gateway: {} }, + }); + expect(mocks.runSecurityAudit).toHaveBeenCalledWith({ config: { gateway: {} }, sourceConfig: { gateway: {} }, @@ -244,6 +280,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..742260b0985 100644 --- a/src/commands/status-runtime-shared.ts +++ b/src/commands/status-runtime-shared.ts @@ -1,3 +1,4 @@ +import { resolveReadOnlyChannelPluginsForConfig } 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"; @@ -27,12 +28,18 @@ export async function resolveStatusSecurityAudit(params: { sourceConfig: OpenClawConfig; }) { const { runSecurityAudit } = await loadSecurityAuditModule(); + const readOnlyPlugins = resolveReadOnlyChannelPluginsForConfig(params.config, { + activationSourceConfig: params.sourceConfig, + }); return await runSecurityAudit({ config: params.config, sourceConfig: params.sourceConfig, deep: false, includeFilesystem: true, includeChannelSecurity: true, + ...(readOnlyPlugins.missingConfiguredChannelIds.length === 0 + ? { plugins: readOnlyPlugins.plugins } + : {}), }); } 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..e2ba85e6c37 100644 --- a/src/commands/status.scan-overview.ts +++ b/src/commands/status.scan-overview.ts @@ -1,8 +1,8 @@ -import { hasPotentialConfiguredChannels } from "../channels/config-presence.js"; import type { OpenClawConfig } from "../config/types.js"; import type { collectChannelStatusIssues as collectChannelStatusIssuesFn } from "../infra/channels-status-issues.js"; import { resolveOsSummary } from "../infra/os-summary.js"; import type { UpdateCheckResult } from "../infra/update-check.js"; +import { hasConfiguredChannelsForReadOnlyScope } from "../plugins/channel-plugin-ids.js"; import type { RuntimeEnv } from "../runtime.js"; import type { buildChannelsTable as buildChannelsTableFn } from "./status-all/channels.js"; import type { getAgentLocalStatuses as getAgentLocalStatusesFn } from "./status.agent-local.js"; @@ -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 } : {}), }), @@ -176,7 +178,7 @@ export async function collectStatusScanOverview(params: { params.progress?.tick(); const hasConfiguredChannels = params.resolveHasConfiguredChannels ? params.resolveHasConfiguredChannels(cfg) - : hasPotentialConfiguredChannels(cfg); + : hasConfiguredChannelsForReadOnlyScope({ config: cfg }); const osSummary = resolveOsSummary(); const bootstrap = await createStatusScanCoreBootstrap< Awaited> @@ -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..edd58db00e7 100644 --- a/src/commands/status.scan.fast-json.ts +++ b/src/commands/status.scan.fast-json.ts @@ -1,5 +1,5 @@ -import { hasPotentialConfiguredChannels } from "../channels/config-presence.js"; -import { ensureCliPluginRegistryLoaded } from "../cli/plugin-registry-loader.js"; +import type { OpenClawConfig } from "../config/types.js"; +import { hasConfiguredChannelsForReadOnlyScope } from "../plugins/channel-plugin-ids.js"; import type { RuntimeEnv } from "../runtime.js"; import { executeStatusScanFromOverview } from "./status.scan-execute.ts"; import { @@ -12,9 +12,8 @@ import type { StatusScanResult } from "./status.scan-result.ts"; type StatusJsonScanPolicy = { commandName: string; allowMissingConfigFastPath?: boolean; - resolveHasConfiguredChannels: ( - cfg: Parameters[0], - ) => boolean; + includeChannelSummary?: boolean; + resolveHasConfiguredChannels: (cfg: OpenClawConfig) => boolean; resolveMemory: Parameters[0]["resolveMemory"]; }; @@ -35,18 +34,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,8 +57,11 @@ export async function scanStatusJsonFast( return await scanStatusJsonWithPolicy(opts, runtime, { commandName: "status --json", allowMissingConfigFastPath: true, + includeChannelSummary: false, resolveHasConfiguredChannels: (cfg) => - hasPotentialConfiguredChannels(cfg, process.env, { + hasConfiguredChannelsForReadOnlyScope({ + config: cfg, + env: process.env, includePersistedAuthState: false, }), resolveMemory: async ({ cfg, agentStatus, memoryPlugin }) => diff --git a/src/commands/status.scan.test-helpers.ts b/src/commands/status.scan.test-helpers.ts index 6bf5e632a67..a8d1755d89d 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, }; } @@ -178,6 +182,36 @@ export async function loadStatusScanModuleForTest( vi.doMock("../channels/config-presence.js", () => ({ hasPotentialConfiguredChannels: mocks.hasPotentialConfiguredChannels, })); + vi.doMock("../plugins/channel-plugin-ids.js", () => ({ + hasConfiguredChannelsForReadOnlyScope: (params: { + config: OpenClawConfig; + env?: NodeJS.ProcessEnv; + includePersistedAuthState?: boolean; + }) => + Boolean( + mocks.hasPotentialConfiguredChannels( + params.config, + params.env, + params.includePersistedAuthState === undefined + ? undefined + : { includePersistedAuthState: params.includePersistedAuthState }, + ), + ), + listConfiguredChannelIdsForReadOnlyScope: (params: { + config: OpenClawConfig; + env?: NodeJS.ProcessEnv; + includePersistedAuthState?: boolean; + }) => + mocks.hasPotentialConfiguredChannels( + params.config, + params.env, + params.includePersistedAuthState === undefined + ? undefined + : { includePersistedAuthState: params.includePersistedAuthState }, + ) + ? ["mock-channel"] + : [], + })); vi.doMock("../config/io.js", () => ({ readBestEffortConfig: mocks.readBestEffortConfig, 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..cce6c103f6c 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 { hasConfiguredChannelsForReadOnlyScope } from "../plugins/channel-plugin-ids.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"; @@ -25,7 +25,8 @@ export async function scanStatus( _runtime, { commandName: "status --json", - resolveHasConfiguredChannels: (cfg) => hasPotentialConfiguredChannels(cfg), + resolveHasConfiguredChannels: (cfg) => + hasConfiguredChannelsForReadOnlyScope({ config: cfg }), resolveMemory: async ({ cfg, agentStatus, memoryPlugin }) => await resolveStatusMemoryStatusSnapshot({ cfg, @@ -59,7 +60,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..4eab0d1b3e9 100644 --- a/src/commands/status.summary.test.ts +++ b/src/commands/status.summary.test.ts @@ -1,12 +1,12 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const statusSummaryMocks = vi.hoisted(() => ({ - hasPotentialConfiguredChannels: vi.fn(() => true), + hasConfiguredChannelsForReadOnlyScope: vi.fn(() => true), buildChannelSummary: vi.fn(async () => ["ok"]), })); -vi.mock("../channels/config-presence.js", () => ({ - hasPotentialConfiguredChannels: statusSummaryMocks.hasPotentialConfiguredChannels, +vi.mock("../plugins/channel-plugin-ids.js", () => ({ + hasConfiguredChannelsForReadOnlyScope: statusSummaryMocks.hasConfiguredChannelsForReadOnlyScope, })); vi.mock("./status.summary.runtime.js", () => ({ @@ -125,7 +125,7 @@ describe("getStatusSummary", () => { beforeEach(() => { vi.clearAllMocks(); - statusSummaryMocks.hasPotentialConfiguredChannels.mockReturnValue(true); + statusSummaryMocks.hasConfiguredChannelsForReadOnlyScope.mockReturnValue(true); statusSummaryMocks.buildChannelSummary.mockResolvedValue(["ok"]); }); @@ -140,12 +140,25 @@ describe("getStatusSummary", () => { }); it("skips channel summary imports when no channels are configured", async () => { - statusSummaryMocks.hasPotentialConfiguredChannels.mockReturnValue(false); + statusSummaryMocks.hasConfiguredChannelsForReadOnlyScope.mockReturnValue(false); const summary = await getStatusSummary(); expect(summary.channelSummary).toEqual([]); expect(summary.linkChannel).toBeUndefined(); + expect(statusSummaryMocks.hasConfiguredChannelsForReadOnlyScope).toHaveBeenCalledWith({ + config: {}, + }); + expect(buildChannelSummary).not.toHaveBeenCalled(); + 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.hasConfiguredChannelsForReadOnlyScope).not.toHaveBeenCalled(); expect(buildChannelSummary).not.toHaveBeenCalled(); expect(resolveLinkChannelContext).not.toHaveBeenCalled(); }); diff --git a/src/commands/status.summary.ts b/src/commands/status.summary.ts index 2e567d39b7e..f808cefbb6b 100644 --- a/src/commands/status.summary.ts +++ b/src/commands/status.summary.ts @@ -1,5 +1,4 @@ import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; -import { hasPotentialConfiguredChannels } from "../channels/config-presence.js"; import { resolveMainSessionKey } from "../config/sessions/main-session.js"; import { resolveStorePath } from "../config/sessions/paths.js"; import { readSessionStoreReadOnly } from "../config/sessions/store-read.js"; @@ -8,6 +7,7 @@ import type { OpenClawConfig } from "../config/types.js"; import { listGatewayAgentsBasic } from "../gateway/agent-list.js"; import { resolveHeartbeatSummaryForAgent } from "../infra/heartbeat-summary.js"; import { peekSystemEvents } from "../infra/system-events.js"; +import { hasConfiguredChannelsForReadOnlyScope } from "../plugins/channel-plugin-ids.js"; import { parseAgentSessionKey } from "../routing/session-key.js"; import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js"; import { resolveRuntimeServiceVersion } from "../version.js"; @@ -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,11 @@ export async function getStatusSummary( resolveSessionModelRef, } = await loadStatusSummaryRuntimeModule(); const cfg = options.config ?? (await loadConfigIoModule()).loadConfig(); - const needsChannelPlugins = hasPotentialConfiguredChannels(cfg); + const needsChannelPlugins = + includeChannelSummary && + hasConfiguredChannelsForReadOnlyScope({ + config: 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..4cc79ee4235 100644 --- a/src/infra/provider-usage.auth.plugin.test.ts +++ b/src/infra/provider-usage.auth.plugin.test.ts @@ -1,18 +1,29 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const resolveProviderUsageAuthWithPluginMock = vi.fn( async (..._args: unknown[]): Promise => null, ); +const hasAnyAuthProfileStoreSourceMock = vi.fn(() => false); const ensureAuthProfileStoreMock = vi.fn(() => ({ profiles: {}, })); +const ensureAuthProfileStoreWithoutExternalProfilesMock = vi.fn(() => ({ + profiles: {}, +})); +const resolveAuthProfileOrderMock = vi.fn((_params: unknown): string[] => []); vi.mock("../agents/auth-profiles.js", () => ({ dedupeProfileIds: (profileIds: string[]) => [...new Set(profileIds)], ensureAuthProfileStore: () => ensureAuthProfileStoreMock(), + ensureAuthProfileStoreWithoutExternalProfiles: () => + ensureAuthProfileStoreWithoutExternalProfilesMock(), + hasAnyAuthProfileStoreSource: () => hasAnyAuthProfileStoreSourceMock(), listProfilesForProvider: () => [], resolveApiKeyForProfile: async () => null, - resolveAuthProfileOrder: () => [], + resolveAuthProfileOrder: (params: unknown) => resolveAuthProfileOrderMock(params), })); vi.mock("../plugins/provider-runtime.js", async () => { @@ -27,13 +38,33 @@ vi.mock("../plugins/provider-runtime.js", async () => { let resolveProviderAuths: typeof import("./provider-usage.auth.js").resolveProviderAuths; +async function withTempHome(fn: (homeDir: string) => Promise): Promise { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-provider-usage-")); + try { + return await fn(homeDir); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } +} + describe("resolveProviderAuths plugin boundary", () => { beforeAll(async () => { ({ resolveProviderAuths } = await import("./provider-usage.auth.js")); }); beforeEach(() => { + hasAnyAuthProfileStoreSourceMock.mockReset(); + hasAnyAuthProfileStoreSourceMock.mockReturnValue(false); ensureAuthProfileStoreMock.mockClear(); + ensureAuthProfileStoreMock.mockReturnValue({ + profiles: {}, + }); + ensureAuthProfileStoreWithoutExternalProfilesMock.mockClear(); + ensureAuthProfileStoreWithoutExternalProfilesMock.mockReturnValue({ + profiles: {}, + }); + resolveAuthProfileOrderMock.mockReset(); + resolveAuthProfileOrderMock.mockReturnValue([]); resolveProviderUsageAuthWithPluginMock.mockReset(); resolveProviderUsageAuthWithPluginMock.mockResolvedValue(null); }); @@ -55,4 +86,256 @@ describe("resolveProviderAuths plugin boundary", () => { ]); expect(ensureAuthProfileStoreMock).not.toHaveBeenCalled(); }); + + it("skips plugin usage auth when requested and no direct credential source exists", async () => { + await withTempHome(async (homeDir) => { + await expect( + resolveProviderAuths({ + providers: ["zai"], + skipPluginAuthWithoutCredentialSource: true, + env: { HOME: homeDir }, + }), + ).resolves.toEqual([]); + }); + + expect(resolveProviderUsageAuthWithPluginMock).not.toHaveBeenCalled(); + expect(ensureAuthProfileStoreMock).not.toHaveBeenCalled(); + }); + + it("keeps plugin usage auth when a shared legacy plugin credential source exists", async () => { + await withTempHome(async (homeDir) => { + fs.mkdirSync(path.join(homeDir, ".pi", "agent"), { recursive: true }); + fs.writeFileSync( + path.join(homeDir, ".pi", "agent", "auth.json"), + `${JSON.stringify({ "z-ai": { access: "legacy-zai-token" } })}\n`, + ); + resolveProviderUsageAuthWithPluginMock.mockResolvedValueOnce({ + token: "legacy-zai-token", + }); + await expect( + resolveProviderAuths({ + providers: ["zai"], + skipPluginAuthWithoutCredentialSource: true, + env: { HOME: homeDir }, + }), + ).resolves.toEqual([ + { + provider: "zai", + token: "legacy-zai-token", + }, + ]); + }); + + expect(resolveProviderUsageAuthWithPluginMock).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "zai", + }), + ); + expect(ensureAuthProfileStoreMock).not.toHaveBeenCalled(); + }); + + it("keeps legacy plugin credential sources provider-specific", async () => { + await withTempHome(async (homeDir) => { + fs.mkdirSync(path.join(homeDir, ".pi", "agent"), { recursive: true }); + fs.writeFileSync( + path.join(homeDir, ".pi", "agent", "auth.json"), + `${JSON.stringify({ "z-ai": { access: "legacy-zai-token" } })}\n`, + ); + resolveProviderUsageAuthWithPluginMock.mockResolvedValueOnce({ + token: "legacy-zai-token", + }); + + await expect( + resolveProviderAuths({ + providers: ["anthropic", "zai"], + skipPluginAuthWithoutCredentialSource: true, + env: { HOME: homeDir }, + }), + ).resolves.toEqual([ + { + provider: "zai", + token: "legacy-zai-token", + }, + ]); + }); + + expect(resolveProviderUsageAuthWithPluginMock).toHaveBeenCalledTimes(1); + expect(resolveProviderUsageAuthWithPluginMock).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "zai", + }), + ); + }); + + it("keeps auth-profile credential sources provider-specific", async () => { + hasAnyAuthProfileStoreSourceMock.mockReturnValue(true); + ensureAuthProfileStoreWithoutExternalProfilesMock.mockReturnValue({ + profiles: { + "anthropic:default": { + type: "api_key", + provider: "anthropic", + key: "sk-ant", + }, + }, + }); + resolveAuthProfileOrderMock.mockImplementation((params: unknown) => { + const provider = + params && typeof params === "object" && "provider" in params + ? (params as { provider?: unknown }).provider + : undefined; + return provider === "anthropic" ? ["anthropic:default"] : []; + }); + resolveProviderUsageAuthWithPluginMock.mockResolvedValueOnce({ + token: "plugin-anthropic-token", + }); + + await withTempHome(async (homeDir) => { + await expect( + resolveProviderAuths({ + providers: ["anthropic", "zai"], + skipPluginAuthWithoutCredentialSource: true, + env: { HOME: homeDir }, + }), + ).resolves.toEqual([ + { + provider: "anthropic", + token: "plugin-anthropic-token", + }, + ]); + }); + + expect(resolveProviderUsageAuthWithPluginMock).toHaveBeenCalledTimes(1); + expect(resolveProviderUsageAuthWithPluginMock).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "anthropic", + }), + ); + expect(ensureAuthProfileStoreMock).not.toHaveBeenCalled(); + }); + + it("keeps plugin usage auth when an owned alias provider has auth-profile credentials", async () => { + hasAnyAuthProfileStoreSourceMock.mockReturnValue(true); + ensureAuthProfileStoreWithoutExternalProfilesMock.mockReturnValue({ + profiles: { + "minimax-portal:default": { + type: "oauth", + provider: "minimax-portal", + accessToken: "portal-oauth-token", + }, + }, + }); + resolveAuthProfileOrderMock.mockImplementation((params: unknown) => { + const provider = + params && typeof params === "object" && "provider" in params + ? (params as { provider?: unknown }).provider + : undefined; + return provider === "minimax-portal" ? ["minimax-portal:default"] : []; + }); + resolveProviderUsageAuthWithPluginMock.mockResolvedValueOnce({ + token: "plugin-minimax-token", + }); + + await withTempHome(async (homeDir) => { + await expect( + resolveProviderAuths({ + providers: ["minimax"], + skipPluginAuthWithoutCredentialSource: true, + env: { HOME: homeDir }, + }), + ).resolves.toEqual([ + { + provider: "minimax", + token: "plugin-minimax-token", + }, + ]); + }); + + expect(resolveAuthProfileOrderMock).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "minimax-portal", + }), + ); + expect(resolveProviderUsageAuthWithPluginMock).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "minimax", + }), + ); + expect(ensureAuthProfileStoreMock).not.toHaveBeenCalled(); + }); + + it("keeps plugin usage auth when provider-owned usage env credentials exist", async () => { + resolveProviderUsageAuthWithPluginMock.mockResolvedValueOnce({ + token: "plugin-minimax-token", + }); + + await withTempHome(async (homeDir) => { + await expect( + resolveProviderAuths({ + providers: ["minimax"], + skipPluginAuthWithoutCredentialSource: true, + env: { + HOME: homeDir, + MINIMAX_CODE_PLAN_KEY: "code-plan-key", + }, + }), + ).resolves.toEqual([ + { + provider: "minimax", + token: "plugin-minimax-token", + }, + ]); + }); + + expect(resolveProviderUsageAuthWithPluginMock).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "minimax", + }), + ); + expect(ensureAuthProfileStoreMock).not.toHaveBeenCalled(); + }); + + it("does not overlay external auth profiles while checking the skip gate", async () => { + hasAnyAuthProfileStoreSourceMock.mockReturnValue(true); + + await withTempHome(async (homeDir) => { + await expect( + resolveProviderAuths({ + providers: ["anthropic"], + skipPluginAuthWithoutCredentialSource: true, + env: { HOME: homeDir }, + }), + ).resolves.toEqual([]); + }); + + expect(ensureAuthProfileStoreWithoutExternalProfilesMock).toHaveBeenCalledTimes(1); + expect(ensureAuthProfileStoreMock).not.toHaveBeenCalled(); + expect(resolveProviderUsageAuthWithPluginMock).not.toHaveBeenCalled(); + }); + + it("skips plugin usage auth per provider when only another provider has direct credentials", async () => { + await withTempHome(async (homeDir) => { + await expect( + resolveProviderAuths({ + providers: ["anthropic", "zai"], + skipPluginAuthWithoutCredentialSource: true, + env: { + HOME: homeDir, + ANTHROPIC_API_KEY: "sk-ant", + }, + }), + ).resolves.toEqual([ + { + provider: "anthropic", + token: "sk-ant", + }, + ]); + }); + + expect(resolveProviderUsageAuthWithPluginMock).toHaveBeenCalledTimes(1); + expect(resolveProviderUsageAuthWithPluginMock).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "anthropic", + }), + ); + }); }); diff --git a/src/infra/provider-usage.auth.ts b/src/infra/provider-usage.auth.ts index 40c26d2b854..26ff9275eb4 100644 --- a/src/infra/provider-usage.auth.ts +++ b/src/infra/provider-usage.auth.ts @@ -1,16 +1,29 @@ import { dedupeProfileIds, ensureAuthProfileStore, + ensureAuthProfileStoreWithoutExternalProfiles, + 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"; import { loadConfig, type OpenClawConfig } from "../config/config.js"; +import { normalizePluginsConfig } from "../plugins/config-state.js"; +import { + isActivatedManifestOwner, + passesManifestOwnerBasePolicy, +} from "../plugins/manifest-owner-policy.js"; +import { + loadPluginManifestRegistry, + type PluginManifestRecord, +} from "../plugins/manifest-registry.js"; import { resolveProviderUsageAuthWithPlugin } from "../plugins/provider-runtime.js"; import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; +import { resolveLegacyPiAgentAccessToken } from "./provider-usage.shared.js"; import type { UsageProviderId } from "./provider-usage.types.js"; export type ProviderAuth = { @@ -25,6 +38,7 @@ type UsageAuthState = { cfg: OpenClawConfig; env: NodeJS.ProcessEnv; agentDir?: string; + allowAuthProfileStore: boolean; store?: AuthStore; }; @@ -35,7 +49,7 @@ function resolveUsageAuthStore(state: UsageAuthState): AuthStore { return state.store; } -function resolveProviderApiKeyFromConfigAndStore(params: { +function resolveProviderApiKeyFromConfig(params: { state: UsageAuthState; providerIds: string[]; envDirect?: Array; @@ -46,14 +60,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), @@ -88,10 +119,76 @@ function resolveProviderApiKeyFromConfigAndStore(params: { return undefined; } +function normalizeProviderIds(providerIds: Iterable): string[] { + return [ + ...new Set( + [...providerIds] + .map((providerId) => (providerId ? normalizeProviderId(providerId) : undefined)) + .filter((providerId): providerId is string => Boolean(providerId)), + ), + ]; +} + +function isUsageProviderManifestEligible(params: { + plugin: PluginManifestRecord; + state: UsageAuthState; +}): boolean { + const normalizedConfig = normalizePluginsConfig(params.state.cfg.plugins); + if ( + !passesManifestOwnerBasePolicy({ + plugin: params.plugin, + normalizedConfig, + }) + ) { + return false; + } + if (params.plugin.origin !== "workspace") { + return true; + } + return isActivatedManifestOwner({ + plugin: params.plugin, + normalizedConfig, + rootConfig: params.state.cfg, + }); +} + +function resolveUsageCredentialProviderIds(params: { + state: UsageAuthState; + provider: UsageProviderId; +}): string[] { + const providerIds = new Set(normalizeProviderIds([params.provider])); + const providerIdSet = new Set(providerIds); + try { + const registry = loadPluginManifestRegistry({ + config: params.state.cfg, + env: params.state.env, + }); + for (const plugin of registry.plugins) { + const pluginProviderIds = normalizeProviderIds(plugin.providers); + if (!pluginProviderIds.some((providerId) => providerIdSet.has(providerId))) { + continue; + } + if (!isUsageProviderManifestEligible({ plugin, state: params.state })) { + continue; + } + for (const providerId of pluginProviderIds) { + providerIds.add(providerId); + } + } + } catch { + // Credential-source checks are an optimization gate; preserve usage fallback + // behavior if manifest discovery is unavailable in a constrained environment. + } + return [...providerIds]; +} + 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, @@ -202,32 +299,97 @@ async function resolveProviderUsageAuthFallback(params: { return null; } +function hasAuthProfileCredentialSource(params: { + state: UsageAuthState; + providerIds: string[]; +}): boolean { + const store = ensureAuthProfileStoreWithoutExternalProfiles(params.state.agentDir, { + allowKeychainPrompt: false, + }); + for (const provider of params.providerIds) { + const order = resolveAuthProfileOrder({ + cfg: params.state.cfg, + store, + provider, + }); + if ( + dedupeProfileIds(order).some((profileId) => { + const cred = store.profiles[profileId]; + return cred?.type === "api_key" || cred?.type === "oauth" || cred?.type === "token"; + }) + ) { + return true; + } + } + return false; +} + +function resolveLegacyPiAgentProviderIds(provider: UsageProviderId): string[] { + return provider === "zai" ? ["z-ai", "zai"] : [provider]; +} + export async function resolveProviderAuths(params: { providers: UsageProviderId[]; auth?: ProviderAuth[]; 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 hasAuthProfileStoreSource = hasAnyAuthProfileStoreSource(params.agentDir); + const authProfileSourceState: UsageAuthState = { + ...stateBase, + allowAuthProfileStore: true, + }; const auths: ProviderAuth[] = []; for (const provider of params.providers) { - const pluginAuth = await resolveProviderUsageAuthViaPlugin({ - state, + const credentialProviderIds = resolveUsageCredentialProviderIds({ + state: { ...stateBase, allowAuthProfileStore: false }, provider, }); - if (pluginAuth) { - auths.push(pluginAuth); - continue; + const hasDirectCredentialSource = Boolean( + resolveProviderApiKeyFromConfig({ + state: { ...stateBase, allowAuthProfileStore: false }, + providerIds: credentialProviderIds, + }), + ); + const allowAuthProfileStore = + !params.skipPluginAuthWithoutCredentialSource || + hasDirectCredentialSource || + (hasAuthProfileStoreSource && + hasAuthProfileCredentialSource({ + state: authProfileSourceState, + providerIds: credentialProviderIds, + })); + const state: UsageAuthState = { + ...stateBase, + allowAuthProfileStore, + }; + const hasLegacyPiAgentCredentialSource = Boolean( + resolveLegacyPiAgentAccessToken(stateBase.env, resolveLegacyPiAgentProviderIds(provider)), + ); + const hasPluginCredentialSource = + hasDirectCredentialSource || allowAuthProfileStore || hasLegacyPiAgentCredentialSource; + + if (!params.skipPluginAuthWithoutCredentialSource || hasPluginCredentialSource) { + 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/infra/provider-usage.shared.ts b/src/infra/provider-usage.shared.ts index 5f38ad9f30d..4f6ab8c09d0 100644 --- a/src/infra/provider-usage.shared.ts +++ b/src/infra/provider-usage.shared.ts @@ -71,17 +71,16 @@ export const withTimeout = async (work: Promise, ms: number, fallback: T): } }; +function resolveLegacyPiAgentAuthPath(env: NodeJS.ProcessEnv): string { + return path.join(resolveRequiredHomeDir(env, os.homedir), ".pi", "agent", "auth.json"); +} + export function resolveLegacyPiAgentAccessToken( env: NodeJS.ProcessEnv, providerIds: string[], ): string | undefined { try { - const authPath = path.join( - resolveRequiredHomeDir(env, os.homedir), - ".pi", - "agent", - "auth.json", - ); + const authPath = resolveLegacyPiAgentAuthPath(env); if (!fs.existsSync(authPath)) { return undefined; } diff --git a/src/plugins/channel-plugin-ids.test.ts b/src/plugins/channel-plugin-ids.test.ts index c66237a1302..8eed1735507 100644 --- a/src/plugins/channel-plugin-ids.test.ts +++ b/src/plugins/channel-plugin-ids.test.ts @@ -19,6 +19,8 @@ vi.mock("./manifest-registry.js", async (importOriginal) => { }); import { + hasConfiguredChannelsForReadOnlyScope, + listConfiguredChannelIdsForReadOnlyScope, resolveConfiguredChannelPluginIds, resolveGatewayStartupPluginIds, } from "./channel-plugin-ids.js"; @@ -102,6 +104,28 @@ function createManifestRegistryFixture() { providers: [], cliBackends: [], }, + { + id: "external-env-channel-plugin", + channels: ["external-env-channel"], + channelEnvVars: { + "external-env-channel": ["EXTERNAL_ENV_CHANNEL_TOKEN"], + }, + origin: "config", + enabledByDefault: undefined, + providers: [], + cliBackends: [], + }, + { + id: "ambient-env-channel-plugin", + channels: ["ambient-env-channel"], + channelEnvVars: { + "ambient-env-channel": ["HOME", "PATH"], + }, + origin: "config", + enabledByDefault: undefined, + providers: [], + cliBackends: [], + }, { id: "voice-call", channels: [], @@ -586,6 +610,22 @@ describe("resolveConfiguredChannelPluginIds", () => { ).toEqual([]); }); + it("includes trusted external channel owners configured only by manifest env vars", () => { + expect( + resolveConfiguredChannelPluginIds({ + config: { + plugins: { + allow: ["external-env-channel-plugin"], + }, + } as OpenClawConfig, + workspaceDir: "/tmp", + env: { + EXTERNAL_ENV_CHANNEL_TOKEN: "token", + } as NodeJS.ProcessEnv, + }), + ).toEqual(["external-env-channel-plugin"]); + }); + it("blocks bundled activation owners when explicitly disabled", () => { expect( resolveConfiguredChannelPluginIds({ @@ -607,3 +647,151 @@ describe("resolveConfiguredChannelPluginIds", () => { ).toEqual([]); }); }); + +describe("listConfiguredChannelIdsForReadOnlyScope", () => { + beforeEach(() => { + listPotentialConfiguredChannelIds.mockReset().mockReturnValue([]); + hasPotentialConfiguredChannels.mockReset().mockReturnValue(false); + loadPluginManifestRegistry.mockReset().mockReturnValue(createManifestRegistryFixture()); + }); + + it("uses manifest env vars as read-only configured channel triggers", () => { + expect( + listConfiguredChannelIdsForReadOnlyScope({ + config: { + plugins: { + allow: ["external-env-channel-plugin"], + }, + } as OpenClawConfig, + workspaceDir: "/tmp", + env: { + EXTERNAL_ENV_CHANNEL_TOKEN: "token", + } as NodeJS.ProcessEnv, + includePersistedAuthState: false, + }), + ).toEqual(["external-env-channel"]); + }); + + it("ignores manifest env vars from untrusted external plugins", () => { + expect( + listConfiguredChannelIdsForReadOnlyScope({ + config: {} as OpenClawConfig, + workspaceDir: "/tmp", + env: { + EXTERNAL_ENV_CHANNEL_TOKEN: "token", + } as NodeJS.ProcessEnv, + includePersistedAuthState: false, + }), + ).toEqual([]); + + expect( + hasConfiguredChannelsForReadOnlyScope({ + config: {} as OpenClawConfig, + workspaceDir: "/tmp", + env: { + EXTERNAL_ENV_CHANNEL_TOKEN: "token", + } as NodeJS.ProcessEnv, + includePersistedAuthState: false, + }), + ).toBe(false); + }); + + it("ignores ambient or malformed manifest env vars as read-only configured channel triggers", () => { + expect( + listConfiguredChannelIdsForReadOnlyScope({ + config: { + plugins: { + allow: ["ambient-env-channel-plugin"], + }, + } as OpenClawConfig, + workspaceDir: "/tmp", + env: { + HOME: "/tmp/user", + PATH: "/usr/bin", + lowercase_token: "token", + } as NodeJS.ProcessEnv, + includePersistedAuthState: false, + }), + ).toEqual([]); + }); + + it("accepts lowercase or mixed-case manifest env vars as read-only configured channel triggers", () => { + expect( + listConfiguredChannelIdsForReadOnlyScope({ + config: { + plugins: { + allow: ["external-env-channel-plugin"], + }, + } as OpenClawConfig, + workspaceDir: "/tmp", + env: { + external_env_channel_token: "token", + } as NodeJS.ProcessEnv, + includePersistedAuthState: false, + manifestRecords: [ + { + id: "external-env-channel-plugin", + channels: ["external-env-channel"], + channelEnvVars: { + "external-env-channel": ["external_env_channel_token"], + }, + origin: "config", + enabledByDefault: undefined, + providers: [], + cliBackends: [], + } as never, + ], + }), + ).toEqual(["external-env-channel"]); + }); + + it("matches uppercase process env entries for lowercase manifest env var declarations", () => { + expect( + listConfiguredChannelIdsForReadOnlyScope({ + config: { + plugins: { + allow: ["external-env-channel-plugin"], + }, + } as OpenClawConfig, + workspaceDir: "/tmp", + env: { + EXTERNAL_ENV_CHANNEL_TOKEN: "token", + } as NodeJS.ProcessEnv, + includePersistedAuthState: false, + manifestRecords: [ + { + id: "external-env-channel-plugin", + channels: ["external-env-channel"], + channelEnvVars: { + "external-env-channel": ["external_env_channel_token"], + }, + origin: "config", + enabledByDefault: undefined, + providers: [], + cliBackends: [], + } as never, + ], + }), + ).toEqual(["external-env-channel"]); + }); + + it("uses manifest env vars for read-only channel presence checks", () => { + listPotentialConfiguredChannelIds.mockReturnValue([]); + hasPotentialConfiguredChannels.mockReturnValue(false); + + expect( + hasConfiguredChannelsForReadOnlyScope({ + config: { + plugins: { + allow: ["external-env-channel-plugin"], + }, + } as OpenClawConfig, + workspaceDir: "/tmp", + env: { + EXTERNAL_ENV_CHANNEL_TOKEN: "token", + } as NodeJS.ProcessEnv, + includePersistedAuthState: false, + }), + ).toBe(true); + }); +}); diff --git a/src/plugins/channel-plugin-ids.ts b/src/plugins/channel-plugin-ids.ts index e6d6c9898c7..2758945a919 100644 --- a/src/plugins/channel-plugin-ids.ts +++ b/src/plugins/channel-plugin-ids.ts @@ -1,11 +1,16 @@ +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { collectConfiguredAgentHarnessRuntimes } from "../agents/harness-runtimes.js"; -import { listPotentialConfiguredChannelIds } from "../channels/config-presence.js"; +import { + hasPotentialConfiguredChannels, + listPotentialConfiguredChannelIds, +} from "../channels/config-presence.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveMemoryDreamingConfig, resolveMemoryDreamingPluginConfig, resolveMemoryDreamingPluginId, } from "../memory-host-sdk/dreaming.js"; +import { isSafeChannelEnvVarTriggerName } from "../secrets/channel-env-var-names.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import { resolveManifestActivationPluginIds } from "./activation-planner.js"; import { @@ -61,6 +66,125 @@ function normalizeChannelIds(channelIds: Iterable): string[] { ).toSorted((left, right) => left.localeCompare(right)); } +function hasNonEmptyEnvValue(env: NodeJS.ProcessEnv, key: string): boolean { + if (!isSafeChannelEnvVarTriggerName(key)) { + return false; + } + const trimmed = key.trim(); + const value = env[trimmed] ?? env[trimmed.toUpperCase()]; + return typeof value === "string" && value.trim().length > 0; +} + +function listEnvConfiguredManifestChannelIds(params: { + records: readonly PluginManifestRecord[]; + config: OpenClawConfig; + activationSourceConfig?: OpenClawConfig; + env: NodeJS.ProcessEnv; +}): string[] { + const channelIds = new Set(); + const trustConfig = params.activationSourceConfig ?? params.config; + const normalizedConfig = normalizePluginsConfig(trustConfig.plugins); + for (const record of params.records) { + if ( + !isChannelPluginEligibleForScopedOwnership({ + plugin: record, + normalizedConfig, + rootConfig: trustConfig, + }) + ) { + continue; + } + for (const channelId of record.channels) { + const envVars = record.channelEnvVars?.[channelId] ?? []; + if (envVars.some((envVar) => hasNonEmptyEnvValue(params.env, envVar))) { + channelIds.add(channelId); + } + } + } + return [...channelIds].toSorted((left, right) => left.localeCompare(right)); +} + +function listConfiguredChannelIdsForPluginScope(params: { + config: OpenClawConfig; + activationSourceConfig?: OpenClawConfig; + workspaceDir?: string; + env: NodeJS.ProcessEnv; + cache?: boolean; + includePersistedAuthState?: boolean; + manifestRecords?: readonly PluginManifestRecord[]; +}): string[] { + const records = + params.manifestRecords ?? + loadPluginManifestRegistry({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + cache: params.cache, + }).plugins; + return [ + ...new Set([ + ...listPotentialConfiguredChannelIds(params.config, params.env, { + includePersistedAuthState: params.includePersistedAuthState, + }), + ...listEnvConfiguredManifestChannelIds({ + records, + config: params.config, + activationSourceConfig: params.activationSourceConfig, + env: params.env, + }), + ]), + ].toSorted((left, right) => left.localeCompare(right)); +} + +export function listConfiguredChannelIdsForReadOnlyScope(params: { + config: OpenClawConfig; + activationSourceConfig?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + cache?: boolean; + includePersistedAuthState?: boolean; + manifestRecords?: readonly PluginManifestRecord[]; +}): string[] { + const env = params.env ?? process.env; + const workspaceDir = + params.workspaceDir ?? + resolveAgentWorkspaceDir(params.config, resolveDefaultAgentId(params.config)); + return listConfiguredChannelIdsForPluginScope({ + config: params.config, + activationSourceConfig: params.activationSourceConfig, + workspaceDir, + env, + cache: params.cache, + includePersistedAuthState: params.includePersistedAuthState, + manifestRecords: params.manifestRecords, + }); +} + +export function hasConfiguredChannelsForReadOnlyScope(params: { + config: OpenClawConfig; + activationSourceConfig?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + cache?: boolean; + includePersistedAuthState?: boolean; + manifestRecords?: readonly PluginManifestRecord[]; +}): boolean { + const env = params.env ?? process.env; + if ( + hasPotentialConfiguredChannels(params.config, env, { + includePersistedAuthState: params.includePersistedAuthState, + }) + ) { + return true; + } + return ( + listConfiguredChannelIdsForReadOnlyScope({ + ...params, + env, + }).length > 0 + ); +} + function isChannelPluginEligibleForScopedOwnership(params: { plugin: PluginManifestRecord; normalizedConfig: ReturnType; @@ -143,7 +267,7 @@ function resolveScopedChannelOwnerPluginIds(params: { .toSorted((left, right) => left.localeCompare(right)); } -export function resolveScopedChannelPluginIds(params: { +function resolveScopedChannelPluginIds(params: { config: OpenClawConfig; activationSourceConfig?: OpenClawConfig; channelIds: readonly string[]; @@ -222,7 +346,12 @@ export function resolveConfiguredChannelPluginIds(params: { env: NodeJS.ProcessEnv; }): string[] { const configuredChannelIds = new Set( - listPotentialConfiguredChannelIds(params.config, params.env).map((id) => id.trim()), + listConfiguredChannelIdsForPluginScope({ + config: params.config, + activationSourceConfig: params.activationSourceConfig, + workspaceDir: params.workspaceDir, + env: params.env, + }).map((id) => id.trim()), ); if (configuredChannelIds.size === 0) { return []; diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index f3683a1e8c6..b474648dcb5 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -134,6 +134,8 @@ export type PluginLoadOptions = { mode?: "full" | "validate"; onlyPluginIds?: string[]; includeSetupOnlyChannelPlugins?: boolean; + forceSetupOnlyChannelPlugins?: boolean; + requireSetupEntryForSetupOnlyChannelPlugins?: boolean; /** * Prefer `setupEntry` for configured channel plugins that explicitly opt in * via package metadata because their setup entry covers the pre-listen startup surface. @@ -505,6 +507,8 @@ function buildCacheKey(params: { env: NodeJS.ProcessEnv; onlyPluginIds?: string[]; includeSetupOnlyChannelPlugins?: boolean; + forceSetupOnlyChannelPlugins?: boolean; + requireSetupEntryForSetupOnlyChannelPlugins?: boolean; preferSetupRuntimeForChannelPlugins?: boolean; loadModules?: boolean; runtimeSubagentMode?: "default" | "explicit" | "gateway-bindable"; @@ -534,6 +538,12 @@ function buildCacheKey(params: { ); const scopeKey = serializePluginIdScope(params.onlyPluginIds); const setupOnlyKey = params.includeSetupOnlyChannelPlugins === true ? "setup-only" : "runtime"; + const setupOnlyModeKey = + params.forceSetupOnlyChannelPlugins === true ? "force-setup" : "normal-setup"; + const setupOnlyRequirementKey = + params.requireSetupEntryForSetupOnlyChannelPlugins === true + ? "require-setup-entry" + : "allow-full-fallback"; const startupChannelMode = params.preferSetupRuntimeForChannelPlugins === true ? "prefer-setup" : "full"; const moduleLoadMode = params.loadModules === false ? "manifest-only" : "load-modules"; @@ -544,7 +554,7 @@ function buildCacheKey(params: { installs, loadPaths, activationMetadataKey: params.activationMetadataKey ?? "", - })}::${scopeKey}::${setupOnlyKey}::${startupChannelMode}::${moduleLoadMode}::${runtimeSubagentMode}::${params.pluginSdkResolution ?? "auto"}::${gatewayMethodsKey}`; + })}::${scopeKey}::${setupOnlyKey}::${setupOnlyModeKey}::${setupOnlyRequirementKey}::${startupChannelMode}::${moduleLoadMode}::${runtimeSubagentMode}::${params.pluginSdkResolution ?? "auto"}::${gatewayMethodsKey}`; } function matchesScopedPluginRequest(params: { @@ -619,6 +629,8 @@ function hasExplicitCompatibilityInputs(options: PluginLoadOptions): boolean { options.pluginSdkResolution !== undefined || options.coreGatewayHandlers !== undefined || options.includeSetupOnlyChannelPlugins === true || + options.forceSetupOnlyChannelPlugins === true || + options.requireSetupEntryForSetupOnlyChannelPlugins === true || options.preferSetupRuntimeForChannelPlugins === true || options.loadModules === false ); @@ -634,6 +646,9 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) { }); const onlyPluginIds = normalizePluginIdScope(options.onlyPluginIds); const includeSetupOnlyChannelPlugins = options.includeSetupOnlyChannelPlugins === true; + const forceSetupOnlyChannelPlugins = options.forceSetupOnlyChannelPlugins === true; + const requireSetupEntryForSetupOnlyChannelPlugins = + options.requireSetupEntryForSetupOnlyChannelPlugins === true; const preferSetupRuntimeForChannelPlugins = options.preferSetupRuntimeForChannelPlugins === true; const runtimeSubagentMode = resolveRuntimeSubagentMode(options.runtimeOptions); const coreGatewayMethodNames = Object.keys(options.coreGatewayHandlers ?? {}).toSorted(); @@ -648,6 +663,8 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) { env, onlyPluginIds, includeSetupOnlyChannelPlugins, + forceSetupOnlyChannelPlugins, + requireSetupEntryForSetupOnlyChannelPlugins, preferSetupRuntimeForChannelPlugins, loadModules: options.loadModules, runtimeSubagentMode, @@ -663,6 +680,8 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) { autoEnabledReasons: options.autoEnabledReasons ?? {}, onlyPluginIds, includeSetupOnlyChannelPlugins, + forceSetupOnlyChannelPlugins, + requireSetupEntryForSetupOnlyChannelPlugins, preferSetupRuntimeForChannelPlugins, shouldActivate: options.activate !== false, shouldLoadModules: options.loadModules !== false, @@ -980,6 +999,17 @@ function shouldLoadChannelPluginInSetupRuntime(params: { ); } +function channelPluginIdBelongsToManifest(params: { + channelId: string | undefined; + pluginId: string; + manifestChannels: readonly string[]; +}): boolean { + if (!params.channelId) { + return true; + } + return params.channelId === params.pluginId || params.manifestChannels.includes(params.channelId); +} + function createPluginRecord(params: { id: string; name?: string; @@ -1410,6 +1440,8 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi autoEnabledReasons, onlyPluginIds, includeSetupOnlyChannelPlugins, + forceSetupOnlyChannelPlugins, + requireSetupEntryForSetupOnlyChannelPlugins, preferSetupRuntimeForChannelPlugins, shouldActivate, shouldLoadModules, @@ -1740,26 +1772,34 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi } } - const registrationMode = enableState.enabled - ? shouldLoadModules && - !validateOnly && - shouldLoadChannelPluginInSetupRuntime({ - manifestChannels: manifestRecord.channels, - setupSource: manifestRecord.setupSource, - startupDeferConfiguredChannelFullLoadUntilAfterListen: - manifestRecord.startupDeferConfiguredChannelFullLoadUntilAfterListen, - cfg, - env, - preferSetupRuntimeForChannelPlugins, - }) - ? "setup-runtime" - : "full" - : includeSetupOnlyChannelPlugins && - !validateOnly && - onlyPluginIdSet && - manifestRecord.channels.length > 0 - ? "setup-only" - : null; + const scopedSetupOnlyChannelPluginRequested = + includeSetupOnlyChannelPlugins && + !validateOnly && + onlyPluginIdSet && + manifestRecord.channels.length > 0 && + (!enableState.enabled || forceSetupOnlyChannelPlugins); + const canLoadScopedSetupOnlyChannelPlugin = + scopedSetupOnlyChannelPluginRequested && + (!requireSetupEntryForSetupOnlyChannelPlugins || Boolean(manifestRecord.setupSource)); + const registrationMode = canLoadScopedSetupOnlyChannelPlugin + ? "setup-only" + : scopedSetupOnlyChannelPluginRequested && requireSetupEntryForSetupOnlyChannelPlugins + ? null + : enableState.enabled + ? shouldLoadModules && + !validateOnly && + shouldLoadChannelPluginInSetupRuntime({ + manifestChannels: manifestRecord.channels, + setupSource: manifestRecord.setupSource, + startupDeferConfiguredChannelFullLoadUntilAfterListen: + manifestRecord.startupDeferConfiguredChannelFullLoadUntilAfterListen, + cfg, + env, + preferSetupRuntimeForChannelPlugins, + }) + ? "setup-runtime" + : "full" + : null; if (!registrationMode) { record.status = "disabled"; @@ -1980,7 +2020,13 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi continue; } if (setupRegistration.plugin) { - if (setupRegistration.plugin.id && setupRegistration.plugin.id !== record.id) { + if ( + !channelPluginIdBelongsToManifest({ + channelId: setupRegistration.plugin.id, + pluginId: record.id, + manifestChannels: manifestRecord.channels, + }) + ) { pushPluginLoadError( `plugin id mismatch (config uses "${record.id}", setup export uses "${setupRegistration.plugin.id}")`, ); @@ -2105,7 +2151,13 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi if (!mergedSetupPlugin) { continue; } - if (mergedSetupPlugin.id && mergedSetupPlugin.id !== record.id) { + if ( + !channelPluginIdBelongsToManifest({ + channelId: mergedSetupPlugin.id, + pluginId: record.id, + manifestChannels: manifestRecord.channels, + }) + ) { pushPluginLoadError( `plugin id mismatch (config uses "${record.id}", setup export uses "${mergedSetupPlugin.id}")`, ); diff --git a/src/plugins/runtime/runtime-registry-loader.ts b/src/plugins/runtime/runtime-registry-loader.ts index 811b072b751..8be63200466 100644 --- a/src/plugins/runtime/runtime-registry-loader.ts +++ b/src/plugins/runtime/runtime-registry-loader.ts @@ -78,6 +78,7 @@ export function ensurePluginRegistryLoaded(options?: { config?: OpenClawConfig; activationSourceConfig?: OpenClawConfig; env?: NodeJS.ProcessEnv; + workspaceDir?: string; onlyPluginIds?: string[]; }): void { const scope = options?.scope ?? "all"; 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/secrets/channel-env-var-names.ts b/src/secrets/channel-env-var-names.ts new file mode 100644 index 00000000000..e4e717d0d8c --- /dev/null +++ b/src/secrets/channel-env-var-names.ts @@ -0,0 +1,26 @@ +const UNSAFE_CHANNEL_ENV_VAR_TRIGGER_NAMES = new Set([ + "CI", + "HOME", + "LANG", + "LC_ALL", + "LC_CTYPE", + "LOGNAME", + "NODE_ENV", + "OLDPWD", + "PATH", + "PWD", + "SHELL", + "SSH_AUTH_SOCK", + "TEMP", + "TERM", + "TMP", + "TMPDIR", + "USER", +]); + +export function isSafeChannelEnvVarTriggerName(key: string): boolean { + const normalized = key.trim().toUpperCase(); + return ( + /^[A-Z][A-Z0-9_]*$/.test(normalized) && !UNSAFE_CHANNEL_ENV_VAR_TRIGGER_NAMES.has(normalized) + ); +} diff --git a/src/secrets/channel-env-vars.ts b/src/secrets/channel-env-vars.ts index b76aebb06f0..98a7976bcac 100644 --- a/src/secrets/channel-env-vars.ts +++ b/src/secrets/channel-env-vars.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js"; +export { isSafeChannelEnvVarTriggerName } from "./channel-env-var-names.js"; type ChannelEnvVarLookupParams = { config?: OpenClawConfig; diff --git a/src/secrets/provider-env-vars.ts b/src/secrets/provider-env-vars.ts index c8dc009dd95..dd5528851af 100644 --- a/src/secrets/provider-env-vars.ts +++ b/src/secrets/provider-env-vars.ts @@ -18,6 +18,7 @@ const CORE_PROVIDER_AUTH_ENV_VAR_CANDIDATES = { } as const; const CORE_PROVIDER_SETUP_ENV_VAR_OVERRIDES = { + minimax: ["MINIMAX_API_KEY"], "minimax-cn": ["MINIMAX_API_KEY"], } as const; @@ -210,8 +211,6 @@ export function getProviderEnvVars( return Array.isArray(envVars) ? [...envVars] : []; } -const EXTRA_PROVIDER_AUTH_ENV_VARS = ["MINIMAX_CODE_PLAN_KEY", "MINIMAX_CODING_API_KEY"] as const; - // OPENCLAW_API_KEY authenticates the local OpenClaw bridge itself and must // remain available to child bridge/runtime processes. export function listKnownProviderAuthEnvVarNames(params?: ProviderEnvVarLookupParams): string[] { @@ -219,7 +218,6 @@ export function listKnownProviderAuthEnvVarNames(params?: ProviderEnvVarLookupPa ...new Set([ ...Object.values(resolveProviderAuthEnvVarCandidates(params)).flatMap((keys) => keys), ...Object.values(resolveProviderEnvVars(params)).flatMap((keys) => keys), - ...EXTRA_PROVIDER_AUTH_ENV_VARS, ]), ]; } diff --git a/src/security/audit-plugin-readonly-scope.test.ts b/src/security/audit-plugin-readonly-scope.test.ts new file mode 100644 index 00000000000..8fe631c2c19 --- /dev/null +++ b/src/security/audit-plugin-readonly-scope.test.ts @@ -0,0 +1,113 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const applyPluginAutoEnableMock = vi.hoisted(() => vi.fn()); +const loadPluginMetadataRegistrySnapshotMock = vi.hoisted(() => vi.fn()); +const resolveConfiguredChannelPluginIdsMock = vi.hoisted(() => vi.fn()); + +vi.mock("../config/plugin-auto-enable.js", () => ({ + applyPluginAutoEnable: (...args: unknown[]) => applyPluginAutoEnableMock(...args), +})); + +vi.mock("../plugins/channel-plugin-ids.js", () => ({ + resolveConfiguredChannelPluginIds: (...args: unknown[]) => + resolveConfiguredChannelPluginIdsMock(...args), +})); + +vi.mock("../plugins/runtime/metadata-registry-loader.js", () => ({ + loadPluginMetadataRegistrySnapshot: (...args: unknown[]) => + loadPluginMetadataRegistrySnapshotMock(...args), +})); + +let runSecurityAudit: typeof import("./audit.js").runSecurityAudit; + +describe("security audit read-only plugin scope", () => { + beforeAll(async () => { + ({ runSecurityAudit } = await import("./audit.js")); + }); + + beforeEach(() => { + applyPluginAutoEnableMock.mockReset(); + loadPluginMetadataRegistrySnapshotMock.mockReset(); + resolveConfiguredChannelPluginIdsMock.mockReset(); + applyPluginAutoEnableMock.mockImplementation((params: { config: unknown }) => ({ + config: params.config, + changes: [], + autoEnabledReasons: {}, + })); + loadPluginMetadataRegistrySnapshotMock.mockReturnValue({ + securityAuditCollectors: [], + }); + resolveConfiguredChannelPluginIdsMock.mockReturnValue([]); + }); + + it("keeps configured channel owner collectors when the provided channel plugin list omits them", async () => { + const sourceConfig = { + plugins: { + allow: ["external-channel-plugin", "audit-plugin"], + }, + }; + applyPluginAutoEnableMock.mockReturnValue({ + config: sourceConfig, + changes: [], + autoEnabledReasons: { + "external-channel-plugin": ["channel:external"], + "audit-plugin": ["explicit"], + }, + }); + resolveConfiguredChannelPluginIdsMock.mockReturnValue(["external-channel-plugin"]); + + await runSecurityAudit({ + config: sourceConfig, + sourceConfig, + env: {} as NodeJS.ProcessEnv, + includeFilesystem: false, + includeChannelSecurity: true, + plugins: [], + }); + + expect(resolveConfiguredChannelPluginIdsMock).toHaveBeenCalledWith( + expect.objectContaining({ + config: sourceConfig, + activationSourceConfig: sourceConfig, + env: {}, + }), + ); + expect(loadPluginMetadataRegistrySnapshotMock).toHaveBeenCalledWith( + expect.objectContaining({ + onlyPluginIds: ["external-channel-plugin", "audit-plugin"], + }), + ); + }); + + it("removes configured channel owner collectors only when channel security will audit them", async () => { + const sourceConfig = { + plugins: { + allow: ["external-channel-plugin", "audit-plugin"], + }, + }; + applyPluginAutoEnableMock.mockReturnValue({ + config: sourceConfig, + changes: [], + autoEnabledReasons: { + "external-channel-plugin": ["channel:external"], + "audit-plugin": ["explicit"], + }, + }); + resolveConfiguredChannelPluginIdsMock.mockReturnValue(["external-channel-plugin"]); + + await runSecurityAudit({ + config: sourceConfig, + sourceConfig, + env: {} as NodeJS.ProcessEnv, + includeFilesystem: false, + includeChannelSecurity: true, + plugins: [{ id: "external-channel-plugin" }] as never, + }); + + expect(loadPluginMetadataRegistrySnapshotMock).toHaveBeenCalledWith( + expect.objectContaining({ + onlyPluginIds: ["audit-plugin"], + }), + ); + }); +}); diff --git a/src/security/audit.ts b/src/security/audit.ts index c0241aceb70..31493bca751 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { resolveSandboxConfigForAgent } from "../agents/sandbox/config.js"; import { hasPotentialConfiguredChannels } from "../channels/config-presence.js"; import type { listChannelPlugins } from "../channels/plugins/index.js"; @@ -13,6 +14,7 @@ import { } from "../infra/exec-safe-bin-runtime-policy.js"; import { listRiskyConfiguredSafeBins } from "../infra/exec-safe-bin-semantics.js"; import { normalizeTrustedSafeBinDirs } from "../infra/exec-safe-bin-trust.js"; +import { resolveConfiguredChannelPluginIds } from "../plugins/channel-plugin-ids.js"; import { getActivePluginRegistry } from "../plugins/runtime.js"; import { DEFAULT_AGENT_ID } from "../routing/session-key.js"; import { asNullableRecord } from "../shared/record-coerce.js"; @@ -70,6 +72,8 @@ export type SecurityAuditOptions = { codeSafetySummaryCache?: Map>; /** Optional explicit auth for deep gateway probe. */ deepProbeAuth?: { token?: string; password?: string }; + /** Override workspace used for workspace plugin discovery. */ + workspaceDir?: string; /** Dependency injection for tests. */ probeGatewayFn?: ProbeGatewayFn; }; @@ -92,6 +96,7 @@ type AuditExecutionContext = { configSnapshot: ConfigFileSnapshot | null; codeSafetySummaryCache: Map>; deepProbeAuth?: { token?: string; password?: string }; + workspaceDir?: string; }; let channelPluginsModulePromise: Promise | undefined; @@ -352,6 +357,19 @@ async function collectPluginSecurityAuditFindings( requestedPluginIds.add(normalized); } } + if (context.includeChannelSecurity && context.plugins !== undefined) { + const auditedChannelPluginIds = new Set(context.plugins.map((plugin) => plugin.id)); + for (const pluginId of resolveConfiguredChannelPluginIds({ + config: autoEnabled.config, + activationSourceConfig: context.sourceConfig, + workspaceDir: context.workspaceDir, + env: context.env, + })) { + if (auditedChannelPluginIds.has(pluginId)) { + requestedPluginIds.delete(pluginId); + } + } + } if (requestedPluginIds.size === 0) { return []; } @@ -361,6 +379,7 @@ async function collectPluginSecurityAuditFindings( config: autoEnabled.config, activationSourceConfig: context.sourceConfig, env: context.env, + workspaceDir: context.workspaceDir, onlyPluginIds: [...requestedPluginIds], }); collectors = snapshot.securityAuditCollectors ?? []; @@ -883,6 +902,8 @@ async function createAuditExecutionContext( const deepTimeoutMs = Math.max(250, opts.deepTimeoutMs ?? 5000); const stateDir = opts.stateDir ?? resolveStateDir(env); const configPath = opts.configPath ?? resolveConfigPath(env, stateDir); + const workspaceDir = + opts.workspaceDir ?? resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)); const { readConfigSnapshotForAudit } = await loadAuditNonDeepModule(); const configSnapshot = includeFilesystem ? opts.configSnapshot !== undefined @@ -904,6 +925,7 @@ async function createAuditExecutionContext( execDockerRawFn: opts.execDockerRawFn, probeGatewayFn: opts.probeGatewayFn, plugins: opts.plugins, + workspaceDir, configSnapshot, codeSafetySummaryCache: opts.codeSafetySummaryCache ?? new Map>(), deepProbeAuth: opts.deepProbeAuth, @@ -986,13 +1008,21 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise 0); if (shouldAuditChannelSecurity) { if (context.plugins === undefined) { (await loadPluginRegistryLoaderModule()).ensurePluginRegistryLoaded({ scope: "configured-channels", config: cfg, activationSourceConfig: context.sourceConfig, + workspaceDir: context.workspaceDir, env, }); } diff --git a/src/shared/net/redact-sensitive-url.test.ts b/src/shared/net/redact-sensitive-url.test.ts index b72b90655cb..3a092fab090 100644 --- a/src/shared/net/redact-sensitive-url.test.ts +++ b/src/shared/net/redact-sensitive-url.test.ts @@ -34,12 +34,21 @@ describe("redactSensitiveUrlLikeString", () => { "//***:***@example.com/mcp?client_secret=***", ); }); + + it("redacts protocol URLs that are too malformed to parse", () => { + expect( + redactSensitiveUrlLikeString( + "wss://fallback-user:fallback-pass@[bad-host/socket?token=fallback-secret&keep=visible)", + ), + ).toBe("wss://***:***@[bad-host/socket?token=***&keep=visible)"); + }); }); describe("isSensitiveUrlQueryParamName", () => { it("matches the auth-oriented query params used by MCP SSE config redaction", () => { expect(isSensitiveUrlQueryParamName("token")).toBe(true); expect(isSensitiveUrlQueryParamName("refresh_token")).toBe(true); + expect(isSensitiveUrlQueryParamName("signature")).toBe(true); expect(isSensitiveUrlQueryParamName("safe")).toBe(false); }); }); diff --git a/src/shared/net/redact-sensitive-url.ts b/src/shared/net/redact-sensitive-url.ts index c2a3f379f27..090a4ee3200 100644 --- a/src/shared/net/redact-sensitive-url.ts +++ b/src/shared/net/redact-sensitive-url.ts @@ -15,6 +15,7 @@ const SENSITIVE_URL_QUERY_PARAM_NAMES = new Set([ "auth", "client_secret", "refresh_token", + "signature", ]); export function isSensitiveUrlQueryParamName(name: string): boolean {