diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b5cc9a6bab..3983166843b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,7 @@ Docs: https://docs.openclaw.ai - Gateway/plugins: stop persisted WhatsApp auth state from activating bundled channel runtime-dependency repair during startup when `channels.whatsapp` is absent, avoiding npm/git stalls on packaged Linux installs. Fixes #71994. Thanks @xiao398008. - Gateway/device tokens: enforce caller-scope containment inside token rotation and revocation so pairing-only sessions cannot mutate higher-scope operator tokens. Fixes #71990. Thanks @coygeek. - Plugins/channels: keep security checks, thread-binding placement, provider summaries, health formatting, and message action labels on read-only or already-loaded channel metadata instead of importing full channel runtime. Thanks @shakkernerd. +- Plugins/status: keep config-only channel labels and status security summaries from importing plugin runtime modules just to render metadata. Thanks @shakkernerd. - Sessions/channels: stop group-session metadata from loading bundled channel runtime just to classify `#channel` subjects, using only already-loaded channel capabilities on that path. Thanks @shakkernerd. - Plugins/channels: keep native command and native skill `auto` defaults on static channel metadata so config, audit, and command-list checks do not load channel runtime just to read those defaults. Thanks @shakkernerd. - CLI/channels: keep channel remove selection and all-channel capabilities summaries on read-only plugin metadata, loading channel runtime only for the selected mutation path. Thanks @shakkernerd. diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index ccf49a45b1d..baad702c9f3 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -15,7 +15,8 @@ import { createClackPrompter } from "../../wizard/clack-prompter.js"; import { applyAgentBindings, describeBinding } from "../agents.bindings.js"; import type { ChannelChoice } from "../onboard-types.js"; import { applyAccountName, applyChannelAccountConfig } from "./add-mutators.js"; -import { channelLabel, requireValidConfigFileSnapshot, shouldUseWizard } from "./shared.js"; +import { channelLabel } from "./runtime-label.js"; +import { requireValidConfigFileSnapshot, shouldUseWizard } from "./shared.js"; type ChannelSetupPluginInstallModule = typeof import("../channel-setup/plugin-install.js"); type OnboardChannelsModule = typeof import("../onboard-channels.js"); diff --git a/src/commands/channels/capabilities.ts b/src/commands/channels/capabilities.ts index 95664de72d4..dbe9d50b7e0 100644 --- a/src/commands/channels/capabilities.ts +++ b/src/commands/channels/capabilities.ts @@ -317,6 +317,7 @@ export async function channelsCapabilitiesCommand( channel: report.channel, accountId: report.accountId, name: report.accountName, + channelLabel: report.plugin.meta.label ?? report.channel, channelStyle: theme.accent, accountStyle: theme.heading, }); diff --git a/src/commands/channels/list.ts b/src/commands/channels/list.ts index 58c7c7163c1..fecb3754dd2 100644 --- a/src/commands/channels/list.ts +++ b/src/commands/channels/list.ts @@ -61,6 +61,7 @@ function formatAccountLine(params: { channel: channel.id, accountId: snapshot.accountId, name: snapshot.name, + channelLabel: channel.meta.label ?? channel.id, channelStyle: theme.accent, accountStyle: theme.heading, }); diff --git a/src/commands/channels/remove.ts b/src/commands/channels/remove.ts index 1ffee9dff26..9cdfc343f0f 100644 --- a/src/commands/channels/remove.ts +++ b/src/commands/channels/remove.ts @@ -9,12 +9,8 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-ke import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { createClackPrompter } from "../../wizard/clack-prompter.js"; -import { - type ChatChannel, - channelLabel, - requireValidConfigFileSnapshot, - shouldUseWizard, -} from "./shared.js"; +import { channelLabel } from "./runtime-label.js"; +import { type ChatChannel, requireValidConfigFileSnapshot, shouldUseWizard } from "./shared.js"; export type ChannelsRemoveOptions = { channel?: string; diff --git a/src/commands/channels/runtime-label.ts b/src/commands/channels/runtime-label.ts new file mode 100644 index 00000000000..54e6698dbe8 --- /dev/null +++ b/src/commands/channels/runtime-label.ts @@ -0,0 +1,11 @@ +import { getBundledChannelSetupPlugin } from "../../channels/plugins/bundled.js"; +import { getChannelPlugin, getLoadedChannelPlugin } from "../../channels/plugins/index.js"; +import type { ChatChannel } from "./shared.js"; + +export const channelLabel = (channel: ChatChannel) => { + const plugin = + getLoadedChannelPlugin(channel) ?? + getBundledChannelSetupPlugin(channel) ?? + getChannelPlugin(channel); + return plugin?.meta.label ?? channel; +}; diff --git a/src/commands/channels/shared.ts b/src/commands/channels/shared.ts index 178e6f591a2..e7427c4eb2f 100644 --- a/src/commands/channels/shared.ts +++ b/src/commands/channels/shared.ts @@ -1,10 +1,5 @@ import { hasConfiguredUnavailableCredentialStatus } from "../../channels/account-snapshot-fields.js"; -import { getBundledChannelSetupPlugin } from "../../channels/plugins/bundled.js"; -import { - type ChannelId, - getChannelPlugin, - getLoadedChannelPlugin, -} from "../../channels/plugins/index.js"; +import type { ChannelId } from "../../channels/plugins/types.public.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"; @@ -50,22 +45,15 @@ export function formatAccountLabel(params: { accountId: string; name?: string }) return base; } -export const channelLabel = (channel: ChatChannel) => { - const plugin = - getLoadedChannelPlugin(channel) ?? - getBundledChannelSetupPlugin(channel) ?? - getChannelPlugin(channel); - return plugin?.meta.label ?? channel; -}; - export function formatChannelAccountLabel(params: { channel: ChatChannel; accountId: string; name?: string; + channelLabel?: string; channelStyle?: (value: string) => string; accountStyle?: (value: string) => string; }): string { - const channelText = channelLabel(params.channel); + const channelText = params.channelLabel ?? params.channel; const accountText = formatAccountLabel({ accountId: params.accountId, name: params.name, @@ -130,6 +118,7 @@ export function buildChannelAccountLine( provider: ChatChannel, account: Record, bits: string[], + opts?: { channelLabel?: string }, ): string { const accountId = typeof account.accountId === "string" ? account.accountId : DEFAULT_ACCOUNT_ID; const name = typeof account.name === "string" ? account.name : undefined; @@ -137,6 +126,7 @@ export function buildChannelAccountLine( channel: provider, accountId, name, + channelLabel: opts?.channelLabel, }); return `- ${labelText}: ${bits.join(", ")}`; } diff --git a/src/commands/channels/status-config-format.ts b/src/commands/channels/status-config-format.ts index de93afbaf84..50a7bf33252 100644 --- a/src/commands/channels/status-config-format.ts +++ b/src/commands/channels/status-config-format.ts @@ -20,6 +20,11 @@ import { type ChatChannel, } from "./shared.js"; +type ChannelStatusPluginLabel = { + id: ChatChannel; + meta: { label?: string }; +}; + export async function formatConfigChannelsStatusLines( cfg: OpenClawConfig, meta: { path?: string; mode?: "local" | "remote" }, @@ -37,14 +42,19 @@ export async function formatConfigChannelsStatusLines( lines.push(""); } - const accountLines = (provider: ChatChannel, accounts: Array>) => + const accountLines = ( + plugin: ChannelStatusPluginLabel, + accounts: Array>, + ) => accounts.map((account) => { const bits: string[] = []; appendEnabledConfiguredLinkedBits(bits, account); appendModeBit(bits, account); appendTokenSourceBits(bits, account); appendBaseUrlBit(bits, account); - return buildChannelAccountLine(provider, account, bits); + return buildChannelAccountLine(plugin.id, account, bits, { + channelLabel: plugin.meta.label ?? plugin.id, + }); }); const sourceConfig = opts?.sourceConfig ?? cfg; @@ -79,7 +89,7 @@ export async function formatConfigChannelsStatusLines( ); } if (snapshots.length > 0) { - lines.push(...accountLines(plugin.id, snapshots)); + lines.push(...accountLines(plugin, snapshots)); } } diff --git a/src/commands/channels/status.ts b/src/commands/channels/status.ts index d4d1ef2427f..879b1b60f24 100644 --- a/src/commands/channels/status.ts +++ b/src/commands/channels/status.ts @@ -44,6 +44,10 @@ function formatChannelsStatusError(err: unknown): string { export function formatGatewayChannelsStatusLines(payload: Record): string[] { const lines: string[] = []; lines.push(theme.success("Gateway reachable.")); + const channelLabels = + payload.channelLabels && typeof payload.channelLabels === "object" + ? (payload.channelLabels as Record) + : {}; const accountLines = (provider: ChatChannel, accounts: Array>) => accounts.map((account) => { const bits: string[] = []; @@ -118,7 +122,10 @@ export function formatGatewayChannelsStatusLines(payload: Record | undefined; diff --git a/src/commands/status-runtime-shared.test.ts b/src/commands/status-runtime-shared.test.ts index a60d8eccfcd..49b07fc7319 100644 --- a/src/commands/status-runtime-shared.test.ts +++ b/src/commands/status-runtime-shared.test.ts @@ -67,6 +67,7 @@ describe("status-runtime-shared", () => { deep: false, includeFilesystem: true, includeChannelSecurity: true, + loadPluginSecurityCollectors: false, plugins: expect.any(Array), }); expect(mocks.resolveReadOnlyChannelPluginsForConfig).toHaveBeenCalledWith( @@ -96,6 +97,7 @@ describe("status-runtime-shared", () => { deep: false, includeFilesystem: true, includeChannelSecurity: true, + loadPluginSecurityCollectors: false, }); }); @@ -283,6 +285,7 @@ describe("status-runtime-shared", () => { deep: false, includeFilesystem: true, includeChannelSecurity: true, + loadPluginSecurityCollectors: false, plugins: expect.any(Array), }); }); diff --git a/src/commands/status-runtime-shared.ts b/src/commands/status-runtime-shared.ts index c78f6346f38..c5c222442c1 100644 --- a/src/commands/status-runtime-shared.ts +++ b/src/commands/status-runtime-shared.ts @@ -38,6 +38,7 @@ export async function resolveStatusSecurityAudit(params: { deep: false, includeFilesystem: true, includeChannelSecurity: true, + loadPluginSecurityCollectors: false, ...(readOnlyPlugins.missingConfiguredChannelIds.length === 0 ? { plugins: readOnlyPlugins.plugins } : {}), diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index 9764f8d290c..61d4a426ecb 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -740,6 +740,7 @@ vi.mock("./status-runtime-shared.ts", () => ({ deep: false, includeFilesystem: true, includeChannelSecurity: true, + loadPluginSecurityCollectors: false, }), ), resolveStatusUsageSummary: vi.fn(async () => undefined), @@ -759,6 +760,7 @@ vi.mock("./status-runtime-shared.ts", () => ({ deep: false, includeFilesystem: true, includeChannelSecurity: true, + loadPluginSecurityCollectors: false, })) )({ config: params.config, diff --git a/src/security/audit-plugin-readonly-scope.test.ts b/src/security/audit-plugin-readonly-scope.test.ts index aef07f3a721..73fb85dee94 100644 --- a/src/security/audit-plugin-readonly-scope.test.ts +++ b/src/security/audit-plugin-readonly-scope.test.ts @@ -1,6 +1,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const applyPluginAutoEnableMock = vi.hoisted(() => vi.fn()); +const getActivePluginRegistryMock = vi.hoisted(() => vi.fn()); const loadPluginMetadataRegistrySnapshotMock = vi.hoisted(() => vi.fn()); const resolveConfiguredChannelPluginIdsMock = vi.hoisted(() => vi.fn()); @@ -13,6 +14,10 @@ vi.mock("../plugins/channel-plugin-ids.js", () => ({ resolveConfiguredChannelPluginIdsMock(...args), })); +vi.mock("../plugins/runtime.js", () => ({ + getActivePluginRegistry: (...args: unknown[]) => getActivePluginRegistryMock(...args), +})); + vi.mock("../plugins/runtime/metadata-registry-loader.js", () => ({ loadPluginMetadataRegistrySnapshot: (...args: unknown[]) => loadPluginMetadataRegistrySnapshotMock(...args), @@ -36,6 +41,7 @@ function createAuditContext(params: { stateDir: "/tmp/openclaw-test-state", configPath: "/tmp/openclaw-test-config.json", plugins: params.plugins, + loadPluginSecurityCollectors: true, configSnapshot: null, codeSafetySummaryCache: new Map>(), }; @@ -48,8 +54,10 @@ describe("security audit read-only plugin scope", () => { beforeEach(() => { applyPluginAutoEnableMock.mockReset(); + getActivePluginRegistryMock.mockReset(); loadPluginMetadataRegistrySnapshotMock.mockReset(); resolveConfiguredChannelPluginIdsMock.mockReset(); + getActivePluginRegistryMock.mockReturnValue(null); applyPluginAutoEnableMock.mockImplementation((params: { config: unknown }) => ({ config: params.config, changes: [], @@ -127,4 +135,25 @@ describe("security audit read-only plugin scope", () => { }), ); }); + + it("skips plugin runtime and collector discovery when collector loading is disabled", async () => { + const sourceConfig = { + plugins: { + allow: ["audit-plugin"], + }, + }; + + const findings = await collectPluginSecurityAuditFindings({ + ...createAuditContext({ + sourceConfig, + plugins: [], + }), + loadPluginSecurityCollectors: false, + }); + + expect(findings).toEqual([]); + expect(getActivePluginRegistryMock).not.toHaveBeenCalled(); + expect(applyPluginAutoEnableMock).not.toHaveBeenCalled(); + expect(loadPluginMetadataRegistrySnapshotMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/security/audit.ts b/src/security/audit.ts index 7b05818b84b..311a323dc62 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -58,6 +58,8 @@ export type SecurityAuditOptions = { deepTimeoutMs?: number; /** Dependency injection for tests. */ plugins?: ChannelPlugin[]; + /** Whether to import plugin modules to discover plugin security audit collectors. */ + loadPluginSecurityCollectors?: boolean; /** Dependency injection for tests (Windows ACL checks). */ execIcacls?: ExecFn; /** Dependency injection for tests (Docker label checks). */ @@ -89,6 +91,7 @@ export type AuditExecutionContext = { execDockerRawFn?: ExecDockerRawFn; probeGatewayFn?: ProbeGatewayFn; plugins?: ChannelPlugin[]; + loadPluginSecurityCollectors: boolean; configSnapshot: ConfigFileSnapshot | null; codeSafetySummaryCache: Map>; deepProbeAuth?: { token?: string; password?: string }; @@ -338,6 +341,9 @@ export function collectGatewayConfigFindings( export async function collectPluginSecurityAuditFindings( context: AuditExecutionContext, ): Promise { + if (!context.loadPluginSecurityCollectors) { + return []; + } const { getActivePluginRegistry } = await loadPluginRuntimeModule(); let collectors = getActivePluginRegistry()?.securityAuditCollectors ?? []; if (collectors.length === 0) { @@ -940,6 +946,7 @@ async function createAuditExecutionContext( execDockerRawFn: opts.execDockerRawFn, probeGatewayFn: opts.probeGatewayFn, plugins: opts.plugins, + loadPluginSecurityCollectors: opts.loadPluginSecurityCollectors !== false, workspaceDir, configSnapshot, codeSafetySummaryCache: opts.codeSafetySummaryCache ?? new Map>(),