fix(cli): keep channel status checks off plugin runtimes (#69479)

Merged via squash.

Prepared head SHA: 63f6e416a9
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Gustavo Madeira Santana
2026-04-21 13:53:08 -04:00
committed by GitHub
parent 09c5669299
commit 24db09a19b
85 changed files with 3176 additions and 366 deletions

View File

@@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
- Auth/commands: require owner identity (an owner-candidate match or internal `operator.admin`) for owner-enforced commands instead of treating wildcard channel `allowFrom` or empty owner-candidate lists as sufficient, so non-owner senders can no longer reach owner-only commands through a permissive fallback when `enforceOwnerForCommands=true` and `commands.ownerAllowFrom` is unset. (#69774) Thanks @drobison00.
- Control UI/CSP: tighten `img-src` to `'self' data:` only, and make Control UI avatar helpers drop remote `http(s)` and protocol-relative URLs so the UI falls back to the built-in logo/badge instead of issuing arbitrary remote image fetches. Same-origin avatar routes (relative paths) and `data:image/...` avatars still render. (#69773)
- CLI/channels: keep `status`, `health`, `channels list`, and `channels status` on read-only channel metadata when Telegram, Slack, Discord, or third-party channel plugins are configured, avoiding full bundled plugin runtime imports on those cold paths. Fixes #69042. (#69479) Thanks @gumadeiras.
## 2026.4.20

View File

@@ -510,7 +510,7 @@ Important examples:
| Field | What it means |
| ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `openclaw.extensions` | Declares native plugin entrypoints. |
| `openclaw.setupEntry` | Lightweight setup-only entrypoint used during onboarding and deferred channel startup. |
| `openclaw.setupEntry` | Lightweight setup-only entrypoint used during onboarding, deferred channel startup, and read-only channel status/SecretRef discovery. |
| `openclaw.channel` | Cheap channel catalog metadata like labels, docs paths, aliases, and selection copy. |
| `openclaw.channel.configuredState` | Lightweight configured-state checker metadata that can answer "does env-only setup already exist?" without loading the full channel runtime. |
| `openclaw.channel.persistedAuthState` | Lightweight persisted-auth checker metadata that can answer "is anything already signed in?" without loading the full channel runtime. |
@@ -524,6 +524,12 @@ Important examples:
registry loading. Invalid values are rejected; newer-but-valid values skip the
plugin on older hosts.
Channel plugins should provide `openclaw.setupEntry` when status, channel list,
or SecretRef scans need to identify configured accounts without loading the full
runtime. The setup entry should expose channel metadata plus setup-safe config,
status, and secrets adapters; keep network clients, gateway listeners, and
transport runtimes in the main extension entrypoint.
`openclaw.install.allowInvalidConfigRecovery` is intentionally narrow. It does
not make arbitrary broken configs installable. Today it only allows install
flows to recover from specific stale bundled-plugin upgrade failures, such as a

View File

@@ -139,6 +139,14 @@ If your channel supports env-driven setup or auth and generic startup/config
flows should know those env names before runtime loads, declare them in the
plugin manifest with `channelEnvVars`. Keep channel runtime `envVars` or local
constants for operator-facing copy only.
If your channel can appear in `status`, `channels list`, `channels status`, or
SecretRef scans before the plugin runtime starts, add `openclaw.setupEntry` in
`package.json`. That entrypoint should be safe to import in read-only command
paths and should return the channel metadata, setup-safe config adapter, status
adapter, and channel secret target metadata needed for those summaries. Do not
start clients, listeners, or transport runtimes from the setup entry.
`createOptionalChannelSetupWizard`, `DEFAULT_ACCOUNT_ID`,
`createTopLevelChannelDmPolicy`, `setSetupChannelEnabled`, and
`splitSetupEntries`

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

@@ -0,0 +1,52 @@
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy";
import type { ResolvedDiscordAccount } from "./accounts.js";
import type { ChannelPlugin } from "./channel-api.js";
const resolveDiscordDmPolicy = createScopedDmSecurityResolver<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"),
});
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',
},
});
let discordSecurityAuditModulePromise:
| Promise<typeof import("./security-audit.runtime.js")>
| undefined;
async function loadDiscordSecurityAuditModule() {
discordSecurityAuditModulePromise ??= import("./security-audit.runtime.js");
return await discordSecurityAuditModulePromise;
}
export const discordSecurityAdapter = {
resolveDmPolicy: resolveDiscordDmPolicy,
collectWarnings: collectDiscordSecurityWarnings,
collectAuditFindings: async (params) =>
(await loadDiscordSecurityAuditModule()).collectDiscordSecurityAuditFindings(params),
} satisfies NonNullable<ChannelPlugin<ResolvedDiscordAccount>["security"]>;

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

@@ -6,7 +6,7 @@
"autoEnableWhenConfiguredProviders": ["minimax", "minimax-portal"],
"nonSecretAuthMarkers": ["minimax-oauth"],
"providerAuthEnvVars": {
"minimax": ["MINIMAX_API_KEY"],
"minimax": ["MINIMAX_CODE_PLAN_KEY", "MINIMAX_CODING_API_KEY", "MINIMAX_API_KEY"],
"minimax-portal": ["MINIMAX_OAUTH_TOKEN", "MINIMAX_API_KEY"]
},
"providerAuthChoices": [

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

@@ -0,0 +1,43 @@
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy";
import type { ResolvedSlackAccount } from "./accounts.js";
import type { ChannelPlugin } from "./channel-api.js";
import { collectSlackSecurityAuditFindings } from "./security-audit.js";
const resolveSlackDmPolicy = createScopedDmSecurityResolver<ResolvedSlackAccount>({
channelKey: "slack",
resolvePolicy: (account) => account.dm?.policy,
resolveAllowFrom: (account) => account.dm?.allowFrom,
allowFromPathSuffix: "dm.",
normalizeEntry: (raw) =>
raw
.trim()
.replace(/^(slack|user):/i, "")
.trim(),
});
const collectSlackSecurityWarnings =
createOpenProviderConfiguredRouteWarningCollector<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 slackSecurityAdapter = {
resolveDmPolicy: resolveSlackDmPolicy,
collectWarnings: collectSlackSecurityWarnings,
collectAuditFindings: collectSlackSecurityAuditFindings,
} satisfies NonNullable<ChannelPlugin<ResolvedSlackAccount>["security"]>;

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

@@ -0,0 +1,40 @@
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
import type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
import { createAllowlistProviderRouteAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
import type { ResolvedTelegramAccount } from "./accounts.js";
import { collectTelegramSecurityAuditFindings } from "./security-audit.js";
const resolveTelegramDmPolicy = createScopedDmSecurityResolver<ResolvedTelegramAccount>({
channelKey: "telegram",
resolvePolicy: (account) => account.config.dmPolicy,
resolveAllowFrom: (account) => account.config.allowFrom,
policyPathSuffix: "dmPolicy",
normalizeEntry: (raw) => raw.replace(/^(telegram|tg):/i, ""),
});
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 telegramSecurityAdapter = {
resolveDmPolicy: resolveTelegramDmPolicy,
collectWarnings: collectTelegramSecurityWarnings,
collectAuditFindings: collectTelegramSecurityAuditFindings,
} satisfies NonNullable<ChannelPlugin<ResolvedTelegramAccount>["security"]>;

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

@@ -27,6 +27,7 @@ export {
export {
clearRuntimeAuthProfileStoreSnapshots,
ensureAuthProfileStore,
ensureAuthProfileStoreWithoutExternalProfiles,
hasAnyAuthProfileStoreSource,
loadAuthProfileStoreForSecretsRuntime,
loadAuthProfileStoreWithoutExternalProfiles,

View File

@@ -298,23 +298,30 @@ export function loadAuthProfileStoreWithoutExternalProfiles(agentDir?: string):
export function ensureAuthProfileStore(
agentDir?: string,
options?: { allowKeychainPrompt?: boolean },
): AuthProfileStore {
return overlayExternalAuthProfiles(
ensureAuthProfileStoreWithoutExternalProfiles(agentDir, options),
{ agentDir },
);
}
export function ensureAuthProfileStoreWithoutExternalProfiles(
agentDir?: string,
options?: { allowKeychainPrompt?: boolean },
): AuthProfileStore {
const runtimeStore = resolveRuntimeAuthProfileStore(agentDir);
if (runtimeStore) {
return overlayExternalAuthProfiles(runtimeStore, { agentDir });
return runtimeStore;
}
const store = loadAuthProfileStoreForAgent(agentDir, options);
const authPath = resolveAuthStorePath(agentDir);
const mainAuthPath = resolveAuthStorePath();
if (!agentDir || authPath === mainAuthPath) {
return overlayExternalAuthProfiles(store, { agentDir });
return store;
}
const mainStore = loadAuthProfileStoreForAgent(undefined, options);
const merged = mergeAuthProfileStores(mainStore, store);
return overlayExternalAuthProfiles(merged, { agentDir });
return mergeAuthProfileStores(mainStore, store);
}
export function findPersistedAuthProfileCredential(params: {

View File

@@ -0,0 +1,460 @@
import fs from "node:fs";
import path from "node:path";
import { afterAll, afterEach, describe, expect, it } from "vitest";
import {
cleanupPluginLoaderFixturesForTest,
EMPTY_PLUGIN_SCHEMA,
makeTempDir,
resetPluginLoaderTestStateForTest,
useNoBundledPlugins,
} from "../../plugins/loader.test-fixtures.js";
import { listReadOnlyChannelPluginsForConfig } from "./read-only.js";
function writeExternalSetupChannelPlugin(
options: {
setupEntry?: boolean;
pluginDir?: string;
pluginId?: string;
channelId?: string;
manifestChannelIds?: string[];
setupChannelId?: string;
} = {},
) {
useNoBundledPlugins();
const pluginDir = options.pluginDir ?? makeTempDir();
const pluginId = options.pluginId ?? "external-chat";
const channelId = options.channelId ?? "external-chat";
const manifestChannelIds = options.manifestChannelIds ?? [channelId];
const setupChannelId = options.setupChannelId ?? channelId;
const fullMarker = path.join(pluginDir, "full-loaded.txt");
const setupMarker = path.join(pluginDir, "setup-loaded.txt");
const setupEntry = options.setupEntry !== false;
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify(
{
name: `@example/openclaw-${pluginId}`,
version: "1.0.0",
openclaw: {
extensions: ["./index.cjs"],
...(setupEntry ? { setupEntry: "./setup-entry.cjs" } : {}),
},
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(
path.join(pluginDir, "openclaw.plugin.json"),
JSON.stringify(
{
id: pluginId,
configSchema: EMPTY_PLUGIN_SCHEMA,
channels: manifestChannelIds,
channelEnvVars: {
[channelId]: ["EXTERNAL_CHAT_TOKEN"],
},
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(
path.join(pluginDir, "index.cjs"),
`require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8");
module.exports = {
id: ${JSON.stringify(pluginId)},
register(api) {
api.registerChannel({
plugin: {
id: ${JSON.stringify(channelId)},
meta: {
id: ${JSON.stringify(channelId)},
label: "External Chat",
selectionLabel: "External Chat",
docsPath: ${JSON.stringify(`/channels/${channelId}`)},
blurb: "full entry",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => ["default"],
resolveAccount: (cfg) => ({
accountId: "default",
token: cfg.channels?.[${JSON.stringify(channelId)}]?.token ?? "configured",
}),
},
outbound: { deliveryMode: "direct" },
secrets: {
secretTargetRegistryEntries: [
{
id: ${JSON.stringify(`channels.${channelId}.token`)},
targetType: "channel",
configFile: "openclaw.json",
pathPattern: ${JSON.stringify(`channels.${channelId}.token`)},
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
],
},
},
});
},
};`,
"utf-8",
);
if (setupEntry) {
fs.writeFileSync(
path.join(pluginDir, "setup-entry.cjs"),
`require("node:fs").writeFileSync(${JSON.stringify(setupMarker)}, "loaded", "utf-8");
module.exports = {
plugin: {
id: ${JSON.stringify(setupChannelId)},
meta: {
id: ${JSON.stringify(setupChannelId)},
label: "External Chat",
selectionLabel: "External Chat",
docsPath: ${JSON.stringify(`/channels/${setupChannelId}`)},
blurb: "setup entry",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => ["default"],
resolveAccount: (cfg) => ({
accountId: "default",
token: cfg.channels?.[${JSON.stringify(setupChannelId)}]?.token ?? "configured",
}),
},
outbound: { deliveryMode: "direct" },
secrets: {
secretTargetRegistryEntries: [
{
id: ${JSON.stringify(`channels.${setupChannelId}.token`)},
targetType: "channel",
configFile: "openclaw.json",
pathPattern: ${JSON.stringify(`channels.${setupChannelId}.token`)},
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
],
},
},
};`,
"utf-8",
);
}
return { pluginDir, fullMarker, setupMarker };
}
afterEach(() => {
resetPluginLoaderTestStateForTest();
});
afterAll(() => {
cleanupPluginLoaderFixturesForTest();
});
describe("listReadOnlyChannelPluginsForConfig", () => {
it("loads configured external channel setup metadata without importing full runtime", () => {
const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin();
const plugins = listReadOnlyChannelPluginsForConfig(
{
channels: {
"external-chat": { token: "configured" },
},
plugins: {
load: { paths: [pluginDir] },
allow: ["external-chat"],
},
} as never,
{
env: { ...process.env },
includePersistedAuthState: false,
},
);
const plugin = plugins.find((entry) => entry.id === "external-chat");
expect(plugin?.meta.blurb).toBe("setup entry");
expect(
plugin?.secrets?.secretTargetRegistryEntries?.some(
(entry) => entry.id === "channels.external-chat.token",
),
).toBe(true);
expect(fs.existsSync(setupMarker)).toBe(true);
expect(fs.existsSync(fullMarker)).toBe(false);
});
it("matches setup-only plugins by manifest-owned channel ids when plugin id differs", () => {
const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin({
pluginId: "external-chat-plugin",
channelId: "external-chat",
setupChannelId: "external-chat-plugin",
});
const plugins = listReadOnlyChannelPluginsForConfig(
{
channels: {
"external-chat": { token: "configured" },
},
plugins: {
load: { paths: [pluginDir] },
allow: ["external-chat-plugin"],
},
} as never,
{
env: { ...process.env },
includePersistedAuthState: false,
},
);
const plugin = plugins.find((entry) => entry.id === "external-chat");
expect(plugin?.meta.id).toBe("external-chat");
expect(plugin?.meta.blurb).toBe("setup entry");
expect(fs.existsSync(setupMarker)).toBe(true);
expect(fs.existsSync(fullMarker)).toBe(false);
});
it("clones setup-only plugins for every configured owned channel when setup id matches one channel", () => {
const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin({
pluginId: "external-chat-plugin",
channelId: "alpha-chat",
manifestChannelIds: ["alpha-chat", "beta-chat"],
setupChannelId: "alpha-chat",
});
const plugins = listReadOnlyChannelPluginsForConfig(
{
channels: {
"alpha-chat": { token: "configured" },
"beta-chat": { token: "configured" },
},
plugins: {
load: { paths: [pluginDir] },
allow: ["external-chat-plugin"],
},
} as never,
{
env: { ...process.env },
includePersistedAuthState: false,
},
);
const alphaPlugin = plugins.find((entry) => entry.id === "alpha-chat");
const betaPlugin = plugins.find((entry) => entry.id === "beta-chat");
expect(alphaPlugin?.meta.id).toBe("alpha-chat");
expect(betaPlugin?.meta.id).toBe("beta-chat");
expect(alphaPlugin?.meta.blurb).toBe("setup entry");
expect(betaPlugin?.meta.blurb).toBe("setup entry");
expect(
betaPlugin?.secrets?.secretTargetRegistryEntries?.some(
(entry) => entry.id === "channels.beta-chat.token",
),
).toBe(true);
expect(
betaPlugin?.config.resolveAccount({
channels: {
"alpha-chat": { token: "alpha-token" },
"beta-chat": { token: "beta-token" },
},
} as never),
).toMatchObject({ token: "beta-token" });
expect(fs.existsSync(setupMarker)).toBe(true);
expect(fs.existsSync(fullMarker)).toBe(false);
});
it("clones setup-only plugins when only another owned channel is configured", () => {
const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin({
pluginId: "external-chat-plugin",
channelId: "alpha-chat",
manifestChannelIds: ["alpha-chat", "beta-chat"],
setupChannelId: "alpha-chat",
});
const plugins = listReadOnlyChannelPluginsForConfig(
{
channels: {
"beta-chat": { token: "beta-token" },
},
plugins: {
load: { paths: [pluginDir] },
allow: ["external-chat-plugin"],
},
} as never,
{
env: { ...process.env },
includePersistedAuthState: false,
},
);
expect(plugins.some((entry) => entry.id === "alpha-chat")).toBe(false);
const betaPlugin = plugins.find((entry) => entry.id === "beta-chat");
expect(betaPlugin?.meta.id).toBe("beta-chat");
expect(
betaPlugin?.secrets?.secretTargetRegistryEntries?.some(
(entry) => entry.id === "channels.beta-chat.token",
),
).toBe(true);
expect(
betaPlugin?.config.resolveAccount({
channels: {
"beta-chat": { token: "beta-token" },
},
} as never),
).toMatchObject({ token: "beta-token" });
expect(fs.existsSync(setupMarker)).toBe(true);
expect(fs.existsSync(fullMarker)).toBe(false);
});
it("keeps configured external channels visible when no setup entry exists", () => {
const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin({
setupEntry: false,
});
const plugins = listReadOnlyChannelPluginsForConfig(
{
channels: {
"external-chat": { token: "configured" },
},
plugins: {
load: { paths: [pluginDir] },
allow: ["external-chat"],
},
} as never,
{
env: { ...process.env },
includePersistedAuthState: false,
},
);
const plugin = plugins.find((entry) => entry.id === "external-chat");
expect(plugin).toBeUndefined();
expect(fs.existsSync(setupMarker)).toBe(false);
expect(fs.existsSync(fullMarker)).toBe(false);
});
it("uses external channel env vars as read-only configuration triggers", () => {
const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin({
pluginId: "external-chat-plugin",
channelId: "external-chat",
});
const plugins = listReadOnlyChannelPluginsForConfig(
{
plugins: {
load: { paths: [pluginDir] },
allow: ["external-chat-plugin"],
},
} as never,
{
env: { ...process.env, EXTERNAL_CHAT_TOKEN: "configured" },
includePersistedAuthState: false,
},
);
const plugin = plugins.find((entry) => entry.id === "external-chat");
expect(plugin?.meta.blurb).toBe("setup entry");
expect(
plugin?.secrets?.secretTargetRegistryEntries?.some(
(entry) => entry.id === "channels.external-chat.token",
),
).toBe(true);
expect(fs.existsSync(setupMarker)).toBe(true);
expect(fs.existsSync(fullMarker)).toBe(false);
});
it("accepts option-like env keys through the explicit env option", () => {
const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin({
pluginId: "external-chat-plugin",
channelId: "external-chat",
});
const plugins = listReadOnlyChannelPluginsForConfig(
{
plugins: {
load: { paths: [pluginDir] },
allow: ["external-chat-plugin"],
},
} as never,
{
env: {
...process.env,
cache: "true",
env: "prod",
EXTERNAL_CHAT_TOKEN: "configured",
workspaceDir: "workspace-env-value",
},
},
);
const plugin = plugins.find((entry) => entry.id === "external-chat");
expect(plugin?.meta.blurb).toBe("setup entry");
expect(fs.existsSync(setupMarker)).toBe(true);
expect(fs.existsSync(fullMarker)).toBe(false);
});
it("discovers trusted external channel plugins from the default agent workspace", () => {
const workspaceDir = makeTempDir();
const pluginDir = path.join(workspaceDir, ".openclaw", "extensions", "external-chat-plugin");
fs.mkdirSync(pluginDir, { recursive: true });
const { fullMarker, setupMarker } = writeExternalSetupChannelPlugin({
pluginDir,
pluginId: "external-chat-plugin",
channelId: "external-chat",
});
const plugins = listReadOnlyChannelPluginsForConfig(
{
agents: {
defaults: {
workspace: workspaceDir,
},
},
channels: {
"external-chat": { token: "configured" },
},
plugins: {
allow: ["external-chat-plugin"],
},
} as never,
{
env: { ...process.env },
includePersistedAuthState: false,
},
);
const plugin = plugins.find((entry) => entry.id === "external-chat");
expect(plugin?.meta.blurb).toBe("setup entry");
expect(fs.existsSync(setupMarker)).toBe(true);
expect(fs.existsSync(fullMarker)).toBe(false);
});
it("ignores external setup plugins that export an unrequested channel id", () => {
const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin({
pluginId: "external-chat-plugin",
channelId: "external-chat",
manifestChannelIds: ["external-chat"],
setupChannelId: "spoofed-chat",
});
const plugins = listReadOnlyChannelPluginsForConfig(
{
channels: {
"external-chat": { token: "configured" },
},
plugins: {
load: { paths: [pluginDir] },
allow: ["external-chat-plugin"],
},
} as never,
{
env: { ...process.env },
},
);
expect(plugins.some((entry) => entry.id === "spoofed-chat")).toBe(false);
expect(plugins.some((entry) => entry.id === "external-chat")).toBe(false);
expect(fs.existsSync(setupMarker)).toBe(true);
expect(fs.existsSync(fullMarker)).toBe(false);
});
});

View File

@@ -0,0 +1,436 @@
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import {
listConfiguredChannelIdsForReadOnlyScope,
resolveDiscoverableScopedChannelPluginIds,
} from "../../plugins/channel-plugin-ids.js";
import { loadOpenClawPlugins } from "../../plugins/loader.js";
import {
loadPluginManifestRegistry,
type PluginManifestRecord,
} from "../../plugins/manifest-registry.js";
import { getBundledChannelSetupPlugin } from "./bundled.js";
import { listChannelPlugins } from "./registry.js";
import type { ChannelPlugin } from "./types.plugin.js";
type ReadOnlyChannelPluginOptions = {
env?: NodeJS.ProcessEnv;
workspaceDir?: string;
activationSourceConfig?: OpenClawConfig;
includePersistedAuthState?: boolean;
cache?: boolean;
};
type ReadOnlyChannelPluginResolution = {
plugins: ChannelPlugin[];
configuredChannelIds: string[];
missingConfiguredChannelIds: string[];
};
function addChannelPlugins(
byId: Map<string, ChannelPlugin>,
plugins: Iterable<ChannelPlugin | undefined>,
options?: {
onlyIds?: ReadonlySet<string>;
allowOverwrite?: boolean;
},
): void {
for (const plugin of plugins) {
if (!plugin) {
continue;
}
if (options?.onlyIds && !options.onlyIds.has(plugin.id)) {
continue;
}
if (options?.allowOverwrite === false && byId.has(plugin.id)) {
continue;
}
byId.set(plugin.id, plugin);
}
}
function rebindChannelScopedString(
value: string,
sourceChannelId: string,
targetChannelId: string,
): string {
const sourcePrefix = `channels.${sourceChannelId}`;
if (value === sourcePrefix) {
return `channels.${targetChannelId}`;
}
if (value.startsWith(`${sourcePrefix}.`)) {
return `channels.${targetChannelId}${value.slice(sourcePrefix.length)}`;
}
return value;
}
function rebindChannelConfig(
cfg: OpenClawConfig,
sourceChannelId: string,
targetChannelId: string,
): OpenClawConfig {
if (sourceChannelId === targetChannelId || !cfg.channels) {
return cfg;
}
return {
...cfg,
channels: {
...cfg.channels,
[sourceChannelId]: (cfg.channels as Record<string, unknown>)[targetChannelId],
},
};
}
function restoreReboundChannelConfig(params: {
original: OpenClawConfig;
updated: OpenClawConfig;
sourceChannelId: string;
targetChannelId: string;
}): OpenClawConfig {
if (params.sourceChannelId === params.targetChannelId || !params.updated.channels) {
return params.updated;
}
const nextChannels = { ...params.updated.channels };
if (Object.prototype.hasOwnProperty.call(nextChannels, params.sourceChannelId)) {
nextChannels[params.targetChannelId] = nextChannels[params.sourceChannelId];
} else {
delete nextChannels[params.targetChannelId];
}
if (
params.original.channels &&
Object.prototype.hasOwnProperty.call(params.original.channels, params.sourceChannelId)
) {
nextChannels[params.sourceChannelId] = params.original.channels[params.sourceChannelId];
} else {
delete nextChannels[params.sourceChannelId];
}
return {
...params.updated,
channels: nextChannels,
};
}
function rebindChannelPluginConfig(
config: ChannelPlugin["config"],
sourceChannelId: string,
targetChannelId: string,
): ChannelPlugin["config"] {
const rebind = (cfg: OpenClawConfig) =>
rebindChannelConfig(cfg, sourceChannelId, targetChannelId);
return {
...config,
listAccountIds: (cfg) => config.listAccountIds(rebind(cfg)),
resolveAccount: (cfg, accountId) => config.resolveAccount(rebind(cfg), accountId),
inspectAccount: config.inspectAccount
? (cfg, accountId) => config.inspectAccount?.(rebind(cfg), accountId)
: undefined,
defaultAccountId: config.defaultAccountId
? (cfg) => config.defaultAccountId?.(rebind(cfg)) ?? ""
: undefined,
setAccountEnabled: config.setAccountEnabled
? (params) =>
restoreReboundChannelConfig({
original: params.cfg,
updated:
config.setAccountEnabled?.({ ...params, cfg: rebind(params.cfg) }) ?? params.cfg,
sourceChannelId,
targetChannelId,
})
: undefined,
deleteAccount: config.deleteAccount
? (params) =>
restoreReboundChannelConfig({
original: params.cfg,
updated: config.deleteAccount?.({ ...params, cfg: rebind(params.cfg) }) ?? params.cfg,
sourceChannelId,
targetChannelId,
})
: undefined,
isEnabled: config.isEnabled
? (account, cfg) => config.isEnabled?.(account, rebind(cfg)) ?? false
: undefined,
disabledReason: config.disabledReason
? (account, cfg) => config.disabledReason?.(account, rebind(cfg)) ?? ""
: undefined,
isConfigured: config.isConfigured
? (account, cfg) => config.isConfigured?.(account, rebind(cfg)) ?? false
: undefined,
unconfiguredReason: config.unconfiguredReason
? (account, cfg) => config.unconfiguredReason?.(account, rebind(cfg)) ?? ""
: undefined,
describeAccount: config.describeAccount
? (account, cfg) => config.describeAccount!(account, rebind(cfg))
: undefined,
resolveAllowFrom: config.resolveAllowFrom
? (params) => config.resolveAllowFrom?.({ ...params, cfg: rebind(params.cfg) })
: undefined,
formatAllowFrom: config.formatAllowFrom
? (params) => config.formatAllowFrom?.({ ...params, cfg: rebind(params.cfg) }) ?? []
: undefined,
hasConfiguredState: config.hasConfiguredState
? (params) => config.hasConfiguredState?.({ ...params, cfg: rebind(params.cfg) }) ?? false
: undefined,
hasPersistedAuthState: config.hasPersistedAuthState
? (params) => config.hasPersistedAuthState?.({ ...params, cfg: rebind(params.cfg) }) ?? false
: undefined,
resolveDefaultTo: config.resolveDefaultTo
? (params) => config.resolveDefaultTo?.({ ...params, cfg: rebind(params.cfg) })
: undefined,
};
}
function rebindChannelPluginSecrets(
secrets: ChannelPlugin["secrets"],
sourceChannelId: string,
targetChannelId: string,
): ChannelPlugin["secrets"] {
if (!secrets) {
return undefined;
}
return {
...secrets,
secretTargetRegistryEntries: secrets.secretTargetRegistryEntries?.map((entry) => ({
...entry,
id: rebindChannelScopedString(entry.id, sourceChannelId, targetChannelId),
pathPattern: rebindChannelScopedString(entry.pathPattern, sourceChannelId, targetChannelId),
...(entry.refPathPattern
? {
refPathPattern: rebindChannelScopedString(
entry.refPathPattern,
sourceChannelId,
targetChannelId,
),
}
: {}),
})),
unsupportedSecretRefSurfacePatterns: secrets.unsupportedSecretRefSurfacePatterns?.map(
(pattern) => rebindChannelScopedString(pattern, sourceChannelId, targetChannelId),
),
collectRuntimeConfigAssignments: secrets.collectRuntimeConfigAssignments
? (params) =>
secrets.collectRuntimeConfigAssignments?.({
...params,
config: rebindChannelConfig(params.config, sourceChannelId, targetChannelId),
})
: undefined,
};
}
function cloneChannelPluginForChannelId(plugin: ChannelPlugin, channelId: string): ChannelPlugin {
if (plugin.id === channelId && plugin.meta.id === channelId) {
return plugin;
}
const sourceChannelId = plugin.id;
return {
...plugin,
id: channelId,
meta: {
...plugin.meta,
id: channelId,
},
config: rebindChannelPluginConfig(plugin.config, sourceChannelId, channelId),
secrets: rebindChannelPluginSecrets(plugin.secrets, sourceChannelId, channelId),
};
}
function addSetupChannelPlugins(
byId: Map<string, ChannelPlugin>,
setups: Iterable<{
pluginId: string;
plugin: ChannelPlugin;
}>,
options: {
ownedChannelIdsByPluginId: ReadonlyMap<string, readonly string[]>;
ownedMissingChannelIdsByPluginId: ReadonlyMap<string, readonly string[]>;
},
): void {
for (const setup of setups) {
const ownedMissingChannelIds = options.ownedMissingChannelIdsByPluginId.get(setup.pluginId);
if (!ownedMissingChannelIds || ownedMissingChannelIds.length === 0) {
continue;
}
if (ownedMissingChannelIds.includes(setup.plugin.id)) {
addChannelPlugins(byId, [setup.plugin], {
onlyIds: new Set(ownedMissingChannelIds),
allowOverwrite: false,
});
addChannelPlugins(
byId,
ownedMissingChannelIds
.filter((channelId) => channelId !== setup.plugin.id)
.map((channelId) => cloneChannelPluginForChannelId(setup.plugin, channelId)),
{
onlyIds: new Set(ownedMissingChannelIds),
allowOverwrite: false,
},
);
continue;
}
const ownedChannelIds = options.ownedChannelIdsByPluginId.get(setup.pluginId) ?? [];
if (setup.plugin.id !== setup.pluginId && !ownedChannelIds.includes(setup.plugin.id)) {
continue;
}
addChannelPlugins(
byId,
ownedMissingChannelIds.map((channelId) =>
cloneChannelPluginForChannelId(setup.plugin, channelId),
),
{
onlyIds: new Set(ownedMissingChannelIds),
allowOverwrite: false,
},
);
}
}
function resolveReadOnlyWorkspaceDir(
cfg: OpenClawConfig,
options: ReadOnlyChannelPluginOptions,
): string | undefined {
return options.workspaceDir ?? resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
}
function listExternalChannelManifestRecords(params: {
cfg: OpenClawConfig;
workspaceDir?: string;
env: NodeJS.ProcessEnv;
cache?: boolean;
}): PluginManifestRecord[] {
return loadPluginManifestRegistry({
config: params.cfg,
workspaceDir: params.workspaceDir,
env: params.env,
cache: params.cache,
}).plugins.filter((plugin) => plugin.origin !== "bundled" && plugin.channels.length > 0);
}
function resolveExternalReadOnlyChannelPluginIds(params: {
cfg: OpenClawConfig;
activationSourceConfig?: OpenClawConfig;
channelIds: readonly string[];
records: readonly PluginManifestRecord[];
workspaceDir?: string;
env: NodeJS.ProcessEnv;
cache?: boolean;
}): string[] {
if (params.channelIds.length === 0) {
return [];
}
const candidatePluginIds = resolveDiscoverableScopedChannelPluginIds({
config: params.cfg,
activationSourceConfig: params.activationSourceConfig,
channelIds: params.channelIds,
workspaceDir: params.workspaceDir,
env: params.env,
cache: params.cache,
});
if (candidatePluginIds.length === 0) {
return [];
}
const requestedChannelIds = new Set(params.channelIds);
const candidatePluginIdSet = new Set(candidatePluginIds);
return params.records
.filter(
(plugin) =>
candidatePluginIdSet.has(plugin.id) &&
plugin.channels.some((channelId) => requestedChannelIds.has(channelId)),
)
.map((plugin) => plugin.id)
.toSorted((left, right) => left.localeCompare(right));
}
export function listReadOnlyChannelPluginsForConfig(
cfg: OpenClawConfig,
options?: ReadOnlyChannelPluginOptions,
): ChannelPlugin[] {
return resolveReadOnlyChannelPluginsForConfig(cfg, options).plugins;
}
export function resolveReadOnlyChannelPluginsForConfig(
cfg: OpenClawConfig,
options: ReadOnlyChannelPluginOptions = {},
): ReadOnlyChannelPluginResolution {
const env = options.env ?? process.env;
const workspaceDir = resolveReadOnlyWorkspaceDir(cfg, options);
const externalManifestRecords = listExternalChannelManifestRecords({
cfg,
workspaceDir,
env,
cache: options.cache,
});
const configuredChannelIds = [
...new Set(
listConfiguredChannelIdsForReadOnlyScope({
config: cfg,
activationSourceConfig: options.activationSourceConfig ?? cfg,
workspaceDir,
env,
cache: options.cache,
includePersistedAuthState: options.includePersistedAuthState,
manifestRecords: externalManifestRecords,
}),
),
];
const byId = new Map<string, ChannelPlugin>();
addChannelPlugins(byId, listChannelPlugins());
for (const channelId of configuredChannelIds) {
if (byId.has(channelId)) {
continue;
}
addChannelPlugins(byId, [getBundledChannelSetupPlugin(channelId)]);
}
const missingConfiguredChannelIds = configuredChannelIds.filter(
(channelId) => !byId.has(channelId),
);
const externalPluginIds = resolveExternalReadOnlyChannelPluginIds({
cfg,
activationSourceConfig: options.activationSourceConfig ?? cfg,
channelIds: missingConfiguredChannelIds,
records: externalManifestRecords,
workspaceDir,
env,
cache: options.cache,
});
if (externalPluginIds.length > 0) {
const missingChannelIdSet = new Set(missingConfiguredChannelIds);
const externalPluginIdSet = new Set(externalPluginIds);
const ownedChannelIdsByPluginId = new Map(
externalManifestRecords
.filter((record) => externalPluginIdSet.has(record.id))
.map((record) => [record.id, record.channels] as const),
);
const ownedMissingChannelIdsByPluginId = new Map(
[...ownedChannelIdsByPluginId].map(
([pluginId, channelIds]) =>
[pluginId, channelIds.filter((channelId) => missingChannelIdSet.has(channelId))] as const,
),
);
const registry = loadOpenClawPlugins({
config: cfg,
activationSourceConfig: options.activationSourceConfig ?? cfg,
env,
workspaceDir,
cache: false,
activate: false,
includeSetupOnlyChannelPlugins: true,
forceSetupOnlyChannelPlugins: true,
requireSetupEntryForSetupOnlyChannelPlugins: true,
onlyPluginIds: externalPluginIds,
});
addSetupChannelPlugins(byId, registry.channelSetups, {
ownedChannelIdsByPluginId,
ownedMissingChannelIdsByPluginId,
});
}
const plugins = [...byId.values()];
return {
plugins,
configuredChannelIds,
missingConfiguredChannelIds: configuredChannelIds.filter((channelId) => !byId.has(channelId)),
};
}

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,110 @@ describe("command secret targets module import", () => {
expect(() => mod.getChannelsCommandSecretTargetIds()).toThrow("registry touched too early");
expect(listSecretTargetRegistryEntries).toHaveBeenCalledTimes(1);
});
it("can resolve configured-channel status targets without the full registry", async () => {
const listSecretTargetRegistryEntries = vi.fn(() => {
throw new Error("registry touched too early");
});
const listReadOnlyChannelPluginsForConfig = vi.fn(() => [
{
id: "telegram",
secrets: {
secretTargetRegistryEntries: [
{
id: "channels.telegram.botToken",
targetType: "channels.telegram.botToken",
configFile: "openclaw.json",
pathPattern: "channels.telegram.botToken",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.telegram.gatewayToken",
targetType: "gateway.auth.token",
configFile: "openclaw.json",
pathPattern: "gateway.auth.token",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.telegram.gatewayTokenRef",
targetType: "channels.telegram.gatewayTokenRef",
configFile: "openclaw.json",
pathPattern: "channels.telegram.gatewayToken",
refPathPattern: "gateway.auth.token",
secretShape: "sibling_ref",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.discord.token",
targetType: "channels.discord.token",
configFile: "openclaw.json",
pathPattern: "channels.discord.token",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
],
},
},
{
id: "external-chat",
secrets: {
secretTargetRegistryEntries: [
{
id: "channels.external-chat.token",
targetType: "channels.external-chat.token",
configFile: "openclaw.json",
pathPattern: "channels.external-chat.token",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
],
},
},
]);
vi.doMock("../secrets/target-registry.js", () => ({
discoverConfigSecretTargetsByIds: vi.fn(() => []),
listSecretTargetRegistryEntries,
}));
vi.doMock("../channels/plugins/read-only.js", () => ({
listReadOnlyChannelPluginsForConfig,
}));
const mod = await import("./command-secret-targets.js");
const targets = mod.getStatusCommandSecretTargetIds({
channels: {
"external-chat": { token: "configured" },
telegram: { botToken: "123456:ABCDEF" },
},
});
expect(targets.has("channels.external-chat.token")).toBe(true);
expect(targets.has("channels.telegram.botToken")).toBe(true);
expect(targets.has("channels.discord.token")).toBe(false);
expect(targets.has("channels.telegram.gatewayToken")).toBe(false);
expect(targets.has("channels.telegram.gatewayTokenRef")).toBe(false);
expect(targets.has("agents.defaults.memorySearch.remote.apiKey")).toBe(true);
expect(listReadOnlyChannelPluginsForConfig).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ includePersistedAuthState: false }),
);
expect(listSecretTargetRegistryEntries).not.toHaveBeenCalled();
});
});

View File

@@ -1,3 +1,4 @@
import { listReadOnlyChannelPluginsForConfig } from "../channels/plugins/read-only.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizeOptionalAccountId } from "../routing/session-key.js";
import {
@@ -73,6 +74,48 @@ function getChannelSecretTargetIds(): string[] {
return cachedChannelSecretTargetIds;
}
function isScopedChannelSecretTargetEntry(params: {
entry: {
id: string;
configFile?: string;
pathPattern?: string;
refPathPattern?: string;
};
pluginChannelId: string;
}): boolean {
const channelId = normalizeOptionalString(params.pluginChannelId);
if (!channelId) {
return false;
}
const allowedPrefix = `channels.${channelId}.`;
return (
params.entry.id.startsWith(allowedPrefix) &&
params.entry.configFile === "openclaw.json" &&
typeof params.entry.pathPattern === "string" &&
params.entry.pathPattern.startsWith(allowedPrefix) &&
(params.entry.refPathPattern === undefined ||
params.entry.refPathPattern.startsWith(allowedPrefix))
);
}
function getConfiguredChannelSecretTargetIds(
config: OpenClawConfig,
env: NodeJS.ProcessEnv = process.env,
): string[] {
const targetIds = new Set<string>();
for (const plugin of listReadOnlyChannelPluginsForConfig(config, {
env,
includePersistedAuthState: false,
})) {
for (const entry of plugin.secrets?.secretTargetRegistryEntries ?? []) {
if (isScopedChannelSecretTargetEntry({ entry, pluginChannelId: plugin.id })) {
targetIds.add(entry.id);
}
}
}
return [...targetIds].toSorted((left, right) => left.localeCompare(right));
}
function buildCommandSecretTargets(): CommandSecretTargets {
const channelTargetIds = getChannelSecretTargetIds();
return {
@@ -155,6 +198,13 @@ export function getChannelsCommandSecretTargetIds(): Set<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 +218,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

@@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({
readConfigFileSnapshot: vi.fn(async () => ({ path: "/tmp/openclaw.json" })),
requireValidConfigSnapshot: vi.fn(),
listChannelPlugins: vi.fn(),
listConfiguredChannelIdsForReadOnlyScope: vi.fn((_params: unknown) => ["discord"]),
withProgress: vi.fn(async (_opts: unknown, run: () => Promise<unknown>) => await run()),
}));
@@ -33,6 +34,11 @@ vi.mock("../config/config.js", () => ({
readConfigFileSnapshot: () => mocks.readConfigFileSnapshot(),
}));
vi.mock("../plugins/channel-plugin-ids.js", () => ({
listConfiguredChannelIdsForReadOnlyScope: (params: unknown) =>
mocks.listConfiguredChannelIdsForReadOnlyScope(params),
}));
vi.mock("./channels/shared.js", () => ({
requireValidConfigSnapshot: (runtime: unknown) => mocks.requireValidConfigSnapshot(runtime),
formatChannelAccountLabel: ({
@@ -82,6 +88,10 @@ vi.mock("../channels/plugins/index.js", () => ({
(mocks.listChannelPlugins() as Array<{ id: string }>).find((plugin) => plugin.id === channel),
}));
vi.mock("../channels/plugins/read-only.js", () => ({
listReadOnlyChannelPluginsForConfig: () => mocks.listChannelPlugins(),
}));
vi.mock("../channels/account-snapshot-fields.js", () => ({
hasConfiguredUnavailableCredentialStatus: (account: Record<string, unknown>) =>
Object.values(account).includes("configured_unavailable"),
@@ -119,7 +129,7 @@ vi.mock("../channels/plugins/status.js", () => ({
}));
vi.mock("../cli/command-secret-targets.js", () => ({
getChannelsCommandSecretTargetIds: () => [],
getConfiguredChannelsCommandSecretTargetIds: () => [],
}));
vi.mock("../infra/channels-status-issues.js", () => ({
@@ -184,6 +194,8 @@ describe("channelsStatusCommand SecretRef fallback flow", () => {
mocks.readConfigFileSnapshot.mockClear();
mocks.requireValidConfigSnapshot.mockReset();
mocks.listChannelPlugins.mockReset();
mocks.listConfiguredChannelIdsForReadOnlyScope.mockClear();
mocks.listConfiguredChannelIdsForReadOnlyScope.mockReturnValue(["discord"]);
mocks.withProgress.mockClear();
mocks.listChannelPlugins.mockReturnValue([createTokenOnlyPlugin()]);
});
@@ -237,4 +249,51 @@ describe("channelsStatusCommand SecretRef fallback flow", () => {
expect(joined).not.toContain("secret unavailable in this command path");
expect(joined).not.toContain("token:config (unavailable)");
});
it("keeps JSON fallback structured without rendering config-only text", async () => {
mocks.callGateway.mockRejectedValue(
new Error(
[
"gateway timeout after 3000ms",
"Gateway target: wss://user:pass@gateway.example.com/socket?token=secret-token&keep=visible",
"Gateway fallback: (wss://fallback-user:fallback-pass@[bad-host/socket?token=fallback-secret&keep=visible)",
"Source: env OPENCLAW_GATEWAY_URL",
].join("\n"),
),
);
mocks.requireValidConfigSnapshot.mockResolvedValue({ secretResolved: false, channels: {} });
mocks.resolveCommandConfigWithSecrets.mockResolvedValue({
resolvedConfig: { secretResolved: true, channels: {} },
effectiveConfig: { secretResolved: true, channels: {} },
diagnostics: [],
});
const { runtime, logs, errors } = createRuntimeCapture();
await channelsStatusCommand({ json: true, probe: false }, runtime as never);
expect(mocks.listChannelPlugins).not.toHaveBeenCalled();
expect(mocks.listConfiguredChannelIdsForReadOnlyScope).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({ secretResolved: true }),
includePersistedAuthState: false,
}),
);
const payload = JSON.parse(logs.at(-1) ?? "{}");
expect(errors.join("\n")).not.toContain("user:pass");
expect(errors.join("\n")).not.toContain("secret-token");
expect(errors.join("\n")).not.toContain("fallback-user:fallback-pass");
expect(errors.join("\n")).not.toContain("fallback-secret");
expect(payload.error).toContain("Gateway target:");
expect(payload.error).not.toContain("user:pass");
expect(payload.error).not.toContain("secret-token");
expect(payload.error).not.toContain("fallback-user:fallback-pass");
expect(payload.error).not.toContain("fallback-secret");
expect(payload).toEqual(
expect.objectContaining({
gatewayReachable: false,
configOnly: true,
configuredChannels: ["discord"],
}),
);
});
});

View File

@@ -0,0 +1,150 @@
import fs from "node:fs";
import path from "node:path";
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
cleanupPluginLoaderFixturesForTest,
EMPTY_PLUGIN_SCHEMA,
makeTempDir,
resetPluginLoaderTestStateForTest,
useNoBundledPlugins,
} from "../plugins/loader.test-fixtures.js";
import { withEnvAsync } from "../test-utils/env.js";
import { channelsStatusCommand } from "./channels/status.js";
const mocks = vi.hoisted(() => ({
callGateway: vi.fn(),
readConfigFileSnapshot: vi.fn(async () => ({ path: "/tmp/openclaw.json" })),
requireValidConfigSnapshot: vi.fn(),
resolveCommandConfigWithSecrets: vi.fn(),
}));
vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => mocks.callGateway(opts),
}));
vi.mock("../config/config.js", () => ({
readConfigFileSnapshot: () => mocks.readConfigFileSnapshot(),
}));
vi.mock("../cli/command-config-resolution.js", () => ({
resolveCommandConfigWithSecrets: (opts: unknown) => mocks.resolveCommandConfigWithSecrets(opts),
}));
vi.mock("./channels/shared.js", () => ({
requireValidConfigSnapshot: (runtime: unknown) => mocks.requireValidConfigSnapshot(runtime),
formatChannelAccountLabel: ({ channel, accountId }: { channel: string; accountId: string }) =>
`${channel} ${accountId}`,
appendBaseUrlBit: () => undefined,
appendEnabledConfiguredLinkedBits: () => undefined,
appendModeBit: () => undefined,
appendTokenSourceBits: () => undefined,
buildChannelAccountLine: () => "",
}));
vi.mock("../cli/progress.js", () => ({
withProgress: async (_opts: unknown, run: () => Promise<unknown>) => await run(),
}));
function writeExternalEnvChannelPlugin() {
useNoBundledPlugins();
const pluginDir = makeTempDir();
const fullMarker = path.join(pluginDir, "full-loaded.txt");
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify(
{
name: "@example/openclaw-external-env-channel",
version: "1.0.0",
openclaw: {
extensions: ["./index.cjs"],
},
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(
path.join(pluginDir, "openclaw.plugin.json"),
JSON.stringify(
{
id: "external-env-channel-plugin",
configSchema: EMPTY_PLUGIN_SCHEMA,
channels: ["external-env-channel"],
channelEnvVars: {
"external-env-channel": ["EXTERNAL_ENV_CHANNEL_TOKEN"],
},
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(
path.join(pluginDir, "index.cjs"),
`require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8");`,
"utf-8",
);
return { pluginDir, fullMarker };
}
function createRuntimeCapture() {
const logs: string[] = [];
const errors: string[] = [];
const runtime = {
log: (message: unknown) => logs.push(String(message)),
error: (message: unknown) => errors.push(String(message)),
exit: (_code?: number) => undefined,
};
return { runtime, logs, errors };
}
describe("channelsStatusCommand external env-only channel fallback", () => {
beforeEach(() => {
mocks.callGateway.mockReset();
mocks.callGateway.mockRejectedValue(new Error("gateway closed"));
mocks.readConfigFileSnapshot.mockClear();
mocks.requireValidConfigSnapshot.mockReset();
mocks.resolveCommandConfigWithSecrets.mockReset();
});
afterEach(() => {
resetPluginLoaderTestStateForTest();
});
it("reports env-only external manifest channels in JSON fallback without full runtime load", async () => {
const { pluginDir, fullMarker } = writeExternalEnvChannelPlugin();
const config = {
plugins: {
load: { paths: [pluginDir] },
allow: ["external-env-channel-plugin"],
},
} as OpenClawConfig;
mocks.requireValidConfigSnapshot.mockResolvedValue(config);
mocks.resolveCommandConfigWithSecrets.mockResolvedValue({
resolvedConfig: config,
effectiveConfig: config,
diagnostics: [],
});
const { runtime, logs } = createRuntimeCapture();
await withEnvAsync({ EXTERNAL_ENV_CHANNEL_TOKEN: "token" }, async () => {
await channelsStatusCommand({ json: true, probe: false }, runtime as never);
});
expect(fs.existsSync(fullMarker)).toBe(false);
const payload = JSON.parse(logs.at(-1) ?? "{}");
expect(payload).toEqual(
expect.objectContaining({
gatewayReachable: false,
configOnly: true,
configuredChannels: ["external-env-channel"],
}),
);
});
});
afterAll(() => {
cleanupPluginLoaderFixturesForTest();
});

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,13 +1,15 @@
import { listChannelPlugins } from "../../channels/plugins/index.js";
import { resolveCommandConfigWithSecrets } from "../../cli/command-config-resolution.js";
import { formatCliCommand } from "../../cli/command-format.js";
import { getChannelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js";
import { getConfiguredChannelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js";
import { withProgress } from "../../cli/progress.js";
import { readConfigFileSnapshot } from "../../config/config.js";
import { callGateway } from "../../gateway/call.js";
import { collectChannelStatusIssues } from "../../infra/channels-status-issues.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { formatTimeAgo } from "../../infra/format-time/format-relative.ts";
import { listConfiguredChannelIdsForReadOnlyScope } from "../../plugins/channel-plugin-ids.js";
import { defaultRuntime, type RuntimeEnv, writeRuntimeJson } from "../../runtime.js";
import { redactSensitiveUrlLikeString } from "../../shared/net/redact-sensitive-url.js";
import { formatDocsLink } from "../../terminal/links.js";
import { theme } from "../../terminal/theme.js";
import {
@@ -29,6 +31,16 @@ export type ChannelsStatusOptions = {
timeout?: string;
};
function redactGatewayUrlSecretsInText(text: string): string {
return text.replace(/\b(?:wss?|https?):\/\/[^\s"'<>]+/gi, (rawUrl) => {
return redactSensitiveUrlLikeString(rawUrl);
});
}
function formatChannelsStatusError(err: unknown): string {
return redactGatewayUrlSecretsInText(formatErrorMessage(err));
}
export function formatGatewayChannelsStatusLines(payload: Record<string, unknown>): string[] {
const lines: string[] = [];
lines.push(theme.success("Gateway reachable."));
@@ -109,20 +121,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));
}
}
@@ -174,7 +185,8 @@ export async function channelsStatusCommand(
}
runtime.log(formatGatewayChannelsStatusLines(payload).join("\n"));
} catch (err) {
runtime.error(`Gateway not reachable: ${String(err)}`);
const safeError = formatChannelsStatusError(err);
runtime.error(`Gateway not reachable: ${safeError}`);
const cfg = await requireValidConfigSnapshot(runtime);
if (!cfg) {
return;
@@ -182,12 +194,29 @@ export async function channelsStatusCommand(
const { resolvedConfig } = await resolveCommandConfigWithSecrets({
config: cfg,
commandName: "channels status",
targetIds: getChannelsCommandSecretTargetIds(),
targetIds: getConfiguredChannelsCommandSecretTargetIds(cfg),
mode: "read_only_status",
runtime,
});
const snapshot = await readConfigFileSnapshot();
const mode = cfg.gateway?.mode === "remote" ? "remote" : "local";
if (opts.json) {
writeRuntimeJson(runtime, {
gatewayReachable: false,
error: safeError,
configOnly: true,
config: {
path: snapshot.path,
mode,
},
configuredChannels: listConfiguredChannelIdsForReadOnlyScope({
config: resolvedConfig,
env: process.env,
includePersistedAuthState: false,
}),
});
return;
}
runtime.log(
(
await formatConfigChannelsStatusLines(

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

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,8 +20,6 @@ import { hasConfiguredMemorySecretInput } from "../memory-host-sdk/secret.js";
import {
auditDreamingArtifacts,
auditShortTermPromotionArtifacts,
getBuiltinMemoryEmbeddingProviderDoctorMetadata,
listBuiltinAutoSelectMemoryEmbeddingProviderDoctorMetadata,
repairDreamingArtifacts,
repairShortTermPromotionArtifacts,
type DreamingArtifactsAuditSummary,
@@ -26,18 +29,13 @@ import {
getActiveMemorySearchManager,
resolveActiveMemoryBackendConfig,
} from "../plugins/memory-runtime.js";
import { getProviderEnvVars } from "../secrets/provider-env-vars.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { note } from "../terminal/note.js";
import { resolveUserPath } from "../utils.js";
import type { DoctorPrompter } from "./doctor-prompter.js";
import { isRecord } from "./doctor/shared/legacy-config-record-shared.js";
function resolveSuggestedRemoteMemoryProvider(): string | undefined {
return listBuiltinAutoSelectMemoryEmbeddingProviderDoctorMetadata().find(
(provider) => provider.transport === "remote",
)?.providerId;
}
type RuntimeMemoryAuditContext = {
workspaceDir?: string;
backend?: string;
@@ -45,6 +43,90 @@ type RuntimeMemoryAuditContext = {
qmdCollections?: number;
};
type MemoryEmbeddingProviderDoctorMetadata = {
providerId: string;
authProviderId: string;
transport: "local" | "remote";
autoSelectPriority?: number;
};
const BUNDLED_MEMORY_EMBEDDING_PROVIDER_DOCTOR_METADATA: MemoryEmbeddingProviderDoctorMetadata[] = [
{
providerId: "github-copilot",
authProviderId: "github-copilot",
transport: "remote",
autoSelectPriority: 15,
},
{
providerId: "openai",
authProviderId: "openai",
transport: "remote",
autoSelectPriority: 20,
},
{
providerId: "gemini",
authProviderId: "google",
transport: "remote",
autoSelectPriority: 30,
},
{
providerId: "voyage",
authProviderId: "voyage",
transport: "remote",
autoSelectPriority: 40,
},
{
providerId: "mistral",
authProviderId: "mistral",
transport: "remote",
autoSelectPriority: 50,
},
{
providerId: "bedrock",
authProviderId: "amazon-bedrock",
transport: "remote",
autoSelectPriority: 60,
},
];
function resolveMemoryEmbeddingProviderDoctorMetadata(
providerId: string,
): (MemoryEmbeddingProviderDoctorMetadata & { envVars: string[] }) | null {
const metadata =
BUNDLED_MEMORY_EMBEDDING_PROVIDER_DOCTOR_METADATA.find(
(candidate) => candidate.providerId === providerId,
) ?? null;
if (!metadata) {
return null;
}
return {
...metadata,
envVars: getProviderEnvVars(metadata.authProviderId),
};
}
function listAutoSelectMemoryEmbeddingProviderDoctorMetadata(): Array<
MemoryEmbeddingProviderDoctorMetadata & { envVars: string[] }
> {
return BUNDLED_MEMORY_EMBEDDING_PROVIDER_DOCTOR_METADATA.filter(
(provider) => typeof provider.autoSelectPriority === "number",
)
.toSorted((a, b) => (a.autoSelectPriority ?? 0) - (b.autoSelectPriority ?? 0))
.map((provider) => ({
providerId: provider.providerId,
authProviderId: provider.authProviderId,
transport: provider.transport,
autoSelectPriority: provider.autoSelectPriority,
envVars: getProviderEnvVars(provider.authProviderId),
}));
}
function resolveSuggestedRemoteMemoryProvider(): string | undefined {
return listAutoSelectMemoryEmbeddingProviderDoctorMetadata().find(
(provider) => provider.transport === "remote",
)?.providerId;
}
async function resolveRuntimeMemoryAuditContext(
cfg: OpenClawConfig,
): Promise<RuntimeMemoryAuditContext | null> {
@@ -373,7 +455,7 @@ export async function noteMemorySearchHealth(
if (hasLocalEmbeddings(resolved.local)) {
return;
}
const autoSelectProviders = listBuiltinAutoSelectMemoryEmbeddingProviderDoctorMetadata().filter(
const autoSelectProviders = listAutoSelectMemoryEmbeddingProviderDoctorMetadata().filter(
(provider) => provider.transport === "remote",
);
for (const provider of autoSelectProviders) {
@@ -450,10 +532,20 @@ async function hasApiKeyForProvider(
cfg: OpenClawConfig,
agentDir: string,
): Promise<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 +556,7 @@ async function hasApiKeyForProvider(
}
function resolvePrimaryMemoryProviderEnvVar(provider: string): string {
const metadata = getBuiltinMemoryEmbeddingProviderDoctorMetadata(provider);
const metadata = resolveMemoryEmbeddingProviderDoctorMetadata(provider);
return metadata?.envVars[0] ?? `${provider.toUpperCase()}_API_KEY`;
}

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

@@ -16,6 +16,11 @@ const mocks = vi.hoisted(() => ({
callGateway: vi.fn(),
getDaemonStatusSummary: vi.fn(),
getNodeDaemonStatusSummary: vi.fn(),
resolveReadOnlyChannelPluginsForConfig: vi.fn(),
}));
vi.mock("../channels/plugins/read-only.js", () => ({
resolveReadOnlyChannelPluginsForConfig: mocks.resolveReadOnlyChannelPluginsForConfig,
}));
vi.mock("../infra/provider-usage.js", () => ({
@@ -43,6 +48,11 @@ describe("status-runtime-shared", () => {
mocks.callGateway.mockResolvedValue({ ok: true });
mocks.getDaemonStatusSummary.mockResolvedValue({ label: "LaunchAgent" });
mocks.getNodeDaemonStatusSummary.mockResolvedValue({ label: "node" });
mocks.resolveReadOnlyChannelPluginsForConfig.mockReturnValue({
plugins: [{ id: "telegram" }],
configuredChannelIds: ["telegram"],
missingConfiguredChannelIds: [],
});
});
it("resolves the shared security audit payload", async () => {
@@ -51,6 +61,32 @@ describe("status-runtime-shared", () => {
sourceConfig: { gateway: {} },
});
expect(mocks.runSecurityAudit).toHaveBeenCalledWith({
config: { gateway: {} },
sourceConfig: { gateway: {} },
deep: false,
includeFilesystem: true,
includeChannelSecurity: true,
plugins: expect.any(Array),
});
expect(mocks.resolveReadOnlyChannelPluginsForConfig).toHaveBeenCalledWith(
{ gateway: {} },
{ activationSourceConfig: { gateway: {} } },
);
});
it("lets the security audit load configured channel plugins when read-only discovery is incomplete", async () => {
mocks.resolveReadOnlyChannelPluginsForConfig.mockReturnValue({
plugins: [],
configuredChannelIds: ["external"],
missingConfiguredChannelIds: ["external"],
});
await resolveStatusSecurityAudit({
config: { gateway: {} },
sourceConfig: { gateway: {} },
});
expect(mocks.runSecurityAudit).toHaveBeenCalledWith({
config: { gateway: {} },
sourceConfig: { gateway: {} },
@@ -244,6 +280,7 @@ describe("status-runtime-shared", () => {
deep: false,
includeFilesystem: true,
includeChannelSecurity: true,
plugins: expect.any(Array),
});
});
});

View File

@@ -1,3 +1,4 @@
import { resolveReadOnlyChannelPluginsForConfig } from "../channels/plugins/read-only.js";
import type { OpenClawConfig } from "../config/types.js";
import type { HeartbeatEventPayload } from "../infra/heartbeat-events.js";
import type { HealthSummary } from "./health.js";
@@ -27,12 +28,18 @@ export async function resolveStatusSecurityAudit(params: {
sourceConfig: OpenClawConfig;
}) {
const { runSecurityAudit } = await loadSecurityAuditModule();
const readOnlyPlugins = resolveReadOnlyChannelPluginsForConfig(params.config, {
activationSourceConfig: params.sourceConfig,
});
return await runSecurityAudit({
config: params.config,
sourceConfig: params.sourceConfig,
deep: false,
includeFilesystem: true,
includeChannelSecurity: true,
...(readOnlyPlugins.missingConfiguredChannelIds.length === 0
? { plugins: readOnlyPlugins.plugins }
: {}),
});
}

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

@@ -1,8 +1,8 @@
import { hasPotentialConfiguredChannels } from "../channels/config-presence.js";
import type { OpenClawConfig } from "../config/types.js";
import type { collectChannelStatusIssues as collectChannelStatusIssuesFn } from "../infra/channels-status-issues.js";
import { resolveOsSummary } from "../infra/os-summary.js";
import type { UpdateCheckResult } from "../infra/update-check.js";
import { hasConfiguredChannelsForReadOnlyScope } from "../plugins/channel-plugin-ids.js";
import type { RuntimeEnv } from "../runtime.js";
import type { buildChannelsTable as buildChannelsTableFn } from "./status-all/channels.js";
import type { getAgentLocalStatuses as getAgentLocalStatusesFn } from "./status.agent-local.js";
@@ -168,7 +168,9 @@ export async function collectStatusScanOverview(params: {
).resolveCommandConfigWithSecrets({
config: loadedConfig,
commandName: params.commandName,
targetIds: (await loadCommandSecretTargetsModule()).getStatusCommandSecretTargetIds(),
targetIds: (await loadCommandSecretTargetsModule()).getStatusCommandSecretTargetIds(
loadedConfig,
),
mode: "read_only_status",
...(params.runtime ? { runtime: params.runtime } : {}),
}),
@@ -176,7 +178,7 @@ export async function collectStatusScanOverview(params: {
params.progress?.tick();
const hasConfiguredChannels = params.resolveHasConfiguredChannels
? params.resolveHasConfiguredChannels(cfg)
: hasPotentialConfiguredChannels(cfg);
: hasConfiguredChannelsForReadOnlyScope({ config: cfg });
const osSummary = resolveOsSummary();
const bootstrap = await createStatusScanCoreBootstrap<
Awaited<ReturnType<typeof getAgentLocalStatusesFn>>
@@ -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,5 @@
import { hasPotentialConfiguredChannels } from "../channels/config-presence.js";
import { ensureCliPluginRegistryLoaded } from "../cli/plugin-registry-loader.js";
import type { OpenClawConfig } from "../config/types.js";
import { hasConfiguredChannelsForReadOnlyScope } from "../plugins/channel-plugin-ids.js";
import type { RuntimeEnv } from "../runtime.js";
import { executeStatusScanFromOverview } from "./status.scan-execute.ts";
import {
@@ -12,9 +12,8 @@ import type { StatusScanResult } from "./status.scan-result.ts";
type StatusJsonScanPolicy = {
commandName: string;
allowMissingConfigFastPath?: boolean;
resolveHasConfiguredChannels: (
cfg: Parameters<typeof hasPotentialConfiguredChannels>[0],
) => boolean;
includeChannelSummary?: boolean;
resolveHasConfiguredChannels: (cfg: OpenClawConfig) => boolean;
resolveMemory: Parameters<typeof executeStatusScanFromOverview>[0]["resolveMemory"];
};
@@ -35,18 +34,12 @@ export async function scanStatusJsonWithPolicy(
resolveHasConfiguredChannels: policy.resolveHasConfiguredChannels,
includeChannelsData: false,
});
if (overview.hasConfiguredChannels) {
await ensureCliPluginRegistryLoaded({
scope: "configured-channels",
routeLogsToStderr: true,
config: overview.cfg,
activationSourceConfig: overview.sourceConfig,
});
}
return await executeStatusScanFromOverview({
overview,
runtime,
summary: {
includeChannelSummary: policy.includeChannelSummary,
},
resolveMemory: policy.resolveMemory,
channelIssues: [],
channels: { rows: [], details: [] },
@@ -64,8 +57,11 @@ export async function scanStatusJsonFast(
return await scanStatusJsonWithPolicy(opts, runtime, {
commandName: "status --json",
allowMissingConfigFastPath: true,
includeChannelSummary: false,
resolveHasConfiguredChannels: (cfg) =>
hasPotentialConfiguredChannels(cfg, process.env, {
hasConfiguredChannelsForReadOnlyScope({
config: cfg,
env: process.env,
includePersistedAuthState: false,
}),
resolveMemory: async ({ cfg, agentStatus, memoryPlugin }) =>

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,
};
}
@@ -178,6 +182,36 @@ export async function loadStatusScanModuleForTest(
vi.doMock("../channels/config-presence.js", () => ({
hasPotentialConfiguredChannels: mocks.hasPotentialConfiguredChannels,
}));
vi.doMock("../plugins/channel-plugin-ids.js", () => ({
hasConfiguredChannelsForReadOnlyScope: (params: {
config: OpenClawConfig;
env?: NodeJS.ProcessEnv;
includePersistedAuthState?: boolean;
}) =>
Boolean(
mocks.hasPotentialConfiguredChannels(
params.config,
params.env,
params.includePersistedAuthState === undefined
? undefined
: { includePersistedAuthState: params.includePersistedAuthState },
),
),
listConfiguredChannelIdsForReadOnlyScope: (params: {
config: OpenClawConfig;
env?: NodeJS.ProcessEnv;
includePersistedAuthState?: boolean;
}) =>
mocks.hasPotentialConfiguredChannels(
params.config,
params.env,
params.includePersistedAuthState === undefined
? undefined
: { includePersistedAuthState: params.includePersistedAuthState },
)
? ["mock-channel"]
: [],
}));
vi.doMock("../config/io.js", () => ({
readBestEffortConfig: mocks.readBestEffortConfig,

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 { hasConfiguredChannelsForReadOnlyScope } from "../plugins/channel-plugin-ids.js";
import { buildPluginCompatibilitySnapshotNotices } from "../plugins/status.js";
import type { RuntimeEnv } from "../runtime.js";
import { executeStatusScanFromOverview } from "./status.scan-execute.ts";
import { resolveStatusMemoryStatusSnapshot } from "./status.scan-memory.ts";
@@ -25,7 +25,8 @@ export async function scanStatus(
_runtime,
{
commandName: "status --json",
resolveHasConfiguredChannels: (cfg) => hasPotentialConfiguredChannels(cfg),
resolveHasConfiguredChannels: (cfg) =>
hasConfiguredChannelsForReadOnlyScope({ config: cfg }),
resolveMemory: async ({ cfg, agentStatus, memoryPlugin }) =>
await resolveStatusMemoryStatusSnapshot({
cfg,
@@ -59,7 +60,7 @@ export async function scanStatus(
});
progress.setLabel("Checking plugins…");
const pluginCompatibility = buildPluginCompatibilityNotices({ config: overview.cfg });
const pluginCompatibility = buildPluginCompatibilitySnapshotNotices({ config: overview.cfg });
progress.tick();
progress.setLabel("Checking memory and sessions…");

View File

@@ -1,12 +1,12 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const statusSummaryMocks = vi.hoisted(() => ({
hasPotentialConfiguredChannels: vi.fn(() => true),
hasConfiguredChannelsForReadOnlyScope: vi.fn(() => true),
buildChannelSummary: vi.fn(async () => ["ok"]),
}));
vi.mock("../channels/config-presence.js", () => ({
hasPotentialConfiguredChannels: statusSummaryMocks.hasPotentialConfiguredChannels,
vi.mock("../plugins/channel-plugin-ids.js", () => ({
hasConfiguredChannelsForReadOnlyScope: statusSummaryMocks.hasConfiguredChannelsForReadOnlyScope,
}));
vi.mock("./status.summary.runtime.js", () => ({
@@ -125,7 +125,7 @@ describe("getStatusSummary", () => {
beforeEach(() => {
vi.clearAllMocks();
statusSummaryMocks.hasPotentialConfiguredChannels.mockReturnValue(true);
statusSummaryMocks.hasConfiguredChannelsForReadOnlyScope.mockReturnValue(true);
statusSummaryMocks.buildChannelSummary.mockResolvedValue(["ok"]);
});
@@ -140,12 +140,25 @@ describe("getStatusSummary", () => {
});
it("skips channel summary imports when no channels are configured", async () => {
statusSummaryMocks.hasPotentialConfiguredChannels.mockReturnValue(false);
statusSummaryMocks.hasConfiguredChannelsForReadOnlyScope.mockReturnValue(false);
const summary = await getStatusSummary();
expect(summary.channelSummary).toEqual([]);
expect(summary.linkChannel).toBeUndefined();
expect(statusSummaryMocks.hasConfiguredChannelsForReadOnlyScope).toHaveBeenCalledWith({
config: {},
});
expect(buildChannelSummary).not.toHaveBeenCalled();
expect(resolveLinkChannelContext).not.toHaveBeenCalled();
});
it("skips channel summary imports when explicitly disabled", async () => {
const summary = await getStatusSummary({ includeChannelSummary: false });
expect(summary.channelSummary).toEqual([]);
expect(summary.linkChannel).toBeUndefined();
expect(statusSummaryMocks.hasConfiguredChannelsForReadOnlyScope).not.toHaveBeenCalled();
expect(buildChannelSummary).not.toHaveBeenCalled();
expect(resolveLinkChannelContext).not.toHaveBeenCalled();
});

View File

@@ -1,5 +1,4 @@
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import { hasPotentialConfiguredChannels } from "../channels/config-presence.js";
import { resolveMainSessionKey } from "../config/sessions/main-session.js";
import { resolveStorePath } from "../config/sessions/paths.js";
import { readSessionStoreReadOnly } from "../config/sessions/store-read.js";
@@ -8,6 +7,7 @@ import type { OpenClawConfig } from "../config/types.js";
import { listGatewayAgentsBasic } from "../gateway/agent-list.js";
import { resolveHeartbeatSummaryForAgent } from "../infra/heartbeat-summary.js";
import { peekSystemEvents } from "../infra/system-events.js";
import { hasConfiguredChannelsForReadOnlyScope } from "../plugins/channel-plugin-ids.js";
import { parseAgentSessionKey } from "../routing/session-key.js";
import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js";
import { resolveRuntimeServiceVersion } from "../version.js";
@@ -105,11 +105,12 @@ export function redactSensitiveStatusSummary(summary: StatusSummary): StatusSumm
export async function getStatusSummary(
options: {
includeSensitive?: boolean;
includeChannelSummary?: boolean;
config?: OpenClawConfig;
sourceConfig?: OpenClawConfig;
} = {},
): Promise<StatusSummary> {
const { includeSensitive = true } = options;
const { includeSensitive = true, includeChannelSummary = true } = options;
const {
classifySessionKey,
resolveConfiguredStatusModelRef,
@@ -117,7 +118,11 @@ export async function getStatusSummary(
resolveSessionModelRef,
} = await loadStatusSummaryRuntimeModule();
const cfg = options.config ?? (await loadConfigIoModule()).loadConfig();
const needsChannelPlugins = hasPotentialConfiguredChannels(cfg);
const needsChannelPlugins =
includeChannelSummary &&
hasConfiguredChannelsForReadOnlyScope({
config: cfg,
});
const linkContext = needsChannelPlugins
? await loadLinkChannelModule().then(({ resolveLinkChannelContext }) =>
resolveLinkChannelContext(cfg),

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

@@ -1,18 +1,29 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const resolveProviderUsageAuthWithPluginMock = vi.fn(
async (..._args: unknown[]): Promise<unknown> => null,
);
const hasAnyAuthProfileStoreSourceMock = vi.fn(() => false);
const ensureAuthProfileStoreMock = vi.fn(() => ({
profiles: {},
}));
const ensureAuthProfileStoreWithoutExternalProfilesMock = vi.fn(() => ({
profiles: {},
}));
const resolveAuthProfileOrderMock = vi.fn((_params: unknown): string[] => []);
vi.mock("../agents/auth-profiles.js", () => ({
dedupeProfileIds: (profileIds: string[]) => [...new Set(profileIds)],
ensureAuthProfileStore: () => ensureAuthProfileStoreMock(),
ensureAuthProfileStoreWithoutExternalProfiles: () =>
ensureAuthProfileStoreWithoutExternalProfilesMock(),
hasAnyAuthProfileStoreSource: () => hasAnyAuthProfileStoreSourceMock(),
listProfilesForProvider: () => [],
resolveApiKeyForProfile: async () => null,
resolveAuthProfileOrder: () => [],
resolveAuthProfileOrder: (params: unknown) => resolveAuthProfileOrderMock(params),
}));
vi.mock("../plugins/provider-runtime.js", async () => {
@@ -27,13 +38,33 @@ vi.mock("../plugins/provider-runtime.js", async () => {
let resolveProviderAuths: typeof import("./provider-usage.auth.js").resolveProviderAuths;
async function withTempHome<T>(fn: (homeDir: string) => Promise<T>): Promise<T> {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-provider-usage-"));
try {
return await fn(homeDir);
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
}
describe("resolveProviderAuths plugin boundary", () => {
beforeAll(async () => {
({ resolveProviderAuths } = await import("./provider-usage.auth.js"));
});
beforeEach(() => {
hasAnyAuthProfileStoreSourceMock.mockReset();
hasAnyAuthProfileStoreSourceMock.mockReturnValue(false);
ensureAuthProfileStoreMock.mockClear();
ensureAuthProfileStoreMock.mockReturnValue({
profiles: {},
});
ensureAuthProfileStoreWithoutExternalProfilesMock.mockClear();
ensureAuthProfileStoreWithoutExternalProfilesMock.mockReturnValue({
profiles: {},
});
resolveAuthProfileOrderMock.mockReset();
resolveAuthProfileOrderMock.mockReturnValue([]);
resolveProviderUsageAuthWithPluginMock.mockReset();
resolveProviderUsageAuthWithPluginMock.mockResolvedValue(null);
});
@@ -55,4 +86,256 @@ describe("resolveProviderAuths plugin boundary", () => {
]);
expect(ensureAuthProfileStoreMock).not.toHaveBeenCalled();
});
it("skips plugin usage auth when requested and no direct credential source exists", async () => {
await withTempHome(async (homeDir) => {
await expect(
resolveProviderAuths({
providers: ["zai"],
skipPluginAuthWithoutCredentialSource: true,
env: { HOME: homeDir },
}),
).resolves.toEqual([]);
});
expect(resolveProviderUsageAuthWithPluginMock).not.toHaveBeenCalled();
expect(ensureAuthProfileStoreMock).not.toHaveBeenCalled();
});
it("keeps plugin usage auth when a shared legacy plugin credential source exists", async () => {
await withTempHome(async (homeDir) => {
fs.mkdirSync(path.join(homeDir, ".pi", "agent"), { recursive: true });
fs.writeFileSync(
path.join(homeDir, ".pi", "agent", "auth.json"),
`${JSON.stringify({ "z-ai": { access: "legacy-zai-token" } })}\n`,
);
resolveProviderUsageAuthWithPluginMock.mockResolvedValueOnce({
token: "legacy-zai-token",
});
await expect(
resolveProviderAuths({
providers: ["zai"],
skipPluginAuthWithoutCredentialSource: true,
env: { HOME: homeDir },
}),
).resolves.toEqual([
{
provider: "zai",
token: "legacy-zai-token",
},
]);
});
expect(resolveProviderUsageAuthWithPluginMock).toHaveBeenCalledWith(
expect.objectContaining({
provider: "zai",
}),
);
expect(ensureAuthProfileStoreMock).not.toHaveBeenCalled();
});
it("keeps legacy plugin credential sources provider-specific", async () => {
await withTempHome(async (homeDir) => {
fs.mkdirSync(path.join(homeDir, ".pi", "agent"), { recursive: true });
fs.writeFileSync(
path.join(homeDir, ".pi", "agent", "auth.json"),
`${JSON.stringify({ "z-ai": { access: "legacy-zai-token" } })}\n`,
);
resolveProviderUsageAuthWithPluginMock.mockResolvedValueOnce({
token: "legacy-zai-token",
});
await expect(
resolveProviderAuths({
providers: ["anthropic", "zai"],
skipPluginAuthWithoutCredentialSource: true,
env: { HOME: homeDir },
}),
).resolves.toEqual([
{
provider: "zai",
token: "legacy-zai-token",
},
]);
});
expect(resolveProviderUsageAuthWithPluginMock).toHaveBeenCalledTimes(1);
expect(resolveProviderUsageAuthWithPluginMock).toHaveBeenCalledWith(
expect.objectContaining({
provider: "zai",
}),
);
});
it("keeps auth-profile credential sources provider-specific", async () => {
hasAnyAuthProfileStoreSourceMock.mockReturnValue(true);
ensureAuthProfileStoreWithoutExternalProfilesMock.mockReturnValue({
profiles: {
"anthropic:default": {
type: "api_key",
provider: "anthropic",
key: "sk-ant",
},
},
});
resolveAuthProfileOrderMock.mockImplementation((params: unknown) => {
const provider =
params && typeof params === "object" && "provider" in params
? (params as { provider?: unknown }).provider
: undefined;
return provider === "anthropic" ? ["anthropic:default"] : [];
});
resolveProviderUsageAuthWithPluginMock.mockResolvedValueOnce({
token: "plugin-anthropic-token",
});
await withTempHome(async (homeDir) => {
await expect(
resolveProviderAuths({
providers: ["anthropic", "zai"],
skipPluginAuthWithoutCredentialSource: true,
env: { HOME: homeDir },
}),
).resolves.toEqual([
{
provider: "anthropic",
token: "plugin-anthropic-token",
},
]);
});
expect(resolveProviderUsageAuthWithPluginMock).toHaveBeenCalledTimes(1);
expect(resolveProviderUsageAuthWithPluginMock).toHaveBeenCalledWith(
expect.objectContaining({
provider: "anthropic",
}),
);
expect(ensureAuthProfileStoreMock).not.toHaveBeenCalled();
});
it("keeps plugin usage auth when an owned alias provider has auth-profile credentials", async () => {
hasAnyAuthProfileStoreSourceMock.mockReturnValue(true);
ensureAuthProfileStoreWithoutExternalProfilesMock.mockReturnValue({
profiles: {
"minimax-portal:default": {
type: "oauth",
provider: "minimax-portal",
accessToken: "portal-oauth-token",
},
},
});
resolveAuthProfileOrderMock.mockImplementation((params: unknown) => {
const provider =
params && typeof params === "object" && "provider" in params
? (params as { provider?: unknown }).provider
: undefined;
return provider === "minimax-portal" ? ["minimax-portal:default"] : [];
});
resolveProviderUsageAuthWithPluginMock.mockResolvedValueOnce({
token: "plugin-minimax-token",
});
await withTempHome(async (homeDir) => {
await expect(
resolveProviderAuths({
providers: ["minimax"],
skipPluginAuthWithoutCredentialSource: true,
env: { HOME: homeDir },
}),
).resolves.toEqual([
{
provider: "minimax",
token: "plugin-minimax-token",
},
]);
});
expect(resolveAuthProfileOrderMock).toHaveBeenCalledWith(
expect.objectContaining({
provider: "minimax-portal",
}),
);
expect(resolveProviderUsageAuthWithPluginMock).toHaveBeenCalledWith(
expect.objectContaining({
provider: "minimax",
}),
);
expect(ensureAuthProfileStoreMock).not.toHaveBeenCalled();
});
it("keeps plugin usage auth when provider-owned usage env credentials exist", async () => {
resolveProviderUsageAuthWithPluginMock.mockResolvedValueOnce({
token: "plugin-minimax-token",
});
await withTempHome(async (homeDir) => {
await expect(
resolveProviderAuths({
providers: ["minimax"],
skipPluginAuthWithoutCredentialSource: true,
env: {
HOME: homeDir,
MINIMAX_CODE_PLAN_KEY: "code-plan-key",
},
}),
).resolves.toEqual([
{
provider: "minimax",
token: "plugin-minimax-token",
},
]);
});
expect(resolveProviderUsageAuthWithPluginMock).toHaveBeenCalledWith(
expect.objectContaining({
provider: "minimax",
}),
);
expect(ensureAuthProfileStoreMock).not.toHaveBeenCalled();
});
it("does not overlay external auth profiles while checking the skip gate", async () => {
hasAnyAuthProfileStoreSourceMock.mockReturnValue(true);
await withTempHome(async (homeDir) => {
await expect(
resolveProviderAuths({
providers: ["anthropic"],
skipPluginAuthWithoutCredentialSource: true,
env: { HOME: homeDir },
}),
).resolves.toEqual([]);
});
expect(ensureAuthProfileStoreWithoutExternalProfilesMock).toHaveBeenCalledTimes(1);
expect(ensureAuthProfileStoreMock).not.toHaveBeenCalled();
expect(resolveProviderUsageAuthWithPluginMock).not.toHaveBeenCalled();
});
it("skips plugin usage auth per provider when only another provider has direct credentials", async () => {
await withTempHome(async (homeDir) => {
await expect(
resolveProviderAuths({
providers: ["anthropic", "zai"],
skipPluginAuthWithoutCredentialSource: true,
env: {
HOME: homeDir,
ANTHROPIC_API_KEY: "sk-ant",
},
}),
).resolves.toEqual([
{
provider: "anthropic",
token: "sk-ant",
},
]);
});
expect(resolveProviderUsageAuthWithPluginMock).toHaveBeenCalledTimes(1);
expect(resolveProviderUsageAuthWithPluginMock).toHaveBeenCalledWith(
expect.objectContaining({
provider: "anthropic",
}),
);
});
});

View File

@@ -1,16 +1,29 @@
import {
dedupeProfileIds,
ensureAuthProfileStore,
ensureAuthProfileStoreWithoutExternalProfiles,
hasAnyAuthProfileStoreSource,
listProfilesForProvider,
resolveApiKeyForProfile,
resolveAuthProfileOrder,
} from "../agents/auth-profiles.js";
import { resolveEnvApiKey } from "../agents/model-auth-env.js";
import { isNonSecretApiKeyMarker } from "../agents/model-auth-markers.js";
import { resolveUsableCustomProviderApiKey } from "../agents/model-auth.js";
import { normalizeProviderId } from "../agents/model-selection.js";
import { loadConfig, type OpenClawConfig } from "../config/config.js";
import { normalizePluginsConfig } from "../plugins/config-state.js";
import {
isActivatedManifestOwner,
passesManifestOwnerBasePolicy,
} from "../plugins/manifest-owner-policy.js";
import {
loadPluginManifestRegistry,
type PluginManifestRecord,
} from "../plugins/manifest-registry.js";
import { resolveProviderUsageAuthWithPlugin } from "../plugins/provider-runtime.js";
import { normalizeSecretInput } from "../utils/normalize-secret-input.js";
import { resolveLegacyPiAgentAccessToken } from "./provider-usage.shared.js";
import type { UsageProviderId } from "./provider-usage.types.js";
export type ProviderAuth = {
@@ -25,6 +38,7 @@ type UsageAuthState = {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
agentDir?: string;
allowAuthProfileStore: boolean;
store?: AuthStore;
};
@@ -35,7 +49,7 @@ function resolveUsageAuthStore(state: UsageAuthState): AuthStore {
return state.store;
}
function resolveProviderApiKeyFromConfigAndStore(params: {
function resolveProviderApiKeyFromConfig(params: {
state: UsageAuthState;
providerIds: string[];
envDirect?: Array<string | undefined>;
@@ -46,14 +60,31 @@ function resolveProviderApiKeyFromConfigAndStore(params: {
}
for (const providerId of params.providerIds) {
const envKey = resolveEnvApiKey(providerId, params.state.env)?.apiKey;
if (envKey) {
return envKey;
}
const key = resolveUsableCustomProviderApiKey({
cfg: params.state.cfg,
provider: providerId,
env: params.state.env,
})?.apiKey;
if (key) {
return key;
}
}
return undefined;
}
function resolveProviderApiKeyFromConfigAndStore(params: {
state: UsageAuthState;
providerIds: string[];
envDirect?: Array<string | undefined>;
}): string | undefined {
const configKey = resolveProviderApiKeyFromConfig(params);
if (configKey || !params.state.allowAuthProfileStore) {
return configKey;
}
const normalizedProviderIds = new Set(
params.providerIds.map((providerId) => normalizeProviderId(providerId)).filter(Boolean),
@@ -88,10 +119,76 @@ function resolveProviderApiKeyFromConfigAndStore(params: {
return undefined;
}
function normalizeProviderIds(providerIds: Iterable<string | undefined>): string[] {
return [
...new Set(
[...providerIds]
.map((providerId) => (providerId ? normalizeProviderId(providerId) : undefined))
.filter((providerId): providerId is string => Boolean(providerId)),
),
];
}
function isUsageProviderManifestEligible(params: {
plugin: PluginManifestRecord;
state: UsageAuthState;
}): boolean {
const normalizedConfig = normalizePluginsConfig(params.state.cfg.plugins);
if (
!passesManifestOwnerBasePolicy({
plugin: params.plugin,
normalizedConfig,
})
) {
return false;
}
if (params.plugin.origin !== "workspace") {
return true;
}
return isActivatedManifestOwner({
plugin: params.plugin,
normalizedConfig,
rootConfig: params.state.cfg,
});
}
function resolveUsageCredentialProviderIds(params: {
state: UsageAuthState;
provider: UsageProviderId;
}): string[] {
const providerIds = new Set(normalizeProviderIds([params.provider]));
const providerIdSet = new Set(providerIds);
try {
const registry = loadPluginManifestRegistry({
config: params.state.cfg,
env: params.state.env,
});
for (const plugin of registry.plugins) {
const pluginProviderIds = normalizeProviderIds(plugin.providers);
if (!pluginProviderIds.some((providerId) => providerIdSet.has(providerId))) {
continue;
}
if (!isUsageProviderManifestEligible({ plugin, state: params.state })) {
continue;
}
for (const providerId of pluginProviderIds) {
providerIds.add(providerId);
}
}
} catch {
// Credential-source checks are an optimization gate; preserve usage fallback
// behavior if manifest discovery is unavailable in a constrained environment.
}
return [...providerIds];
}
async function resolveOAuthToken(params: {
state: UsageAuthState;
provider: string;
}): Promise<ProviderAuth | null> {
if (!params.state.allowAuthProfileStore) {
return null;
}
const store = resolveUsageAuthStore(params.state);
const order = resolveAuthProfileOrder({
cfg: params.state.cfg,
@@ -202,32 +299,97 @@ async function resolveProviderUsageAuthFallback(params: {
return null;
}
function hasAuthProfileCredentialSource(params: {
state: UsageAuthState;
providerIds: string[];
}): boolean {
const store = ensureAuthProfileStoreWithoutExternalProfiles(params.state.agentDir, {
allowKeychainPrompt: false,
});
for (const provider of params.providerIds) {
const order = resolveAuthProfileOrder({
cfg: params.state.cfg,
store,
provider,
});
if (
dedupeProfileIds(order).some((profileId) => {
const cred = store.profiles[profileId];
return cred?.type === "api_key" || cred?.type === "oauth" || cred?.type === "token";
})
) {
return true;
}
}
return false;
}
function resolveLegacyPiAgentProviderIds(provider: UsageProviderId): string[] {
return provider === "zai" ? ["z-ai", "zai"] : [provider];
}
export async function resolveProviderAuths(params: {
providers: UsageProviderId[];
auth?: ProviderAuth[];
agentDir?: string;
config?: OpenClawConfig;
env?: NodeJS.ProcessEnv;
skipPluginAuthWithoutCredentialSource?: boolean;
}): Promise<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 hasAuthProfileStoreSource = hasAnyAuthProfileStoreSource(params.agentDir);
const authProfileSourceState: UsageAuthState = {
...stateBase,
allowAuthProfileStore: true,
};
const auths: ProviderAuth[] = [];
for (const provider of params.providers) {
const pluginAuth = await resolveProviderUsageAuthViaPlugin({
state,
const credentialProviderIds = resolveUsageCredentialProviderIds({
state: { ...stateBase, allowAuthProfileStore: false },
provider,
});
if (pluginAuth) {
auths.push(pluginAuth);
continue;
const hasDirectCredentialSource = Boolean(
resolveProviderApiKeyFromConfig({
state: { ...stateBase, allowAuthProfileStore: false },
providerIds: credentialProviderIds,
}),
);
const allowAuthProfileStore =
!params.skipPluginAuthWithoutCredentialSource ||
hasDirectCredentialSource ||
(hasAuthProfileStoreSource &&
hasAuthProfileCredentialSource({
state: authProfileSourceState,
providerIds: credentialProviderIds,
}));
const state: UsageAuthState = {
...stateBase,
allowAuthProfileStore,
};
const hasLegacyPiAgentCredentialSource = Boolean(
resolveLegacyPiAgentAccessToken(stateBase.env, resolveLegacyPiAgentProviderIds(provider)),
);
const hasPluginCredentialSource =
hasDirectCredentialSource || allowAuthProfileStore || hasLegacyPiAgentCredentialSource;
if (!params.skipPluginAuthWithoutCredentialSource || hasPluginCredentialSource) {
const pluginAuth = await resolveProviderUsageAuthViaPlugin({
state,
provider,
});
if (pluginAuth) {
auths.push(pluginAuth);
continue;
}
}
const fallbackAuth = await resolveProviderUsageAuthFallback({
state,

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

@@ -71,17 +71,16 @@ export const withTimeout = async <T>(work: Promise<T>, ms: number, fallback: T):
}
};
function resolveLegacyPiAgentAuthPath(env: NodeJS.ProcessEnv): string {
return path.join(resolveRequiredHomeDir(env, os.homedir), ".pi", "agent", "auth.json");
}
export function resolveLegacyPiAgentAccessToken(
env: NodeJS.ProcessEnv,
providerIds: string[],
): string | undefined {
try {
const authPath = path.join(
resolveRequiredHomeDir(env, os.homedir),
".pi",
"agent",
"auth.json",
);
const authPath = resolveLegacyPiAgentAuthPath(env);
if (!fs.existsSync(authPath)) {
return undefined;
}

View File

@@ -19,6 +19,8 @@ vi.mock("./manifest-registry.js", async (importOriginal) => {
});
import {
hasConfiguredChannelsForReadOnlyScope,
listConfiguredChannelIdsForReadOnlyScope,
resolveConfiguredChannelPluginIds,
resolveGatewayStartupPluginIds,
} from "./channel-plugin-ids.js";
@@ -102,6 +104,28 @@ function createManifestRegistryFixture() {
providers: [],
cliBackends: [],
},
{
id: "external-env-channel-plugin",
channels: ["external-env-channel"],
channelEnvVars: {
"external-env-channel": ["EXTERNAL_ENV_CHANNEL_TOKEN"],
},
origin: "config",
enabledByDefault: undefined,
providers: [],
cliBackends: [],
},
{
id: "ambient-env-channel-plugin",
channels: ["ambient-env-channel"],
channelEnvVars: {
"ambient-env-channel": ["HOME", "PATH"],
},
origin: "config",
enabledByDefault: undefined,
providers: [],
cliBackends: [],
},
{
id: "voice-call",
channels: [],
@@ -586,6 +610,22 @@ describe("resolveConfiguredChannelPluginIds", () => {
).toEqual([]);
});
it("includes trusted external channel owners configured only by manifest env vars", () => {
expect(
resolveConfiguredChannelPluginIds({
config: {
plugins: {
allow: ["external-env-channel-plugin"],
},
} as OpenClawConfig,
workspaceDir: "/tmp",
env: {
EXTERNAL_ENV_CHANNEL_TOKEN: "token",
} as NodeJS.ProcessEnv,
}),
).toEqual(["external-env-channel-plugin"]);
});
it("blocks bundled activation owners when explicitly disabled", () => {
expect(
resolveConfiguredChannelPluginIds({
@@ -607,3 +647,151 @@ describe("resolveConfiguredChannelPluginIds", () => {
).toEqual([]);
});
});
describe("listConfiguredChannelIdsForReadOnlyScope", () => {
beforeEach(() => {
listPotentialConfiguredChannelIds.mockReset().mockReturnValue([]);
hasPotentialConfiguredChannels.mockReset().mockReturnValue(false);
loadPluginManifestRegistry.mockReset().mockReturnValue(createManifestRegistryFixture());
});
it("uses manifest env vars as read-only configured channel triggers", () => {
expect(
listConfiguredChannelIdsForReadOnlyScope({
config: {
plugins: {
allow: ["external-env-channel-plugin"],
},
} as OpenClawConfig,
workspaceDir: "/tmp",
env: {
EXTERNAL_ENV_CHANNEL_TOKEN: "token",
} as NodeJS.ProcessEnv,
includePersistedAuthState: false,
}),
).toEqual(["external-env-channel"]);
});
it("ignores manifest env vars from untrusted external plugins", () => {
expect(
listConfiguredChannelIdsForReadOnlyScope({
config: {} as OpenClawConfig,
workspaceDir: "/tmp",
env: {
EXTERNAL_ENV_CHANNEL_TOKEN: "token",
} as NodeJS.ProcessEnv,
includePersistedAuthState: false,
}),
).toEqual([]);
expect(
hasConfiguredChannelsForReadOnlyScope({
config: {} as OpenClawConfig,
workspaceDir: "/tmp",
env: {
EXTERNAL_ENV_CHANNEL_TOKEN: "token",
} as NodeJS.ProcessEnv,
includePersistedAuthState: false,
}),
).toBe(false);
});
it("ignores ambient or malformed manifest env vars as read-only configured channel triggers", () => {
expect(
listConfiguredChannelIdsForReadOnlyScope({
config: {
plugins: {
allow: ["ambient-env-channel-plugin"],
},
} as OpenClawConfig,
workspaceDir: "/tmp",
env: {
HOME: "/tmp/user",
PATH: "/usr/bin",
lowercase_token: "token",
} as NodeJS.ProcessEnv,
includePersistedAuthState: false,
}),
).toEqual([]);
});
it("accepts lowercase or mixed-case manifest env vars as read-only configured channel triggers", () => {
expect(
listConfiguredChannelIdsForReadOnlyScope({
config: {
plugins: {
allow: ["external-env-channel-plugin"],
},
} as OpenClawConfig,
workspaceDir: "/tmp",
env: {
external_env_channel_token: "token",
} as NodeJS.ProcessEnv,
includePersistedAuthState: false,
manifestRecords: [
{
id: "external-env-channel-plugin",
channels: ["external-env-channel"],
channelEnvVars: {
"external-env-channel": ["external_env_channel_token"],
},
origin: "config",
enabledByDefault: undefined,
providers: [],
cliBackends: [],
} as never,
],
}),
).toEqual(["external-env-channel"]);
});
it("matches uppercase process env entries for lowercase manifest env var declarations", () => {
expect(
listConfiguredChannelIdsForReadOnlyScope({
config: {
plugins: {
allow: ["external-env-channel-plugin"],
},
} as OpenClawConfig,
workspaceDir: "/tmp",
env: {
EXTERNAL_ENV_CHANNEL_TOKEN: "token",
} as NodeJS.ProcessEnv,
includePersistedAuthState: false,
manifestRecords: [
{
id: "external-env-channel-plugin",
channels: ["external-env-channel"],
channelEnvVars: {
"external-env-channel": ["external_env_channel_token"],
},
origin: "config",
enabledByDefault: undefined,
providers: [],
cliBackends: [],
} as never,
],
}),
).toEqual(["external-env-channel"]);
});
it("uses manifest env vars for read-only channel presence checks", () => {
listPotentialConfiguredChannelIds.mockReturnValue([]);
hasPotentialConfiguredChannels.mockReturnValue(false);
expect(
hasConfiguredChannelsForReadOnlyScope({
config: {
plugins: {
allow: ["external-env-channel-plugin"],
},
} as OpenClawConfig,
workspaceDir: "/tmp",
env: {
EXTERNAL_ENV_CHANNEL_TOKEN: "token",
} as NodeJS.ProcessEnv,
includePersistedAuthState: false,
}),
).toBe(true);
});
});

View File

@@ -1,11 +1,16 @@
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { collectConfiguredAgentHarnessRuntimes } from "../agents/harness-runtimes.js";
import { listPotentialConfiguredChannelIds } from "../channels/config-presence.js";
import {
hasPotentialConfiguredChannels,
listPotentialConfiguredChannelIds,
} from "../channels/config-presence.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
resolveMemoryDreamingConfig,
resolveMemoryDreamingPluginConfig,
resolveMemoryDreamingPluginId,
} from "../memory-host-sdk/dreaming.js";
import { isSafeChannelEnvVarTriggerName } from "../secrets/channel-env-var-names.js";
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
import { resolveManifestActivationPluginIds } from "./activation-planner.js";
import {
@@ -61,6 +66,125 @@ function normalizeChannelIds(channelIds: Iterable<string>): string[] {
).toSorted((left, right) => left.localeCompare(right));
}
function hasNonEmptyEnvValue(env: NodeJS.ProcessEnv, key: string): boolean {
if (!isSafeChannelEnvVarTriggerName(key)) {
return false;
}
const trimmed = key.trim();
const value = env[trimmed] ?? env[trimmed.toUpperCase()];
return typeof value === "string" && value.trim().length > 0;
}
function listEnvConfiguredManifestChannelIds(params: {
records: readonly PluginManifestRecord[];
config: OpenClawConfig;
activationSourceConfig?: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): string[] {
const channelIds = new Set<string>();
const trustConfig = params.activationSourceConfig ?? params.config;
const normalizedConfig = normalizePluginsConfig(trustConfig.plugins);
for (const record of params.records) {
if (
!isChannelPluginEligibleForScopedOwnership({
plugin: record,
normalizedConfig,
rootConfig: trustConfig,
})
) {
continue;
}
for (const channelId of record.channels) {
const envVars = record.channelEnvVars?.[channelId] ?? [];
if (envVars.some((envVar) => hasNonEmptyEnvValue(params.env, envVar))) {
channelIds.add(channelId);
}
}
}
return [...channelIds].toSorted((left, right) => left.localeCompare(right));
}
function listConfiguredChannelIdsForPluginScope(params: {
config: OpenClawConfig;
activationSourceConfig?: OpenClawConfig;
workspaceDir?: string;
env: NodeJS.ProcessEnv;
cache?: boolean;
includePersistedAuthState?: boolean;
manifestRecords?: readonly PluginManifestRecord[];
}): string[] {
const records =
params.manifestRecords ??
loadPluginManifestRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
cache: params.cache,
}).plugins;
return [
...new Set([
...listPotentialConfiguredChannelIds(params.config, params.env, {
includePersistedAuthState: params.includePersistedAuthState,
}),
...listEnvConfiguredManifestChannelIds({
records,
config: params.config,
activationSourceConfig: params.activationSourceConfig,
env: params.env,
}),
]),
].toSorted((left, right) => left.localeCompare(right));
}
export function listConfiguredChannelIdsForReadOnlyScope(params: {
config: OpenClawConfig;
activationSourceConfig?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
cache?: boolean;
includePersistedAuthState?: boolean;
manifestRecords?: readonly PluginManifestRecord[];
}): string[] {
const env = params.env ?? process.env;
const workspaceDir =
params.workspaceDir ??
resolveAgentWorkspaceDir(params.config, resolveDefaultAgentId(params.config));
return listConfiguredChannelIdsForPluginScope({
config: params.config,
activationSourceConfig: params.activationSourceConfig,
workspaceDir,
env,
cache: params.cache,
includePersistedAuthState: params.includePersistedAuthState,
manifestRecords: params.manifestRecords,
});
}
export function hasConfiguredChannelsForReadOnlyScope(params: {
config: OpenClawConfig;
activationSourceConfig?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
cache?: boolean;
includePersistedAuthState?: boolean;
manifestRecords?: readonly PluginManifestRecord[];
}): boolean {
const env = params.env ?? process.env;
if (
hasPotentialConfiguredChannels(params.config, env, {
includePersistedAuthState: params.includePersistedAuthState,
})
) {
return true;
}
return (
listConfiguredChannelIdsForReadOnlyScope({
...params,
env,
}).length > 0
);
}
function isChannelPluginEligibleForScopedOwnership(params: {
plugin: PluginManifestRecord;
normalizedConfig: ReturnType<typeof normalizePluginsConfig>;
@@ -143,7 +267,7 @@ function resolveScopedChannelOwnerPluginIds(params: {
.toSorted((left, right) => left.localeCompare(right));
}
export function resolveScopedChannelPluginIds(params: {
function resolveScopedChannelPluginIds(params: {
config: OpenClawConfig;
activationSourceConfig?: OpenClawConfig;
channelIds: readonly string[];
@@ -222,7 +346,12 @@ export function resolveConfiguredChannelPluginIds(params: {
env: NodeJS.ProcessEnv;
}): string[] {
const configuredChannelIds = new Set(
listPotentialConfiguredChannelIds(params.config, params.env).map((id) => id.trim()),
listConfiguredChannelIdsForPluginScope({
config: params.config,
activationSourceConfig: params.activationSourceConfig,
workspaceDir: params.workspaceDir,
env: params.env,
}).map((id) => id.trim()),
);
if (configuredChannelIds.size === 0) {
return [];

View File

@@ -134,6 +134,8 @@ export type PluginLoadOptions = {
mode?: "full" | "validate";
onlyPluginIds?: string[];
includeSetupOnlyChannelPlugins?: boolean;
forceSetupOnlyChannelPlugins?: boolean;
requireSetupEntryForSetupOnlyChannelPlugins?: boolean;
/**
* Prefer `setupEntry` for configured channel plugins that explicitly opt in
* via package metadata because their setup entry covers the pre-listen startup surface.
@@ -505,6 +507,8 @@ function buildCacheKey(params: {
env: NodeJS.ProcessEnv;
onlyPluginIds?: string[];
includeSetupOnlyChannelPlugins?: boolean;
forceSetupOnlyChannelPlugins?: boolean;
requireSetupEntryForSetupOnlyChannelPlugins?: boolean;
preferSetupRuntimeForChannelPlugins?: boolean;
loadModules?: boolean;
runtimeSubagentMode?: "default" | "explicit" | "gateway-bindable";
@@ -534,6 +538,12 @@ function buildCacheKey(params: {
);
const scopeKey = serializePluginIdScope(params.onlyPluginIds);
const setupOnlyKey = params.includeSetupOnlyChannelPlugins === true ? "setup-only" : "runtime";
const setupOnlyModeKey =
params.forceSetupOnlyChannelPlugins === true ? "force-setup" : "normal-setup";
const setupOnlyRequirementKey =
params.requireSetupEntryForSetupOnlyChannelPlugins === true
? "require-setup-entry"
: "allow-full-fallback";
const startupChannelMode =
params.preferSetupRuntimeForChannelPlugins === true ? "prefer-setup" : "full";
const moduleLoadMode = params.loadModules === false ? "manifest-only" : "load-modules";
@@ -544,7 +554,7 @@ function buildCacheKey(params: {
installs,
loadPaths,
activationMetadataKey: params.activationMetadataKey ?? "",
})}::${scopeKey}::${setupOnlyKey}::${startupChannelMode}::${moduleLoadMode}::${runtimeSubagentMode}::${params.pluginSdkResolution ?? "auto"}::${gatewayMethodsKey}`;
})}::${scopeKey}::${setupOnlyKey}::${setupOnlyModeKey}::${setupOnlyRequirementKey}::${startupChannelMode}::${moduleLoadMode}::${runtimeSubagentMode}::${params.pluginSdkResolution ?? "auto"}::${gatewayMethodsKey}`;
}
function matchesScopedPluginRequest(params: {
@@ -619,6 +629,8 @@ function hasExplicitCompatibilityInputs(options: PluginLoadOptions): boolean {
options.pluginSdkResolution !== undefined ||
options.coreGatewayHandlers !== undefined ||
options.includeSetupOnlyChannelPlugins === true ||
options.forceSetupOnlyChannelPlugins === true ||
options.requireSetupEntryForSetupOnlyChannelPlugins === true ||
options.preferSetupRuntimeForChannelPlugins === true ||
options.loadModules === false
);
@@ -634,6 +646,9 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) {
});
const onlyPluginIds = normalizePluginIdScope(options.onlyPluginIds);
const includeSetupOnlyChannelPlugins = options.includeSetupOnlyChannelPlugins === true;
const forceSetupOnlyChannelPlugins = options.forceSetupOnlyChannelPlugins === true;
const requireSetupEntryForSetupOnlyChannelPlugins =
options.requireSetupEntryForSetupOnlyChannelPlugins === true;
const preferSetupRuntimeForChannelPlugins = options.preferSetupRuntimeForChannelPlugins === true;
const runtimeSubagentMode = resolveRuntimeSubagentMode(options.runtimeOptions);
const coreGatewayMethodNames = Object.keys(options.coreGatewayHandlers ?? {}).toSorted();
@@ -648,6 +663,8 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) {
env,
onlyPluginIds,
includeSetupOnlyChannelPlugins,
forceSetupOnlyChannelPlugins,
requireSetupEntryForSetupOnlyChannelPlugins,
preferSetupRuntimeForChannelPlugins,
loadModules: options.loadModules,
runtimeSubagentMode,
@@ -663,6 +680,8 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) {
autoEnabledReasons: options.autoEnabledReasons ?? {},
onlyPluginIds,
includeSetupOnlyChannelPlugins,
forceSetupOnlyChannelPlugins,
requireSetupEntryForSetupOnlyChannelPlugins,
preferSetupRuntimeForChannelPlugins,
shouldActivate: options.activate !== false,
shouldLoadModules: options.loadModules !== false,
@@ -980,6 +999,17 @@ function shouldLoadChannelPluginInSetupRuntime(params: {
);
}
function channelPluginIdBelongsToManifest(params: {
channelId: string | undefined;
pluginId: string;
manifestChannels: readonly string[];
}): boolean {
if (!params.channelId) {
return true;
}
return params.channelId === params.pluginId || params.manifestChannels.includes(params.channelId);
}
function createPluginRecord(params: {
id: string;
name?: string;
@@ -1410,6 +1440,8 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
autoEnabledReasons,
onlyPluginIds,
includeSetupOnlyChannelPlugins,
forceSetupOnlyChannelPlugins,
requireSetupEntryForSetupOnlyChannelPlugins,
preferSetupRuntimeForChannelPlugins,
shouldActivate,
shouldLoadModules,
@@ -1740,26 +1772,34 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
}
}
const registrationMode = enableState.enabled
? shouldLoadModules &&
!validateOnly &&
shouldLoadChannelPluginInSetupRuntime({
manifestChannels: manifestRecord.channels,
setupSource: manifestRecord.setupSource,
startupDeferConfiguredChannelFullLoadUntilAfterListen:
manifestRecord.startupDeferConfiguredChannelFullLoadUntilAfterListen,
cfg,
env,
preferSetupRuntimeForChannelPlugins,
})
? "setup-runtime"
: "full"
: includeSetupOnlyChannelPlugins &&
!validateOnly &&
onlyPluginIdSet &&
manifestRecord.channels.length > 0
? "setup-only"
: null;
const scopedSetupOnlyChannelPluginRequested =
includeSetupOnlyChannelPlugins &&
!validateOnly &&
onlyPluginIdSet &&
manifestRecord.channels.length > 0 &&
(!enableState.enabled || forceSetupOnlyChannelPlugins);
const canLoadScopedSetupOnlyChannelPlugin =
scopedSetupOnlyChannelPluginRequested &&
(!requireSetupEntryForSetupOnlyChannelPlugins || Boolean(manifestRecord.setupSource));
const registrationMode = canLoadScopedSetupOnlyChannelPlugin
? "setup-only"
: scopedSetupOnlyChannelPluginRequested && requireSetupEntryForSetupOnlyChannelPlugins
? null
: enableState.enabled
? shouldLoadModules &&
!validateOnly &&
shouldLoadChannelPluginInSetupRuntime({
manifestChannels: manifestRecord.channels,
setupSource: manifestRecord.setupSource,
startupDeferConfiguredChannelFullLoadUntilAfterListen:
manifestRecord.startupDeferConfiguredChannelFullLoadUntilAfterListen,
cfg,
env,
preferSetupRuntimeForChannelPlugins,
})
? "setup-runtime"
: "full"
: null;
if (!registrationMode) {
record.status = "disabled";
@@ -1980,7 +2020,13 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
continue;
}
if (setupRegistration.plugin) {
if (setupRegistration.plugin.id && setupRegistration.plugin.id !== record.id) {
if (
!channelPluginIdBelongsToManifest({
channelId: setupRegistration.plugin.id,
pluginId: record.id,
manifestChannels: manifestRecord.channels,
})
) {
pushPluginLoadError(
`plugin id mismatch (config uses "${record.id}", setup export uses "${setupRegistration.plugin.id}")`,
);
@@ -2105,7 +2151,13 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
if (!mergedSetupPlugin) {
continue;
}
if (mergedSetupPlugin.id && mergedSetupPlugin.id !== record.id) {
if (
!channelPluginIdBelongsToManifest({
channelId: mergedSetupPlugin.id,
pluginId: record.id,
manifestChannels: manifestRecord.channels,
})
) {
pushPluginLoadError(
`plugin id mismatch (config uses "${record.id}", setup export uses "${mergedSetupPlugin.id}")`,
);

View File

@@ -78,6 +78,7 @@ export function ensurePluginRegistryLoaded(options?: {
config?: OpenClawConfig;
activationSourceConfig?: OpenClawConfig;
env?: NodeJS.ProcessEnv;
workspaceDir?: string;
onlyPluginIds?: string[];
}): void {
const scope = options?.scope ?? "all";

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

@@ -0,0 +1,26 @@
const UNSAFE_CHANNEL_ENV_VAR_TRIGGER_NAMES = new Set([
"CI",
"HOME",
"LANG",
"LC_ALL",
"LC_CTYPE",
"LOGNAME",
"NODE_ENV",
"OLDPWD",
"PATH",
"PWD",
"SHELL",
"SSH_AUTH_SOCK",
"TEMP",
"TERM",
"TMP",
"TMPDIR",
"USER",
]);
export function isSafeChannelEnvVarTriggerName(key: string): boolean {
const normalized = key.trim().toUpperCase();
return (
/^[A-Z][A-Z0-9_]*$/.test(normalized) && !UNSAFE_CHANNEL_ENV_VAR_TRIGGER_NAMES.has(normalized)
);
}

View File

@@ -1,5 +1,6 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js";
export { isSafeChannelEnvVarTriggerName } from "./channel-env-var-names.js";
type ChannelEnvVarLookupParams = {
config?: OpenClawConfig;

View File

@@ -18,6 +18,7 @@ const CORE_PROVIDER_AUTH_ENV_VAR_CANDIDATES = {
} as const;
const CORE_PROVIDER_SETUP_ENV_VAR_OVERRIDES = {
minimax: ["MINIMAX_API_KEY"],
"minimax-cn": ["MINIMAX_API_KEY"],
} as const;
@@ -210,8 +211,6 @@ export function getProviderEnvVars(
return Array.isArray(envVars) ? [...envVars] : [];
}
const EXTRA_PROVIDER_AUTH_ENV_VARS = ["MINIMAX_CODE_PLAN_KEY", "MINIMAX_CODING_API_KEY"] as const;
// OPENCLAW_API_KEY authenticates the local OpenClaw bridge itself and must
// remain available to child bridge/runtime processes.
export function listKnownProviderAuthEnvVarNames(params?: ProviderEnvVarLookupParams): string[] {
@@ -219,7 +218,6 @@ export function listKnownProviderAuthEnvVarNames(params?: ProviderEnvVarLookupPa
...new Set([
...Object.values(resolveProviderAuthEnvVarCandidates(params)).flatMap((keys) => keys),
...Object.values(resolveProviderEnvVars(params)).flatMap((keys) => keys),
...EXTRA_PROVIDER_AUTH_ENV_VARS,
]),
];
}

View File

@@ -0,0 +1,113 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const applyPluginAutoEnableMock = vi.hoisted(() => vi.fn());
const loadPluginMetadataRegistrySnapshotMock = vi.hoisted(() => vi.fn());
const resolveConfiguredChannelPluginIdsMock = vi.hoisted(() => vi.fn());
vi.mock("../config/plugin-auto-enable.js", () => ({
applyPluginAutoEnable: (...args: unknown[]) => applyPluginAutoEnableMock(...args),
}));
vi.mock("../plugins/channel-plugin-ids.js", () => ({
resolveConfiguredChannelPluginIds: (...args: unknown[]) =>
resolveConfiguredChannelPluginIdsMock(...args),
}));
vi.mock("../plugins/runtime/metadata-registry-loader.js", () => ({
loadPluginMetadataRegistrySnapshot: (...args: unknown[]) =>
loadPluginMetadataRegistrySnapshotMock(...args),
}));
let runSecurityAudit: typeof import("./audit.js").runSecurityAudit;
describe("security audit read-only plugin scope", () => {
beforeAll(async () => {
({ runSecurityAudit } = await import("./audit.js"));
});
beforeEach(() => {
applyPluginAutoEnableMock.mockReset();
loadPluginMetadataRegistrySnapshotMock.mockReset();
resolveConfiguredChannelPluginIdsMock.mockReset();
applyPluginAutoEnableMock.mockImplementation((params: { config: unknown }) => ({
config: params.config,
changes: [],
autoEnabledReasons: {},
}));
loadPluginMetadataRegistrySnapshotMock.mockReturnValue({
securityAuditCollectors: [],
});
resolveConfiguredChannelPluginIdsMock.mockReturnValue([]);
});
it("keeps configured channel owner collectors when the provided channel plugin list omits them", async () => {
const sourceConfig = {
plugins: {
allow: ["external-channel-plugin", "audit-plugin"],
},
};
applyPluginAutoEnableMock.mockReturnValue({
config: sourceConfig,
changes: [],
autoEnabledReasons: {
"external-channel-plugin": ["channel:external"],
"audit-plugin": ["explicit"],
},
});
resolveConfiguredChannelPluginIdsMock.mockReturnValue(["external-channel-plugin"]);
await runSecurityAudit({
config: sourceConfig,
sourceConfig,
env: {} as NodeJS.ProcessEnv,
includeFilesystem: false,
includeChannelSecurity: true,
plugins: [],
});
expect(resolveConfiguredChannelPluginIdsMock).toHaveBeenCalledWith(
expect.objectContaining({
config: sourceConfig,
activationSourceConfig: sourceConfig,
env: {},
}),
);
expect(loadPluginMetadataRegistrySnapshotMock).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: ["external-channel-plugin", "audit-plugin"],
}),
);
});
it("removes configured channel owner collectors only when channel security will audit them", async () => {
const sourceConfig = {
plugins: {
allow: ["external-channel-plugin", "audit-plugin"],
},
};
applyPluginAutoEnableMock.mockReturnValue({
config: sourceConfig,
changes: [],
autoEnabledReasons: {
"external-channel-plugin": ["channel:external"],
"audit-plugin": ["explicit"],
},
});
resolveConfiguredChannelPluginIdsMock.mockReturnValue(["external-channel-plugin"]);
await runSecurityAudit({
config: sourceConfig,
sourceConfig,
env: {} as NodeJS.ProcessEnv,
includeFilesystem: false,
includeChannelSecurity: true,
plugins: [{ id: "external-channel-plugin" }] as never,
});
expect(loadPluginMetadataRegistrySnapshotMock).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: ["audit-plugin"],
}),
);
});
});

View File

@@ -1,4 +1,5 @@
import path from "node:path";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { resolveSandboxConfigForAgent } from "../agents/sandbox/config.js";
import { hasPotentialConfiguredChannels } from "../channels/config-presence.js";
import type { listChannelPlugins } from "../channels/plugins/index.js";
@@ -13,6 +14,7 @@ import {
} from "../infra/exec-safe-bin-runtime-policy.js";
import { listRiskyConfiguredSafeBins } from "../infra/exec-safe-bin-semantics.js";
import { normalizeTrustedSafeBinDirs } from "../infra/exec-safe-bin-trust.js";
import { resolveConfiguredChannelPluginIds } from "../plugins/channel-plugin-ids.js";
import { getActivePluginRegistry } from "../plugins/runtime.js";
import { DEFAULT_AGENT_ID } from "../routing/session-key.js";
import { asNullableRecord } from "../shared/record-coerce.js";
@@ -70,6 +72,8 @@ export type SecurityAuditOptions = {
codeSafetySummaryCache?: Map<string, Promise<unknown>>;
/** Optional explicit auth for deep gateway probe. */
deepProbeAuth?: { token?: string; password?: string };
/** Override workspace used for workspace plugin discovery. */
workspaceDir?: string;
/** Dependency injection for tests. */
probeGatewayFn?: ProbeGatewayFn;
};
@@ -92,6 +96,7 @@ type AuditExecutionContext = {
configSnapshot: ConfigFileSnapshot | null;
codeSafetySummaryCache: Map<string, Promise<unknown>>;
deepProbeAuth?: { token?: string; password?: string };
workspaceDir?: string;
};
let channelPluginsModulePromise: Promise<typeof import("../channels/plugins/index.js")> | undefined;
@@ -352,6 +357,19 @@ async function collectPluginSecurityAuditFindings(
requestedPluginIds.add(normalized);
}
}
if (context.includeChannelSecurity && context.plugins !== undefined) {
const auditedChannelPluginIds = new Set(context.plugins.map((plugin) => plugin.id));
for (const pluginId of resolveConfiguredChannelPluginIds({
config: autoEnabled.config,
activationSourceConfig: context.sourceConfig,
workspaceDir: context.workspaceDir,
env: context.env,
})) {
if (auditedChannelPluginIds.has(pluginId)) {
requestedPluginIds.delete(pluginId);
}
}
}
if (requestedPluginIds.size === 0) {
return [];
}
@@ -361,6 +379,7 @@ async function collectPluginSecurityAuditFindings(
config: autoEnabled.config,
activationSourceConfig: context.sourceConfig,
env: context.env,
workspaceDir: context.workspaceDir,
onlyPluginIds: [...requestedPluginIds],
});
collectors = snapshot.securityAuditCollectors ?? [];
@@ -883,6 +902,8 @@ async function createAuditExecutionContext(
const deepTimeoutMs = Math.max(250, opts.deepTimeoutMs ?? 5000);
const stateDir = opts.stateDir ?? resolveStateDir(env);
const configPath = opts.configPath ?? resolveConfigPath(env, stateDir);
const workspaceDir =
opts.workspaceDir ?? resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
const { readConfigSnapshotForAudit } = await loadAuditNonDeepModule();
const configSnapshot = includeFilesystem
? opts.configSnapshot !== undefined
@@ -904,6 +925,7 @@ async function createAuditExecutionContext(
execDockerRawFn: opts.execDockerRawFn,
probeGatewayFn: opts.probeGatewayFn,
plugins: opts.plugins,
workspaceDir,
configSnapshot,
codeSafetySummaryCache: opts.codeSafetySummaryCache ?? new Map<string, Promise<unknown>>(),
deepProbeAuth: opts.deepProbeAuth,
@@ -986,13 +1008,21 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<Secu
const shouldAuditChannelSecurity =
context.includeChannelSecurity &&
(context.plugins !== undefined || hasPotentialConfiguredChannels(cfg, env));
(context.plugins !== undefined ||
hasPotentialConfiguredChannels(cfg, env) ||
resolveConfiguredChannelPluginIds({
config: cfg,
activationSourceConfig: context.sourceConfig,
workspaceDir: context.workspaceDir,
env,
}).length > 0);
if (shouldAuditChannelSecurity) {
if (context.plugins === undefined) {
(await loadPluginRegistryLoaderModule()).ensurePluginRegistryLoaded({
scope: "configured-channels",
config: cfg,
activationSourceConfig: context.sourceConfig,
workspaceDir: context.workspaceDir,
env,
});
}

View File

@@ -34,12 +34,21 @@ describe("redactSensitiveUrlLikeString", () => {
"//***:***@example.com/mcp?client_secret=***",
);
});
it("redacts protocol URLs that are too malformed to parse", () => {
expect(
redactSensitiveUrlLikeString(
"wss://fallback-user:fallback-pass@[bad-host/socket?token=fallback-secret&keep=visible)",
),
).toBe("wss://***:***@[bad-host/socket?token=***&keep=visible)");
});
});
describe("isSensitiveUrlQueryParamName", () => {
it("matches the auth-oriented query params used by MCP SSE config redaction", () => {
expect(isSensitiveUrlQueryParamName("token")).toBe(true);
expect(isSensitiveUrlQueryParamName("refresh_token")).toBe(true);
expect(isSensitiveUrlQueryParamName("signature")).toBe(true);
expect(isSensitiveUrlQueryParamName("safe")).toBe(false);
});
});

View File

@@ -15,6 +15,7 @@ const SENSITIVE_URL_QUERY_PARAM_NAMES = new Set([
"auth",
"client_secret",
"refresh_token",
"signature",
]);
export function isSensitiveUrlQueryParamName(name: string): boolean {