mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
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:
committed by
GitHub
parent
09c5669299
commit
24db09a19b
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
52
extensions/discord/src/security.ts
Normal file
52
extensions/discord/src/security.ts
Normal 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"]>;
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
>;
|
||||
}
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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),
|
||||
|
||||
43
extensions/slack/src/security.ts
Normal file
43
extensions/slack/src/security.ts
Normal 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"]>;
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"
|
||||
>;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
40
extensions/telegram/src/security.ts
Normal file
40
extensions/telegram/src/security.ts
Normal 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"]>;
|
||||
@@ -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"
|
||||
|
||||
@@ -27,6 +27,7 @@ export {
|
||||
export {
|
||||
clearRuntimeAuthProfileStoreSnapshots,
|
||||
ensureAuthProfileStore,
|
||||
ensureAuthProfileStoreWithoutExternalProfiles,
|
||||
hasAnyAuthProfileStoreSource,
|
||||
loadAuthProfileStoreForSecretsRuntime,
|
||||
loadAuthProfileStoreWithoutExternalProfiles,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
460
src/channels/plugins/read-only.test.ts
Normal file
460
src/channels/plugins/read-only.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
436
src/channels/plugins/read-only.ts
Normal file
436
src/channels/plugins/read-only.ts
Normal 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)),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
150
src/commands/channels.status.external-env.test.ts
Normal file
150
src/commands/channels.status.external-env.test.ts
Normal 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();
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
36
src/commands/doctor-auth.profile-health.test.ts
Normal file
36
src/commands/doctor-auth.profile-health.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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…");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [] };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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}")`,
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
26
src/secrets/channel-env-var-names.ts
Normal file
26
src/secrets/channel-env-var-names.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
113
src/security/audit-plugin-readonly-scope.test.ts
Normal file
113
src/security/audit-plugin-readonly-scope.test.ts
Normal 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"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ const SENSITIVE_URL_QUERY_PARAM_NAMES = new Set([
|
||||
"auth",
|
||||
"client_secret",
|
||||
"refresh_token",
|
||||
"signature",
|
||||
]);
|
||||
|
||||
export function isSensitiveUrlQueryParamName(name: string): boolean {
|
||||
|
||||
Reference in New Issue
Block a user