fix: keep channel status off plugin runtimes

This commit is contained in:
Gustavo Madeira Santana
2026-04-20 18:14:56 -04:00
parent 09c5669299
commit c10b48a43c
63 changed files with 774 additions and 320 deletions

View File

@@ -78,6 +78,7 @@ Docs: https://docs.openclaw.ai
- fix(security): block MINIMAX_API_HOST workspace env injection and remove env-driven URL routing [AI-assisted]. (#67300) Thanks @pgondhi987.
- Cron/delivery: treat explicit `delivery.mode: "none"` runs as not requested even if the runner reports `delivered: false`, so no-delivery cron jobs no longer persist false delivery failures or errors. (#69285) Thanks @matsuri1987.
- Plugins/install: repair active and default-enabled bundled plugin runtime dependencies before import in packaged installs, so bundled Discord, WhatsApp, Slack, Telegram, and provider plugins work without putting their dependency trees in core.
- CLI/channels: keep `status`, `health`, `channels list`, and `channels status` on read-only channel metadata when Telegram, Slack, or Discord are configured, avoiding full bundled plugin runtime imports on those cold paths. Fixes #69042.
- BlueBubbles: raise the outbound `/api/v1/message/text` send timeout default from 10s to 30s, and add a configurable `channels.bluebubbles.sendTimeoutMs` (also per-account) so macOS 26 setups where Private API iMessage sends stall for 60+ seconds no longer silently lose messages at the 10s abort. Probes, chat lookups, and health checks keep the shorter 10s default. Fixes #67486. (#69193) Thanks @omarshahine.
- Agents/bootstrap: budget truncation markers against per-file caps, preserve source content instead of silently wasting bootstrap bytes, and avoid marker-only output in tiny-budget truncation cases. (#69114) Thanks @BKF-Gitty.
- Context engine/plugins: stop rejecting third-party context engines whose `info.id` differs from the registered plugin slot id. The strict-match contract added in 2026.4.14 broke `lossless-claw` and other plugins whose internal engine id does not equal the slot id they are registered under, producing repeated `info.id must match registered id` lane failures on every turn. Fixes #66601. (#66678) Thanks @GodsBoy.

View File

@@ -4,14 +4,12 @@ import {
createAccountScopedAllowlistNameResolver,
createNestedAllowlistOverrideResolver,
} from "openclaw/plugin-sdk/allowlist-config-edit";
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
import type {
ChannelMessageActionAdapter,
ChannelMessageToolDiscovery,
} from "openclaw/plugin-sdk/channel-contract";
import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy";
import {
createChannelDirectoryAdapter,
createRuntimeDirectoryLiveAdapter,
@@ -65,6 +63,7 @@ import {
import { resolveDiscordOutboundSessionRoute } from "./outbound-session-route.js";
import type { DiscordProbe } from "./probe.js";
import { getDiscordRuntime } from "./runtime.js";
import { discordSecurityAdapter } from "./security.js";
import { normalizeExplicitDiscordSessionKey } from "./session-key-normalization.js";
import { discordSetupAdapter } from "./setup-adapter.js";
import { createDiscordPluginBase, discordConfigAdapter } from "./shared.js";
@@ -89,9 +88,6 @@ let discordCarbonModuleCache: DiscordCarbonModule | null = null;
const loadDiscordDirectoryConfigModule = createLazyRuntimeModule(
() => import("./directory-config.js"),
);
const loadDiscordSecurityAuditModule = createLazyRuntimeModule(
() => import("./security-audit.runtime.js"),
);
const loadDiscordResolveChannelsModule = createLazyRuntimeModule(
() => import("./resolve-channels.js"),
);
@@ -218,18 +214,6 @@ function resolveDiscordStartupDelayMs(cfg: OpenClawConfig, accountId: string): n
return startupIndex <= 0 ? 0 : startupIndex * DISCORD_ACCOUNT_STARTUP_STAGGER_MS;
}
const resolveDiscordDmPolicy = createScopedDmSecurityResolver<ResolvedDiscordAccount>({
channelKey: "discord",
resolvePolicy: (account) => account.config.dm?.policy,
resolveAllowFrom: (account) => account.config.dm?.allowFrom,
allowFromPathSuffix: "dm.",
normalizeEntry: (raw) =>
raw
.trim()
.replace(/^(discord|user):/i, "")
.replace(/^<@!?(\d+)>$/, "$1"),
});
function formatDiscordIntents(intents?: {
messageContent?: string;
guildMembers?: string;
@@ -286,26 +270,6 @@ const resolveDiscordAllowlistNames = createAccountScopedAllowlistNameResolver({
(await loadDiscordResolveUsersModule()).resolveDiscordUserAllowlist({ token, entries }),
});
const collectDiscordSecurityWarnings =
createOpenProviderConfiguredRouteWarningCollector<ResolvedDiscordAccount>({
providerConfigPresent: (cfg) => cfg.channels?.discord !== undefined,
resolveGroupPolicy: (account) => account.config.groupPolicy,
resolveRouteAllowlistConfigured: (account) =>
Object.keys(account.config.guilds ?? {}).length > 0,
configureRouteAllowlist: {
surface: "Discord guilds",
openScope: "any channel not explicitly denied",
groupPolicyPath: "channels.discord.groupPolicy",
routeAllowlistPath: "channels.discord.guilds.<id>.channels",
},
missingRouteAllowlist: {
surface: "Discord guilds",
openBehavior: "with no guild/channel allowlist; any channel can trigger (mention-gated)",
remediation:
'Set channels.discord.groupPolicy="allowlist" and configure channels.discord.guilds.<id>.channels',
},
});
function normalizeDiscordAcpConversationId(conversationId: string) {
const normalized = conversationId.trim();
return normalized ? { conversationId: normalized } : null;
@@ -829,12 +793,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe>
},
},
},
security: {
resolveDmPolicy: resolveDiscordDmPolicy,
collectWarnings: collectDiscordSecurityWarnings,
collectAuditFindings: async (params) =>
(await loadDiscordSecurityAuditModule()).collectDiscordSecurityAuditFindings(params),
},
security: discordSecurityAdapter,
threading: {
scopedAccountReplyToMode: {
resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }),

View File

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

View File

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

View File

@@ -3,13 +3,9 @@ import {
createAccountScopedAllowlistNameResolver,
createFlatAllowlistOverrideResolver,
} from "openclaw/plugin-sdk/allowlist-config-edit";
import {
adaptScopedAccountAccessor,
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { adaptScopedAccountAccessor } from "openclaw/plugin-sdk/channel-config-helpers";
import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy";
import {
createChannelDirectoryAdapter,
createRuntimeDirectoryLiveAdapter,
@@ -62,7 +58,7 @@ import type { SlackProbe } from "./probe.js";
import { resolveSlackReplyBlocks } from "./reply-blocks.js";
import { getOptionalSlackRuntime, getSlackRuntime } from "./runtime.js";
import { fetchSlackScopes } from "./scopes.js";
import { collectSlackSecurityAuditFindings } from "./security-audit.js";
import { slackSecurityAdapter } from "./security.js";
import { slackSetupAdapter } from "./setup-core.js";
import { slackSetupWizard } from "./setup-surface.js";
import {
@@ -74,18 +70,6 @@ import {
import { parseSlackTarget } from "./target-parsing.js";
import { buildSlackThreadingToolContext } from "./threading-tool-context.js";
const resolveSlackDmPolicy = createScopedDmSecurityResolver<ResolvedSlackAccount>({
channelKey: "slack",
resolvePolicy: (account) => account.dm?.policy,
resolveAllowFrom: (account) => account.dm?.allowFrom,
allowFromPathSuffix: "dm.",
normalizeEntry: (raw) =>
raw
.trim()
.replace(/^(slack|user):/i, "")
.trim(),
});
async function resolveSlackHandleAction() {
return (
getOptionalSlackRuntime()?.channel?.slack?.handleSlackAction ??
@@ -289,26 +273,6 @@ const resolveSlackAllowlistNames = createAccountScopedAllowlistNameResolver({
(await loadSlackResolveUsersModule()).resolveSlackUserAllowlist({ token, entries }),
});
const collectSlackSecurityWarnings =
createOpenProviderConfiguredRouteWarningCollector<ResolvedSlackAccount>({
providerConfigPresent: (cfg) => cfg.channels?.slack !== undefined,
resolveGroupPolicy: (account) => account.config.groupPolicy,
resolveRouteAllowlistConfigured: (account) =>
Boolean(account.config.channels) && Object.keys(account.config.channels ?? {}).length > 0,
configureRouteAllowlist: {
surface: "Slack channels",
openScope: "any channel not explicitly denied",
groupPolicyPath: "channels.slack.groupPolicy",
routeAllowlistPath: "channels.slack.channels",
},
missingRouteAllowlist: {
surface: "Slack channels",
openBehavior: "with no channel allowlist; any channel can trigger (mention-gated)",
remediation:
'Set channels.slack.groupPolicy="allowlist" and configure channels.slack.channels',
},
});
export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = createChatChannelPlugin<
ResolvedSlackAccount,
SlackProbe
@@ -554,11 +518,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
},
},
},
security: {
resolveDmPolicy: resolveSlackDmPolicy,
collectWarnings: collectSlackSecurityWarnings,
collectAuditFindings: collectSlackSecurityAuditFindings,
},
security: slackSecurityAdapter,
threading: {
scopedAccountReplyToMode: {
resolveAccount: adaptScopedAccountAccessor(resolveSlackAccount),

View File

@@ -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", () => {

View File

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

View File

@@ -7,7 +7,6 @@ import type { ChannelMessageActionAdapter } from "openclaw/plugin-sdk/channel-co
import { clearAccountEntryFields, createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle";
import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
import { createAllowlistProviderRouteAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
import { attachChannelToResult } from "openclaw/plugin-sdk/channel-send-result";
import {
PAIRING_APPROVED_MESSAGE,
@@ -63,7 +62,7 @@ import type { TelegramProbe } from "./probe.js";
import * as probeModule from "./probe.js";
import { resolveTelegramReactionLevel } from "./reaction-level.js";
import { getTelegramRuntime } from "./runtime.js";
import { collectTelegramSecurityAuditFindings } from "./security-audit.js";
import { telegramSecurityAdapter } from "./security.js";
import { resolveTelegramSessionConversation } from "./session-conversation.js";
import { telegramSetupAdapter } from "./setup-core.js";
import { telegramSetupWizard } from "./setup-surface.js";
@@ -581,27 +580,6 @@ const resolveTelegramAllowlistGroupOverrides = createNestedAllowlistOverrideReso
resolveInnerEntries: (topicCfg) => topicCfg?.allowFrom,
});
const collectTelegramSecurityWarnings =
createAllowlistProviderRouteAllowlistWarningCollector<ResolvedTelegramAccount>({
providerConfigPresent: (cfg) => cfg.channels?.telegram !== undefined,
resolveGroupPolicy: (account) => account.config.groupPolicy,
resolveRouteAllowlistConfigured: (account) =>
Boolean(account.config.groups) && Object.keys(account.config.groups ?? {}).length > 0,
restrictSenders: {
surface: "Telegram groups",
openScope: "any member in allowed groups",
groupPolicyPath: "channels.telegram.groupPolicy",
groupAllowFromPath: "channels.telegram.groupAllowFrom",
},
noRouteAllowlist: {
surface: "Telegram groups",
routeAllowlistPath: "channels.telegram.groups",
routeScope: "group",
groupPolicyPath: "channels.telegram.groupPolicy",
groupAllowFromPath: "channels.telegram.groupAllowFrom",
},
});
export const telegramPlugin = createChatChannelPlugin({
base: {
...createTelegramPluginBase({
@@ -993,17 +971,7 @@ export const telegramPlugin = createChatChannelPlugin({
},
},
},
security: {
dm: {
channelKey: "telegram",
resolvePolicy: (account) => account.config.dmPolicy,
resolveAllowFrom: (account) => account.config.allowFrom,
policyPathSuffix: "dmPolicy",
normalizeEntry: (raw) => raw.replace(/^(telegram|tg):/i, ""),
},
collectWarnings: collectTelegramSecurityWarnings,
collectAuditFindings: collectTelegramSecurityAuditFindings,
},
security: telegramSecurityAdapter,
threading: {
topLevelReplyToMode: "telegram",
buildToolContext: (params) => buildTelegramThreadingToolContext(params),

View File

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

View File

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

View File

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

View File

@@ -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", () => {

View File

@@ -27,4 +27,47 @@ describe("command secret targets module import", () => {
expect(() => mod.getChannelsCommandSecretTargetIds()).toThrow("registry touched too early");
expect(listSecretTargetRegistryEntries).toHaveBeenCalledTimes(1);
});
it("can resolve configured-channel status targets without the full registry", async () => {
const listSecretTargetRegistryEntries = vi.fn(() => {
throw new Error("registry touched too early");
});
const loadBundledChannelSecretContractApi = vi.fn((channelId: string) =>
channelId === "telegram"
? {
secretTargetRegistryEntries: [
{
id: "channels.telegram.botToken",
targetType: "channels.telegram.botToken",
configFile: "openclaw.json",
pathPattern: "channels.telegram.botToken",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
],
}
: undefined,
);
vi.doMock("../secrets/target-registry.js", () => ({
discoverConfigSecretTargetsByIds: vi.fn(() => []),
listSecretTargetRegistryEntries,
}));
vi.doMock("../secrets/channel-contract-api.js", () => ({
loadBundledChannelSecretContractApi,
}));
const mod = await import("./command-secret-targets.js");
const targets = mod.getStatusCommandSecretTargetIds({
channels: { telegram: { botToken: "123456:ABCDEF" } },
});
expect(targets.has("channels.telegram.botToken")).toBe(true);
expect(targets.has("agents.defaults.memorySearch.remote.apiKey")).toBe(true);
expect(loadBundledChannelSecretContractApi).toHaveBeenCalledWith("telegram");
expect(listSecretTargetRegistryEntries).not.toHaveBeenCalled();
});
});

View File

@@ -1,5 +1,7 @@
import { listPotentialConfiguredChannelIds } from "../channels/config-presence.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizeOptionalAccountId } from "../routing/session-key.js";
import { loadBundledChannelSecretContractApi } from "../secrets/channel-contract-api.js";
import {
discoverConfigSecretTargetsByIds,
listSecretTargetRegistryEntries,
@@ -67,12 +69,39 @@ type CommandSecretTargets = {
let cachedCommandSecretTargets: CommandSecretTargets | undefined;
let cachedChannelSecretTargetIds: string[] | undefined;
const cachedBundledChannelSecretTargetIds = new Map<string, string[] | null>();
function getChannelSecretTargetIds(): string[] {
cachedChannelSecretTargetIds ??= idsByPrefix(["channels."]);
return cachedChannelSecretTargetIds;
}
function getBundledChannelSecretTargetIds(channelId: string): string[] {
const normalizedChannelId = channelId.trim();
if (!normalizedChannelId) {
return [];
}
if (cachedBundledChannelSecretTargetIds.has(normalizedChannelId)) {
return cachedBundledChannelSecretTargetIds.get(normalizedChannelId) ?? [];
}
const targetIds =
loadBundledChannelSecretContractApi(normalizedChannelId)
?.secretTargetRegistryEntries?.map((entry) => entry.id)
.filter((id) => id.startsWith(`channels.${normalizedChannelId}.`))
.toSorted() ?? null;
cachedBundledChannelSecretTargetIds.set(normalizedChannelId, targetIds);
return targetIds ?? [];
}
function getConfiguredChannelSecretTargetIds(
config: OpenClawConfig,
env: NodeJS.ProcessEnv = process.env,
): string[] {
return listPotentialConfiguredChannelIds(config, env, { includePersistedAuthState: false })
.toSorted()
.flatMap((channelId) => getBundledChannelSecretTargetIds(channelId));
}
function buildCommandSecretTargets(): CommandSecretTargets {
const channelTargetIds = getChannelSecretTargetIds();
return {
@@ -155,6 +184,13 @@ export function getChannelsCommandSecretTargetIds(): Set<string> {
return toTargetIdSet(getCommandSecretTargets().channels);
}
export function getConfiguredChannelsCommandSecretTargetIds(
config: OpenClawConfig,
env?: NodeJS.ProcessEnv,
): Set<string> {
return toTargetIdSet(getConfiguredChannelSecretTargetIds(config, env));
}
export function getModelsCommandSecretTargetIds(): Set<string> {
return toTargetIdSet(STATIC_MODEL_TARGET_IDS);
}
@@ -168,8 +204,14 @@ export function getAgentRuntimeCommandSecretTargetIds(params?: {
return toTargetIdSet(getCommandSecretTargets().agentRuntime);
}
export function getStatusCommandSecretTargetIds(): Set<string> {
return toTargetIdSet(getCommandSecretTargets().status);
export function getStatusCommandSecretTargetIds(
config?: OpenClawConfig,
env?: NodeJS.ProcessEnv,
): Set<string> {
const channelTargetIds = config
? getConfiguredChannelSecretTargetIds(config, env)
: getChannelSecretTargetIds();
return toTargetIdSet([...STATIC_STATUS_TARGET_IDS, ...channelTargetIds]);
}
export function getSecurityAuditCommandSecretTargetIds(): Set<string> {

View File

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

View File

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

View File

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

View File

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

View File

@@ -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", () => {

View File

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

View File

@@ -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": {

View File

@@ -33,6 +33,10 @@ vi.mock("../config/config.js", () => ({
readConfigFileSnapshot: () => mocks.readConfigFileSnapshot(),
}));
vi.mock("../channels/config-presence.js", () => ({
listPotentialConfiguredChannelIds: () => ["discord"],
}));
vi.mock("./channels/shared.js", () => ({
requireValidConfigSnapshot: (runtime: unknown) => mocks.requireValidConfigSnapshot(runtime),
formatChannelAccountLabel: ({
@@ -82,6 +86,10 @@ vi.mock("../channels/plugins/index.js", () => ({
(mocks.listChannelPlugins() as Array<{ id: string }>).find((plugin) => plugin.id === channel),
}));
vi.mock("../channels/plugins/read-only.js", () => ({
listReadOnlyChannelPluginsForConfig: () => mocks.listChannelPlugins(),
}));
vi.mock("../channels/account-snapshot-fields.js", () => ({
hasConfiguredUnavailableCredentialStatus: (account: Record<string, unknown>) =>
Object.values(account).includes("configured_unavailable"),
@@ -119,7 +127,7 @@ vi.mock("../channels/plugins/status.js", () => ({
}));
vi.mock("../cli/command-secret-targets.js", () => ({
getChannelsCommandSecretTargetIds: () => [],
getConfiguredChannelsCommandSecretTargetIds: () => [],
}));
vi.mock("../infra/channels-status-issues.js", () => ({
@@ -237,4 +245,27 @@ describe("channelsStatusCommand SecretRef fallback flow", () => {
expect(joined).not.toContain("secret unavailable in this command path");
expect(joined).not.toContain("token:config (unavailable)");
});
it("keeps JSON fallback structured without rendering config-only text", async () => {
mocks.callGateway.mockRejectedValue(new Error("gateway closed"));
mocks.requireValidConfigSnapshot.mockResolvedValue({ secretResolved: false, channels: {} });
mocks.resolveCommandConfigWithSecrets.mockResolvedValue({
resolvedConfig: { secretResolved: true, channels: {} },
effectiveConfig: { secretResolved: true, channels: {} },
diagnostics: [],
});
const { runtime, logs } = createRuntimeCapture();
await channelsStatusCommand({ json: true, probe: false }, runtime as never);
expect(mocks.listChannelPlugins).not.toHaveBeenCalled();
const payload = JSON.parse(logs.at(-1) ?? "{}");
expect(payload).toEqual(
expect.objectContaining({
gatewayReachable: false,
configOnly: true,
configuredChannels: ["discord"],
}),
);
});
});

View File

@@ -1,6 +1,6 @@
import { loadAuthProfileStore } from "../../agents/auth-profiles.js";
import { loadAuthProfileStoreWithoutExternalProfiles } from "../../agents/auth-profiles.js";
import { isChannelVisibleInConfiguredLists } from "../../channels/plugins/exposure.js";
import { listChannelPlugins } from "../../channels/plugins/index.js";
import { listReadOnlyChannelPluginsForConfig } from "../../channels/plugins/read-only.js";
import { buildChannelAccountSnapshot } from "../../channels/plugins/status.js";
import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js";
import type { ChannelAccountSnapshot } from "../../channels/plugins/types.public.js";
@@ -94,7 +94,7 @@ async function loadUsageWithProgress(
try {
return await withProgress(
{ label: "Fetching usage snapshot…", indeterminate: true, enabled: true },
async () => await loadProviderUsageSummary(),
async () => await loadProviderUsageSummary({ skipPluginAuthWithoutCredentialSource: true }),
);
} catch (err) {
runtime.error(String(err));
@@ -112,9 +112,9 @@ export async function channelsListCommand(
}
const includeUsage = opts.usage !== false;
const plugins = listChannelPlugins();
const plugins = listReadOnlyChannelPluginsForConfig(cfg);
const authStore = loadAuthProfileStore();
const authStore = loadAuthProfileStoreWithoutExternalProfiles();
const authProfiles = Object.entries(authStore.profiles).map(([profileId, profile]) => ({
id: profileId,
provider: profile.provider,
@@ -122,7 +122,9 @@ export async function channelsListCommand(
isExternal: false,
}));
if (opts.json) {
const usage = includeUsage ? await loadProviderUsageSummary() : undefined;
const usage = includeUsage
? await loadProviderUsageSummary({ skipPluginAuthWithoutCredentialSource: true })
: undefined;
const chat: Record<string, string[]> = {};
for (const plugin of plugins) {
chat[plugin.id] = plugin.config.listAccountIds(cfg);

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import { listChannelPlugins } from "../../channels/plugins/index.js";
import { listPotentialConfiguredChannelIds } from "../../channels/config-presence.js";
import { resolveCommandConfigWithSecrets } from "../../cli/command-config-resolution.js";
import { formatCliCommand } from "../../cli/command-format.js";
import { getChannelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js";
import { getConfiguredChannelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js";
import { withProgress } from "../../cli/progress.js";
import { readConfigFileSnapshot } from "../../config/config.js";
import { callGateway } from "../../gateway/call.js";
@@ -109,20 +109,19 @@ export function formatGatewayChannelsStatusLines(payload: Record<string, unknown
return buildChannelAccountLine(provider, account, bits);
});
const plugins = listChannelPlugins();
const accountsByChannel = payload.channelAccounts as Record<string, unknown> | undefined;
const accountPayloads: Partial<Record<string, Array<Record<string, unknown>>>> = {};
for (const plugin of plugins) {
const raw = accountsByChannel?.[plugin.id];
for (const channelId of Object.keys(accountsByChannel ?? {}).toSorted()) {
const raw = accountsByChannel?.[channelId];
if (Array.isArray(raw)) {
accountPayloads[plugin.id] = raw as Array<Record<string, unknown>>;
accountPayloads[channelId] = raw as Array<Record<string, unknown>>;
}
}
for (const plugin of plugins) {
const accounts = accountPayloads[plugin.id];
for (const channelId of Object.keys(accountPayloads).toSorted()) {
const accounts = accountPayloads[channelId];
if (accounts && accounts.length > 0) {
lines.push(...accountLines(plugin.id, accounts));
lines.push(...accountLines(channelId, accounts));
}
}
@@ -182,12 +181,27 @@ export async function channelsStatusCommand(
const { resolvedConfig } = await resolveCommandConfigWithSecrets({
config: cfg,
commandName: "channels status",
targetIds: getChannelsCommandSecretTargetIds(),
targetIds: getConfiguredChannelsCommandSecretTargetIds(cfg),
mode: "read_only_status",
runtime,
});
const snapshot = await readConfigFileSnapshot();
const mode = cfg.gateway?.mode === "remote" ? "remote" : "local";
if (opts.json) {
writeRuntimeJson(runtime, {
gatewayReachable: false,
error: String(err),
configOnly: true,
config: {
path: snapshot.path,
mode,
},
configuredChannels: listPotentialConfiguredChannelIds(resolvedConfig, process.env, {
includePersistedAuthState: false,
}).toSorted(),
});
return;
}
runtime.log(
(
await formatConfigChannelsStatusLines(

View File

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

View File

@@ -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(
[

View File

@@ -11,11 +11,21 @@ async function loadNoteRuntime() {
return import("../terminal/note.js");
}
function hasConfigOAuthProfiles(cfg: OpenClawConfig): boolean {
return Object.values(cfg.auth?.profiles ?? {}).some((profile) => profile?.mode === "oauth");
}
export async function maybeRepairLegacyOAuthProfileIds(
cfg: OpenClawConfig,
prompter: DoctorPrompter,
): Promise<OpenClawConfig> {
if (!hasConfigOAuthProfiles(cfg)) {
return cfg;
}
const store = ensureAuthProfileStore();
if (Object.keys(store.profiles).length === 0) {
return cfg;
}
let nextCfg = cfg;
const { resolvePluginProviders } = await loadProviderRuntime();
const providers = resolvePluginProviders({

View File

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

View File

@@ -0,0 +1,36 @@
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { DoctorPrompter } from "./doctor-prompter.js";
const authProfileMocks = vi.hoisted(() => ({
ensureAuthProfileStore: vi.fn(() => {
throw new Error("unexpected auth profile load");
}),
hasAnyAuthProfileStoreSource: vi.fn(() => false),
resolveApiKeyForProfile: vi.fn(),
resolveProfileUnusableUntilForDisplay: vi.fn(),
}));
vi.mock("../agents/auth-profiles.js", () => ({
ensureAuthProfileStore: authProfileMocks.ensureAuthProfileStore,
hasAnyAuthProfileStoreSource: authProfileMocks.hasAnyAuthProfileStoreSource,
resolveApiKeyForProfile: authProfileMocks.resolveApiKeyForProfile,
resolveProfileUnusableUntilForDisplay: authProfileMocks.resolveProfileUnusableUntilForDisplay,
}));
vi.mock("../terminal/note.js", () => ({ note: vi.fn() }));
import { noteAuthProfileHealth } from "./doctor-auth.js";
describe("noteAuthProfileHealth", () => {
it("skips external auth profile resolution when no auth source exists", async () => {
await noteAuthProfileHealth({
cfg: { channels: { telegram: { enabled: true } } } as OpenClawConfig,
prompter: {} as DoctorPrompter,
allowKeychainPrompt: false,
});
expect(authProfileMocks.hasAnyAuthProfileStoreSource).toHaveBeenCalledOnce();
expect(authProfileMocks.ensureAuthProfileStore).not.toHaveBeenCalled();
});
});

View File

@@ -6,6 +6,7 @@ import {
import {
type AuthCredentialReasonCode,
ensureAuthProfileStore,
hasAnyAuthProfileStoreSource,
resolveApiKeyForProfile,
resolveProfileUnusableUntilForDisplay,
} from "../agents/auth-profiles.js";
@@ -207,6 +208,12 @@ export async function noteAuthProfileHealth(params: {
prompter: DoctorPrompter;
allowKeychainPrompt: boolean;
}): Promise<void> {
if (
Object.keys(params.cfg.auth?.profiles ?? {}).length === 0 &&
!hasAnyAuthProfileStoreSource()
) {
return;
}
const store = ensureAuthProfileStore(undefined, {
allowKeychainPrompt: params.allowKeychainPrompt,
});

View File

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

View File

@@ -10,7 +10,7 @@ const resolveAgentDir = vi.hoisted(() => vi.fn(() => "/tmp/agent-default"));
const resolveAgentWorkspaceDir = vi.hoisted(() => vi.fn(() => "/tmp/agent-default/workspace"));
const resolveMemorySearchConfig = vi.hoisted(() => vi.fn());
const resolveApiKeyForProvider = vi.hoisted(() => vi.fn());
const resolveActiveMemoryBackendConfig = vi.hoisted(() => vi.fn());
const hasAnyAuthProfileStoreSource = vi.hoisted(() => vi.fn(() => true));
const getActiveMemorySearchManager = vi.hoisted(() => vi.fn());
type CheckQmdBinaryAvailability = typeof checkQmdBinaryAvailabilityFn;
const checkQmdBinaryAvailability = vi.hoisted(() =>
@@ -37,10 +37,15 @@ vi.mock("../agents/memory-search.js", () => ({
vi.mock("../agents/model-auth.js", () => ({
resolveApiKeyForProvider,
resolveEnvApiKey: vi.fn(() => null),
resolveUsableCustomProviderApiKey: vi.fn(() => null),
}));
vi.mock("../agents/auth-profiles.js", () => ({
hasAnyAuthProfileStoreSource,
}));
vi.mock("../plugins/memory-runtime.js", () => ({
resolveActiveMemoryBackendConfig,
getActiveMemorySearchManager,
}));
@@ -145,8 +150,8 @@ describe("noteMemorySearchHealth", () => {
resolveMemorySearchConfig.mockReset();
resolveApiKeyForProvider.mockReset();
resolveApiKeyForProvider.mockRejectedValue(new Error("missing key"));
resolveActiveMemoryBackendConfig.mockReset();
resolveActiveMemoryBackendConfig.mockReturnValue({ backend: "builtin", citations: "auto" });
hasAnyAuthProfileStoreSource.mockReset();
hasAnyAuthProfileStoreSource.mockReturnValue(true);
getActiveMemorySearchManager.mockReset();
getActiveMemorySearchManager.mockResolvedValue({
manager: {
@@ -215,18 +220,14 @@ describe("noteMemorySearchHealth", () => {
});
it("does not warn when QMD backend is active", async () => {
resolveActiveMemoryBackendConfig.mockReturnValue({
backend: "qmd",
citations: "auto",
qmd: { command: "qmd" },
});
const qmdCfg = { memory: { backend: "qmd", qmd: { command: "qmd" } } } as OpenClawConfig;
resolveMemorySearchConfig.mockReturnValue({
provider: "auto",
local: {},
remote: {},
});
await noteMemorySearchHealth(cfg, {});
await noteMemorySearchHealth(qmdCfg, {});
expect(note).not.toHaveBeenCalled();
expect(checkQmdBinaryAvailability).toHaveBeenCalledWith({
@@ -237,11 +238,7 @@ describe("noteMemorySearchHealth", () => {
});
it("warns when QMD backend is active but the qmd binary is unavailable", async () => {
resolveActiveMemoryBackendConfig.mockReturnValue({
backend: "qmd",
citations: "auto",
qmd: { command: "qmd" },
});
const qmdCfg = { memory: { backend: "qmd", qmd: { command: "qmd" } } } as OpenClawConfig;
checkQmdBinaryAvailability.mockResolvedValueOnce({
available: false,
error: "spawn qmd ENOENT",
@@ -252,7 +249,7 @@ describe("noteMemorySearchHealth", () => {
remote: {},
});
await noteMemorySearchHealth(cfg, {});
await noteMemorySearchHealth(qmdCfg, {});
expect(note).toHaveBeenCalledTimes(1);
const message = String(note.mock.calls[0]?.[0] ?? "");
@@ -460,7 +457,29 @@ describe("noteMemorySearchHealth", () => {
expect(note).toHaveBeenCalledTimes(1);
const providerCalls = resolveApiKeyForProvider.mock.calls as Array<[{ provider: string }]>;
const providersChecked = providerCalls.map(([arg]) => arg.provider);
expect(providersChecked).toEqual(["openai"]);
expect(providersChecked).toEqual([
"github-copilot",
"openai",
"google",
"voyage",
"mistral",
"amazon-bedrock",
]);
});
it("skips auth-profile probing in auto mode when no auth store exists", async () => {
hasAnyAuthProfileStoreSource.mockReturnValue(false);
resolveMemorySearchConfig.mockReturnValue({
provider: "auto",
local: {},
remote: {},
});
await noteMemorySearchHealth(cfg);
const providerCalls = resolveApiKeyForProvider.mock.calls as Array<[{ provider: string }]>;
const providersChecked = providerCalls.map(([arg]) => arg.provider);
expect(providersChecked).toEqual(["amazon-bedrock"]);
});
it("uses runtime-derived env var hints for explicit providers", async () => {

View File

@@ -4,8 +4,13 @@ import {
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
} from "../agents/agent-scope.js";
import { hasAnyAuthProfileStoreSource } from "../agents/auth-profiles.js";
import { resolveMemorySearchConfig } from "../agents/memory-search.js";
import { resolveApiKeyForProvider } from "../agents/model-auth.js";
import {
resolveApiKeyForProvider,
resolveEnvApiKey,
resolveUsableCustomProviderApiKey,
} from "../agents/model-auth.js";
import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { formatErrorMessage } from "../infra/errors.js";
@@ -15,29 +20,19 @@ import { hasConfiguredMemorySecretInput } from "../memory-host-sdk/secret.js";
import {
auditDreamingArtifacts,
auditShortTermPromotionArtifacts,
getBuiltinMemoryEmbeddingProviderDoctorMetadata,
listBuiltinAutoSelectMemoryEmbeddingProviderDoctorMetadata,
repairDreamingArtifacts,
repairShortTermPromotionArtifacts,
type DreamingArtifactsAuditSummary,
type ShortTermAuditSummary,
} from "../plugin-sdk/memory-core-engine-runtime.js";
import {
getActiveMemorySearchManager,
resolveActiveMemoryBackendConfig,
} from "../plugins/memory-runtime.js";
import { getActiveMemorySearchManager } from "../plugins/memory-runtime.js";
import { getProviderEnvVars } from "../secrets/provider-env-vars.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { note } from "../terminal/note.js";
import { resolveUserPath } from "../utils.js";
import type { DoctorPrompter } from "./doctor-prompter.js";
import { isRecord } from "./doctor/shared/legacy-config-record-shared.js";
function resolveSuggestedRemoteMemoryProvider(): string | undefined {
return listBuiltinAutoSelectMemoryEmbeddingProviderDoctorMetadata().find(
(provider) => provider.transport === "remote",
)?.providerId;
}
type RuntimeMemoryAuditContext = {
workspaceDir?: string;
backend?: string;
@@ -45,6 +40,94 @@ type RuntimeMemoryAuditContext = {
qmdCollections?: number;
};
type MemoryEmbeddingProviderDoctorMetadata = {
providerId: string;
authProviderId: string;
transport: "local" | "remote";
autoSelectPriority?: number;
};
const BUNDLED_MEMORY_EMBEDDING_PROVIDER_DOCTOR_METADATA: MemoryEmbeddingProviderDoctorMetadata[] = [
{
providerId: "github-copilot",
authProviderId: "github-copilot",
transport: "remote",
autoSelectPriority: 15,
},
{
providerId: "openai",
authProviderId: "openai",
transport: "remote",
autoSelectPriority: 20,
},
{
providerId: "gemini",
authProviderId: "google",
transport: "remote",
autoSelectPriority: 30,
},
{
providerId: "voyage",
authProviderId: "voyage",
transport: "remote",
autoSelectPriority: 40,
},
{
providerId: "mistral",
authProviderId: "mistral",
transport: "remote",
autoSelectPriority: 50,
},
{
providerId: "bedrock",
authProviderId: "amazon-bedrock",
transport: "remote",
autoSelectPriority: 60,
},
];
function resolveMemoryEmbeddingProviderDoctorMetadata(
providerId: string,
): (MemoryEmbeddingProviderDoctorMetadata & { envVars: string[] }) | null {
const metadata =
BUNDLED_MEMORY_EMBEDDING_PROVIDER_DOCTOR_METADATA.find(
(candidate) => candidate.providerId === providerId,
) ?? null;
if (!metadata) {
return null;
}
return {
...metadata,
envVars: getProviderEnvVars(metadata.authProviderId),
};
}
function listAutoSelectMemoryEmbeddingProviderDoctorMetadata(): Array<
MemoryEmbeddingProviderDoctorMetadata & { envVars: string[] }
> {
return BUNDLED_MEMORY_EMBEDDING_PROVIDER_DOCTOR_METADATA.filter(
(provider) => typeof provider.autoSelectPriority === "number",
)
.toSorted((a, b) => (a.autoSelectPriority ?? 0) - (b.autoSelectPriority ?? 0))
.map((provider) => ({
providerId: provider.providerId,
authProviderId: provider.authProviderId,
transport: provider.transport,
autoSelectPriority: provider.autoSelectPriority,
envVars: getProviderEnvVars(provider.authProviderId),
}));
}
function resolveSuggestedRemoteMemoryProvider(): string | undefined {
return listAutoSelectMemoryEmbeddingProviderDoctorMetadata().find(
(provider) => provider.transport === "remote",
)?.providerId;
}
function resolveConfiguredQmdBackendConfig(cfg: OpenClawConfig): OpenClawConfig["memory"] | null {
return cfg.memory?.backend === "qmd" ? cfg.memory : null;
}
async function resolveRuntimeMemoryAuditContext(
cfg: OpenClawConfig,
): Promise<RuntimeMemoryAuditContext | null> {
@@ -242,21 +325,17 @@ export async function noteMemorySearchHealth(
// QMD backend handles embeddings internally (e.g. embeddinggemma) — no
// separate embedding provider is needed. Skip the provider check entirely.
const backendConfig = resolveActiveMemoryBackendConfig({ cfg, agentId });
if (!backendConfig) {
note("No active memory plugin is registered for the current config.", "Memory search");
return;
}
if (backendConfig.backend === "qmd") {
const qmdBackendConfig = resolveConfiguredQmdBackendConfig(cfg);
if (qmdBackendConfig) {
const qmdCheck = await checkQmdBinaryAvailability({
command: backendConfig.qmd?.command ?? "qmd",
command: qmdBackendConfig.qmd?.command ?? "qmd",
env: process.env,
cwd: resolveAgentWorkspaceDir(cfg, agentId),
});
if (!qmdCheck.available) {
note(
[
`QMD memory backend is configured, but the qmd binary could not be started (${backendConfig.qmd?.command ?? "qmd"}).`,
`QMD memory backend is configured, but the qmd binary could not be started (${qmdBackendConfig.qmd?.command ?? "qmd"}).`,
qmdCheck.error ? `Probe error: ${qmdCheck.error}` : null,
"",
"Fix (pick one):",
@@ -373,7 +452,7 @@ export async function noteMemorySearchHealth(
if (hasLocalEmbeddings(resolved.local)) {
return;
}
const autoSelectProviders = listBuiltinAutoSelectMemoryEmbeddingProviderDoctorMetadata().filter(
const autoSelectProviders = listAutoSelectMemoryEmbeddingProviderDoctorMetadata().filter(
(provider) => provider.transport === "remote",
);
for (const provider of autoSelectProviders) {
@@ -450,10 +529,20 @@ async function hasApiKeyForProvider(
cfg: OpenClawConfig,
agentDir: string,
): Promise<boolean> {
const metadata = getBuiltinMemoryEmbeddingProviderDoctorMetadata(provider);
const metadata = resolveMemoryEmbeddingProviderDoctorMetadata(provider);
const authProviderId = metadata?.authProviderId ?? provider;
if (
resolveEnvApiKey(authProviderId) ||
resolveUsableCustomProviderApiKey({ cfg, provider: authProviderId })
) {
return true;
}
if (authProviderId !== "amazon-bedrock" && !hasAnyAuthProfileStoreSource(agentDir)) {
return false;
}
try {
await resolveApiKeyForProvider({
provider: metadata?.authProviderId ?? provider,
provider: authProviderId,
cfg,
agentDir,
});
@@ -464,7 +553,7 @@ async function hasApiKeyForProvider(
}
function resolvePrimaryMemoryProviderEnvVar(provider: string): string {
const metadata = getBuiltinMemoryEmbeddingProviderDoctorMetadata(provider);
const metadata = resolveMemoryEmbeddingProviderDoctorMetadata(provider);
return metadata?.envVars[0] ?? `${provider.toUpperCase()}_API_KEY`;
}

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
import { listChannelPlugins } from "../channels/plugins/index.js";
import { listReadOnlyChannelPluginsForConfig } from "../channels/plugins/read-only.js";
import type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
import type { ChannelAccountSnapshot } from "../channels/plugins/types.public.js";
import { inspectReadOnlyChannelAccount } from "../channels/read-only-account-inspect.js";
@@ -247,10 +247,11 @@ export async function getHealthSnapshot(params?: {
const cappedTimeout = timeoutMs === undefined ? DEFAULT_TIMEOUT_MS : Math.max(50, timeoutMs);
const doProbe = params?.probe !== false;
const channels: Record<string, ChannelHealthSummary> = {};
const channelOrder = listChannelPlugins().map((plugin) => plugin.id);
const plugins = listReadOnlyChannelPluginsForConfig(cfg);
const channelOrder = plugins.map((plugin) => plugin.id);
const channelLabels: Record<string, string> = {};
for (const plugin of listChannelPlugins()) {
for (const plugin of plugins) {
channelLabels[plugin.id] = plugin.meta.label ?? plugin.id;
const accountIds = plugin.config.listAccountIds(cfg);
const defaultAccountId = resolveChannelDefaultAccountId({
@@ -447,9 +448,10 @@ export async function healthCommand(
? resolvedAgents
: resolvedAgents.filter((agent) => agent.agentId === defaultAgentId);
const channelBindings = buildChannelAccountBindings(cfg);
const displayPlugins = listReadOnlyChannelPluginsForConfig(cfg);
if (debugEnabled) {
runtime.log(info("[debug] local channel accounts"));
for (const plugin of listChannelPlugins()) {
for (const plugin of displayPlugins) {
const accountIds = plugin.config.listAccountIds(cfg);
const defaultAccountId = resolveChannelDefaultAccountId({
plugin,
@@ -496,7 +498,7 @@ export async function healthCommand(
}
}
const channelAccountFallbacks = Object.fromEntries(
listChannelPlugins().map((plugin) => {
displayPlugins.map((plugin) => {
const accountIds = plugin.config.listAccountIds(cfg);
const defaultAccountId = resolveChannelDefaultAccountId({
plugin,
@@ -547,7 +549,7 @@ export async function healthCommand(
for (const line of channelLines) {
runtime.log(styleHealthChannelLine(line, rich));
}
for (const plugin of listChannelPlugins()) {
for (const plugin of displayPlugins) {
const channelSummary = summary.channels?.[plugin.id];
if (!channelSummary || channelSummary.linked !== true) {
continue;

View File

@@ -6,7 +6,7 @@ import {
formatChannelAllowFrom,
} from "../../channels/account-summary.js";
import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js";
import { listChannelPlugins } from "../../channels/plugins/index.js";
import { listReadOnlyChannelPluginsForConfig } from "../../channels/plugins/read-only.js";
import { formatChannelStatusState } from "../../channels/plugins/status-state.js";
import type {
ChannelAccountSnapshot,
@@ -205,7 +205,7 @@ export async function buildChannelsTable(
rows: Array<Record<string, string>>;
}> = [];
for (const plugin of listChannelPlugins()) {
for (const plugin of listReadOnlyChannelPluginsForConfig(cfg)) {
const accountIds = plugin.config.listAccountIds(cfg);
const defaultAccountId = resolveChannelDefaultAccountId({
plugin,

View File

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

View File

@@ -57,6 +57,7 @@ describe("status-runtime-shared", () => {
deep: false,
includeFilesystem: true,
includeChannelSecurity: true,
plugins: expect.any(Array),
});
});
@@ -244,6 +245,7 @@ describe("status-runtime-shared", () => {
deep: false,
includeFilesystem: true,
includeChannelSecurity: true,
plugins: expect.any(Array),
});
});
});

View File

@@ -1,3 +1,4 @@
import { listReadOnlyChannelPluginsForConfig } from "../channels/plugins/read-only.js";
import type { OpenClawConfig } from "../config/types.js";
import type { HeartbeatEventPayload } from "../infra/heartbeat-events.js";
import type { HealthSummary } from "./health.js";
@@ -33,6 +34,7 @@ export async function resolveStatusSecurityAudit(params: {
deep: false,
includeFilesystem: true,
includeChannelSecurity: true,
plugins: listReadOnlyChannelPluginsForConfig(params.config),
});
}

View File

@@ -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", () => ({

View File

@@ -1,4 +1,4 @@
import { listChannelPlugins } from "../channels/plugins/index.js";
import { listReadOnlyChannelPluginsForConfig } from "../channels/plugins/read-only.js";
import type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
import type { ChannelAccountSnapshot } from "../channels/plugins/types.public.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
@@ -15,7 +15,7 @@ export type LinkChannelContext = {
export async function resolveLinkChannelContext(
cfg: OpenClawConfig,
): Promise<LinkChannelContext | null> {
for (const plugin of listChannelPlugins()) {
for (const plugin of listReadOnlyChannelPluginsForConfig(cfg)) {
const { defaultAccountId, account, enabled, configured } =
await resolveDefaultChannelAccountContext(plugin, cfg, {
mode: "read_only",

View File

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

View File

@@ -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({

View File

@@ -168,7 +168,9 @@ export async function collectStatusScanOverview(params: {
).resolveCommandConfigWithSecrets({
config: loadedConfig,
commandName: params.commandName,
targetIds: (await loadCommandSecretTargetsModule()).getStatusCommandSecretTargetIds(),
targetIds: (await loadCommandSecretTargetsModule()).getStatusCommandSecretTargetIds(
loadedConfig,
),
mode: "read_only_status",
...(params.runtime ? { runtime: params.runtime } : {}),
}),
@@ -279,6 +281,7 @@ export async function collectStatusScanOverview(params: {
export async function resolveStatusSummaryFromOverview(params: {
overview: Pick<StatusScanOverviewResult, "skipColdStartNetworkChecks" | "cfg" | "sourceConfig">;
includeChannelSummary?: boolean;
}) {
if (params.overview.skipColdStartNetworkChecks) {
return buildColdStartStatusSummary();
@@ -287,6 +290,7 @@ export async function resolveStatusSummaryFromOverview(params: {
getStatusSummary({
config: params.overview.cfg,
sourceConfig: params.overview.sourceConfig,
includeChannelSummary: params.includeChannelSummary,
}),
);
}

View File

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

View File

@@ -1,5 +1,4 @@
import { hasPotentialConfiguredChannels } from "../channels/config-presence.js";
import { ensureCliPluginRegistryLoaded } from "../cli/plugin-registry-loader.js";
import type { RuntimeEnv } from "../runtime.js";
import { executeStatusScanFromOverview } from "./status.scan-execute.ts";
import {
@@ -12,6 +11,7 @@ import type { StatusScanResult } from "./status.scan-result.ts";
type StatusJsonScanPolicy = {
commandName: string;
allowMissingConfigFastPath?: boolean;
includeChannelSummary?: boolean;
resolveHasConfiguredChannels: (
cfg: Parameters<typeof hasPotentialConfiguredChannels>[0],
) => boolean;
@@ -35,18 +35,12 @@ export async function scanStatusJsonWithPolicy(
resolveHasConfiguredChannels: policy.resolveHasConfiguredChannels,
includeChannelsData: false,
});
if (overview.hasConfiguredChannels) {
await ensureCliPluginRegistryLoaded({
scope: "configured-channels",
routeLogsToStderr: true,
config: overview.cfg,
activationSourceConfig: overview.sourceConfig,
});
}
return await executeStatusScanFromOverview({
overview,
runtime,
summary: {
includeChannelSummary: policy.includeChannelSummary,
},
resolveMemory: policy.resolveMemory,
channelIssues: [],
channels: { rows: [], details: [] },
@@ -64,6 +58,7 @@ export async function scanStatusJsonFast(
return await scanStatusJsonWithPolicy(opts, runtime, {
commandName: "status --json",
allowMissingConfigFastPath: true,
includeChannelSummary: false,
resolveHasConfiguredChannels: (cfg) =>
hasPotentialConfiguredChannels(cfg, process.env, {
includePersistedAuthState: false,

View File

@@ -109,9 +109,13 @@ export function createStatusPluginRegistryModuleMock(
export function createStatusPluginStatusModuleMock(
mocks: Pick<StatusScanSharedMocks, "buildPluginCompatibilityNotices">,
): { buildPluginCompatibilityNotices: StatusScanSharedMocks["buildPluginCompatibilityNotices"] } {
): {
buildPluginCompatibilityNotices: StatusScanSharedMocks["buildPluginCompatibilityNotices"];
buildPluginCompatibilitySnapshotNotices: StatusScanSharedMocks["buildPluginCompatibilityNotices"];
} {
return {
buildPluginCompatibilityNotices: mocks.buildPluginCompatibilityNotices,
buildPluginCompatibilitySnapshotNotices: mocks.buildPluginCompatibilityNotices,
};
}

View File

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

View File

@@ -1,6 +1,6 @@
import { hasPotentialConfiguredChannels } from "../channels/config-presence.js";
import { withProgress } from "../cli/progress.js";
import { buildPluginCompatibilityNotices } from "../plugins/status.js";
import { buildPluginCompatibilitySnapshotNotices } from "../plugins/status.js";
import type { RuntimeEnv } from "../runtime.js";
import { executeStatusScanFromOverview } from "./status.scan-execute.ts";
import { resolveStatusMemoryStatusSnapshot } from "./status.scan-memory.ts";
@@ -59,7 +59,7 @@ export async function scanStatus(
});
progress.setLabel("Checking plugins…");
const pluginCompatibility = buildPluginCompatibilityNotices({ config: overview.cfg });
const pluginCompatibility = buildPluginCompatibilitySnapshotNotices({ config: overview.cfg });
progress.tick();
progress.setLabel("Checking memory and sessions…");

View File

@@ -150,6 +150,16 @@ describe("getStatusSummary", () => {
expect(resolveLinkChannelContext).not.toHaveBeenCalled();
});
it("skips channel summary imports when explicitly disabled", async () => {
const summary = await getStatusSummary({ includeChannelSummary: false });
expect(summary.channelSummary).toEqual([]);
expect(summary.linkChannel).toBeUndefined();
expect(statusSummaryMocks.hasPotentialConfiguredChannels).not.toHaveBeenCalled();
expect(buildChannelSummary).not.toHaveBeenCalled();
expect(resolveLinkChannelContext).not.toHaveBeenCalled();
});
it("does not trigger async context warmup while building status summaries", async () => {
await getStatusSummary();

View File

@@ -105,11 +105,12 @@ export function redactSensitiveStatusSummary(summary: StatusSummary): StatusSumm
export async function getStatusSummary(
options: {
includeSensitive?: boolean;
includeChannelSummary?: boolean;
config?: OpenClawConfig;
sourceConfig?: OpenClawConfig;
} = {},
): Promise<StatusSummary> {
const { includeSensitive = true } = options;
const { includeSensitive = true, includeChannelSummary = true } = options;
const {
classifySessionKey,
resolveConfiguredStatusModelRef,
@@ -117,7 +118,7 @@ export async function getStatusSummary(
resolveSessionModelRef,
} = await loadStatusSummaryRuntimeModule();
const cfg = options.config ?? (await loadConfigIoModule()).loadConfig();
const needsChannelPlugins = hasPotentialConfiguredChannels(cfg);
const needsChannelPlugins = includeChannelSummary && hasPotentialConfiguredChannels(cfg);
const linkContext = needsChannelPlugins
? await loadLinkChannelModule().then(({ resolveLinkChannelContext }) =>
resolveLinkChannelContext(cfg),

View File

@@ -417,14 +417,18 @@ async function runGatewayHealthChecks(ctx: DoctorHealthFlowContext): Promise<voi
}
async function runMemorySearchHealthContribution(ctx: DoctorHealthFlowContext): Promise<void> {
await maybeRepairMemoryRecallHealth({
cfg: ctx.cfg,
prompter: ctx.prompter,
});
if (ctx.prompter.shouldRepair) {
await maybeRepairMemoryRecallHealth({
cfg: ctx.cfg,
prompter: ctx.prompter,
});
}
await noteMemorySearchHealth(ctx.cfg, {
gatewayMemoryProbe: ctx.gatewayMemoryProbe ?? { checked: false, ready: false },
});
await noteMemoryRecallHealth(ctx.cfg);
if (ctx.options.deep === true) {
await noteMemoryRecallHealth(ctx.cfg);
}
}
async function runDevicePairingHealth(ctx: DoctorHealthFlowContext): Promise<void> {

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ const ensureAuthProfileStoreMock = vi.fn(() => ({
vi.mock("../agents/auth-profiles.js", () => ({
dedupeProfileIds: (profileIds: string[]) => [...new Set(profileIds)],
ensureAuthProfileStore: () => ensureAuthProfileStoreMock(),
hasAnyAuthProfileStoreSource: () => false,
listProfilesForProvider: () => [],
resolveApiKeyForProfile: async () => null,
resolveAuthProfileOrder: () => [],
@@ -55,4 +56,17 @@ describe("resolveProviderAuths plugin boundary", () => {
]);
expect(ensureAuthProfileStoreMock).not.toHaveBeenCalled();
});
it("skips plugin usage auth when requested and no direct credential source exists", async () => {
await expect(
resolveProviderAuths({
providers: ["zai"],
skipPluginAuthWithoutCredentialSource: true,
env: {},
}),
).resolves.toEqual([]);
expect(resolveProviderUsageAuthWithPluginMock).not.toHaveBeenCalled();
expect(ensureAuthProfileStoreMock).not.toHaveBeenCalled();
});
});

View File

@@ -1,10 +1,12 @@
import {
dedupeProfileIds,
ensureAuthProfileStore,
hasAnyAuthProfileStoreSource,
listProfilesForProvider,
resolveApiKeyForProfile,
resolveAuthProfileOrder,
} from "../agents/auth-profiles.js";
import { resolveEnvApiKey } from "../agents/model-auth-env.js";
import { isNonSecretApiKeyMarker } from "../agents/model-auth-markers.js";
import { resolveUsableCustomProviderApiKey } from "../agents/model-auth.js";
import { normalizeProviderId } from "../agents/model-selection.js";
@@ -25,6 +27,7 @@ type UsageAuthState = {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
agentDir?: string;
allowAuthProfileStore: boolean;
store?: AuthStore;
};
@@ -35,7 +38,7 @@ function resolveUsageAuthStore(state: UsageAuthState): AuthStore {
return state.store;
}
function resolveProviderApiKeyFromConfigAndStore(params: {
function resolveProviderApiKeyFromConfig(params: {
state: UsageAuthState;
providerIds: string[];
envDirect?: Array<string | undefined>;
@@ -46,14 +49,31 @@ function resolveProviderApiKeyFromConfigAndStore(params: {
}
for (const providerId of params.providerIds) {
const envKey = resolveEnvApiKey(providerId, params.state.env)?.apiKey;
if (envKey) {
return envKey;
}
const key = resolveUsableCustomProviderApiKey({
cfg: params.state.cfg,
provider: providerId,
env: params.state.env,
})?.apiKey;
if (key) {
return key;
}
}
return undefined;
}
function resolveProviderApiKeyFromConfigAndStore(params: {
state: UsageAuthState;
providerIds: string[];
envDirect?: Array<string | undefined>;
}): string | undefined {
const configKey = resolveProviderApiKeyFromConfig(params);
if (configKey || !params.state.allowAuthProfileStore) {
return configKey;
}
const normalizedProviderIds = new Set(
params.providerIds.map((providerId) => normalizeProviderId(providerId)).filter(Boolean),
@@ -92,6 +112,9 @@ async function resolveOAuthToken(params: {
state: UsageAuthState;
provider: string;
}): Promise<ProviderAuth | null> {
if (!params.state.allowAuthProfileStore) {
return null;
}
const store = resolveUsageAuthStore(params.state);
const order = resolveAuthProfileOrder({
cfg: params.state.cfg,
@@ -208,26 +231,45 @@ export async function resolveProviderAuths(params: {
agentDir?: string;
config?: OpenClawConfig;
env?: NodeJS.ProcessEnv;
skipPluginAuthWithoutCredentialSource?: boolean;
}): Promise<ProviderAuth[]> {
if (params.auth) {
return params.auth;
}
const state: UsageAuthState = {
const stateBase = {
cfg: params.config ?? loadConfig(),
env: params.env ?? process.env,
agentDir: params.agentDir,
};
const hasDirectCredentialSource = params.providers.some((provider) =>
Boolean(
resolveProviderApiKeyFromConfig({
state: { ...stateBase, allowAuthProfileStore: false },
providerIds: [provider],
}),
),
);
const allowAuthProfileStore =
!params.skipPluginAuthWithoutCredentialSource ||
hasDirectCredentialSource ||
hasAnyAuthProfileStoreSource(params.agentDir);
const state: UsageAuthState = {
...stateBase,
allowAuthProfileStore,
};
const auths: ProviderAuth[] = [];
for (const provider of params.providers) {
const pluginAuth = await resolveProviderUsageAuthViaPlugin({
state,
provider,
});
if (pluginAuth) {
auths.push(pluginAuth);
continue;
if (!params.skipPluginAuthWithoutCredentialSource || allowAuthProfileStore) {
const pluginAuth = await resolveProviderUsageAuthViaPlugin({
state,
provider,
});
if (pluginAuth) {
auths.push(pluginAuth);
continue;
}
}
const fallbackAuth = await resolveProviderUsageAuthFallback({
state,

View File

@@ -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: [] };

View File

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

View File

@@ -1,6 +1,9 @@
import path from "node:path";
import { resolveSandboxConfigForAgent } from "../agents/sandbox/config.js";
import { hasPotentialConfiguredChannels } from "../channels/config-presence.js";
import {
hasPotentialConfiguredChannels,
listPotentialConfiguredChannelIds,
} from "../channels/config-presence.js";
import type { listChannelPlugins } from "../channels/plugins/index.js";
import type { ConfigFileSnapshot, OpenClawConfig } from "../config/config.js";
import { resolveConfigPath, resolveStateDir } from "../config/paths.js";
@@ -352,6 +355,14 @@ async function collectPluginSecurityAuditFindings(
requestedPluginIds.add(normalized);
}
}
if (context.includeChannelSecurity && context.plugins !== undefined) {
for (const channelId of listPotentialConfiguredChannelIds(
context.sourceConfig,
context.env,
)) {
requestedPluginIds.delete(channelId);
}
}
if (requestedPluginIds.size === 0) {
return [];
}