fix: keep status channel metadata cold

This commit is contained in:
Shakker
2026-04-26 09:00:07 +01:00
parent a434133aac
commit 0a82c819bb
14 changed files with 86 additions and 26 deletions

View File

@@ -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.

View File

@@ -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");

View File

@@ -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,
});

View File

@@ -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,
});

View File

@@ -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;

View File

@@ -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;
};

View File

@@ -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<string, unknown>,
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(", ")}`;
}

View File

@@ -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<Record<string, unknown>>) =>
const accountLines = (
plugin: ChannelStatusPluginLabel,
accounts: Array<Record<string, unknown>>,
) =>
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));
}
}

View File

@@ -44,6 +44,10 @@ function formatChannelsStatusError(err: unknown): string {
export function formatGatewayChannelsStatusLines(payload: Record<string, unknown>): string[] {
const lines: string[] = [];
lines.push(theme.success("Gateway reachable."));
const channelLabels =
payload.channelLabels && typeof payload.channelLabels === "object"
? (payload.channelLabels as Record<string, unknown>)
: {};
const accountLines = (provider: ChatChannel, accounts: Array<Record<string, unknown>>) =>
accounts.map((account) => {
const bits: string[] = [];
@@ -118,7 +122,10 @@ export function formatGatewayChannelsStatusLines(payload: Record<string, unknown
if (typeof account.lastError === "string" && account.lastError) {
bits.push(`error:${account.lastError}`);
}
return buildChannelAccountLine(provider, account, bits);
const rawChannelLabel = channelLabels[provider];
return buildChannelAccountLine(provider, account, bits, {
channelLabel: typeof rawChannelLabel === "string" ? rawChannelLabel : provider,
});
});
const accountsByChannel = payload.channelAccounts as Record<string, unknown> | undefined;

View File

@@ -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),
});
});

View File

@@ -38,6 +38,7 @@ export async function resolveStatusSecurityAudit(params: {
deep: false,
includeFilesystem: true,
includeChannelSecurity: true,
loadPluginSecurityCollectors: false,
...(readOnlyPlugins.missingConfiguredChannelIds.length === 0
? { plugins: readOnlyPlugins.plugins }
: {}),

View File

@@ -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,

View File

@@ -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<string, Promise<unknown>>(),
};
@@ -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();
});
});

View File

@@ -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<string, Promise<unknown>>;
deepProbeAuth?: { token?: string; password?: string };
@@ -338,6 +341,9 @@ export function collectGatewayConfigFindings(
export async function collectPluginSecurityAuditFindings(
context: AuditExecutionContext,
): Promise<SecurityAuditFinding[]> {
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<string, Promise<unknown>>(),