diff --git a/src/channels/plugins/configured-state.test.ts b/src/channels/plugins/configured-state.test.ts index 5e637182ee79..dde25834d9e7 100644 --- a/src/channels/plugins/configured-state.test.ts +++ b/src/channels/plugins/configured-state.test.ts @@ -3,15 +3,15 @@ import { createRequire } from "node:module"; import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { - hasBundledChannelConfiguredState, - listBundledChannelIdsWithConfiguredState, -} from "./configured-state.js"; + hasBundledChannelPackageState, + listBundledChannelIdsForPackageState, +} from "./package-state-probes.js"; const nodeRequire = createRequire(import.meta.url); describe("bundled channel configured-state metadata", () => { it("lists the shipped metadata-first configured-state channels", () => { - expect(listBundledChannelIdsWithConfiguredState()).toEqual([ + expect(listBundledChannelIdsForPackageState("configuredState")).toEqual([ "buzz", "clickclack", "discord", @@ -37,28 +37,32 @@ describe("bundled channel configured-state metadata", () => { it("resolves Discord, Slack, Telegram, and IRC env probes without full plugin loads", () => { expect( - hasBundledChannelConfiguredState({ + hasBundledChannelPackageState({ + metadataKey: "configuredState", channelId: "discord", cfg: {}, env: { DISCORD_BOT_TOKEN: "token" }, }), ).toBe(true); expect( - hasBundledChannelConfiguredState({ + hasBundledChannelPackageState({ + metadataKey: "configuredState", channelId: "slack", cfg: {}, env: { SLACK_BOT_TOKEN: "xoxb-test", SLACK_APP_TOKEN: "xapp-test" }, }), ).toBe(true); expect( - hasBundledChannelConfiguredState({ + hasBundledChannelPackageState({ + metadataKey: "configuredState", channelId: "telegram", cfg: {}, env: { TELEGRAM_BOT_TOKEN: "token" }, }), ).toBe(true); expect( - hasBundledChannelConfiguredState({ + hasBundledChannelPackageState({ + metadataKey: "configuredState", channelId: "irc", cfg: {}, env: { IRC_HOST: "irc.example.com", IRC_NICK: "openclaw" }, @@ -79,7 +83,9 @@ describe("bundled channel configured-state metadata", () => { { channelId: "nextcloud-talk", env: { NEXTCLOUD_TALK_BOT_SECRET: "secret" } }, { channelId: "zalo", env: { ZALO_WEBHOOK_SECRET: "secret" } }, ])("rejects incomplete $channelId environment credentials", ({ channelId, env }) => { - expect(hasBundledChannelConfiguredState({ channelId, cfg: {}, env })).toBe(false); + expect( + hasBundledChannelPackageState({ metadataKey: "configuredState", channelId, cfg: {}, env }), + ).toBe(false); }); it.each([ @@ -109,12 +115,15 @@ describe("bundled channel configured-state metadata", () => { env: { SYNOLOGY_CHAT_TOKEN: "token", SYNOLOGY_CHAT_INCOMING_URL: "https://example.test" }, }, ])("accepts complete $channelId environment credentials", ({ channelId, env }) => { - expect(hasBundledChannelConfiguredState({ channelId, cfg: {}, env })).toBe(true); + expect( + hasBundledChannelPackageState({ metadataKey: "configuredState", channelId, cfg: {}, env }), + ).toBe(true); }); it("keeps explicit blank Teams credentials authoritative over ambient credentials", () => { expect( - hasBundledChannelConfiguredState({ + hasBundledChannelPackageState({ + metadataKey: "configuredState", channelId: "msteams", cfg: { channels: { msteams: { appId: "", appPassword: "", tenantId: "" } } }, env: { @@ -168,7 +177,9 @@ describe("bundled channel configured-state metadata", () => { cfg: OpenClawConfig; env: NodeJS.ProcessEnv; }>)("accepts the owner-specific $name contract", ({ channelId, cfg, env }) => { - expect(hasBundledChannelConfiguredState({ channelId, cfg, env })).toBe(true); + expect( + hasBundledChannelPackageState({ metadataKey: "configuredState", channelId, cfg, env }), + ).toBe(true); }); it("uses declarative env metadata without a TypeScript source require hook", () => { @@ -176,7 +187,8 @@ describe("bundled channel configured-state metadata", () => { delete nodeRequire.extensions[".ts"]; try { expect( - hasBundledChannelConfiguredState({ + hasBundledChannelPackageState({ + metadataKey: "configuredState", channelId: "discord", cfg: {}, env: { DISCORD_BOT_TOKEN: "token" }, diff --git a/src/channels/plugins/configured-state.ts b/src/channels/plugins/configured-state.ts deleted file mode 100644 index 88c05ac38a45..000000000000 --- a/src/channels/plugins/configured-state.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Bundled channel configured-state probes. - * - * Lists and checks bundled channels that can report configured account state. - */ -import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import type { PluginDiscoveryResult } from "../../plugins/discovery.js"; -import { - hasBundledChannelPackageState, - listBundledChannelIdsForPackageState, -} from "./package-state-probes.js"; - -/** - * Lists bundled channel ids that expose configured-state detectors. - */ -export function listBundledChannelIdsWithConfiguredState( - discovery?: PluginDiscoveryResult, -): string[] { - return listBundledChannelIdsForPackageState("configuredState", discovery); -} - -/** - * Checks whether a bundled channel reports configured state for the current config. - */ -export function hasBundledChannelConfiguredState(params: { - channelId: string; - cfg: OpenClawConfig; - env?: NodeJS.ProcessEnv; - discovery?: PluginDiscoveryResult; -}): boolean { - return hasBundledChannelPackageState({ - metadataKey: "configuredState", - channelId: params.channelId, - cfg: params.cfg, - env: params.env, - discovery: params.discovery, - }); -} diff --git a/src/channels/plugins/package-state-probes.test.ts b/src/channels/plugins/package-state-probes.test.ts index e8eb8e91b74c..3139b12837e3 100644 --- a/src/channels/plugins/package-state-probes.test.ts +++ b/src/channels/plugins/package-state-probes.test.ts @@ -133,6 +133,27 @@ describe("channel package-state probes", () => { ).toBe(false); }); + it.each([ + { name: "PATH", env: { PATH: "/usr/bin" }, configured: false }, + { name: "mixed_case_token", env: { MIXED_CASE_TOKEN: "token" }, configured: true }, + ])("applies the safe channel env-trigger contract to $name", ({ name, env, configured }) => { + listChannelCatalogEntriesMock.mockReturnValue([ + { + ...makeBundledChannelCatalogEntry({ pluginId: "env-chat", channelId: "env-chat" }), + channel: { id: "env-chat", configuredState: { env: { allOf: [name] } } }, + } satisfies PluginChannelCatalogEntry, + ]); + + expect( + hasBundledChannelPackageState({ + metadataKey: "configuredState", + channelId: "env-chat", + cfg: {}, + env, + }), + ).toBe(configured); + }); + it("prefers built bundled package-state probes when the catalog root is source", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-package-state-probe-")); tempDirs.push(root); diff --git a/src/channels/plugins/package-state-probes.ts b/src/channels/plugins/package-state-probes.ts index deffc12d5b26..068cac75c5fd 100644 --- a/src/channels/plugins/package-state-probes.ts +++ b/src/channels/plugins/package-state-probes.ts @@ -20,6 +20,7 @@ import { getCachedPluginModuleLoader, type PluginModuleLoaderCache, } from "../../plugins/plugin-module-loader-cache.js"; +import { isSafeChannelEnvVarTriggerName } from "../../secrets/channel-env-var-names.js"; import { loadChannelPluginModule, resolveExistingPluginModulePath } from "./module-loader.js"; type ChannelPackageStateChecker = (params: { @@ -81,7 +82,12 @@ function loadChannelPackageStateModule(params: { modulePath: string; rootDir: st } function hasNonEmptyEnvValue(env: NodeJS.ProcessEnv | undefined, key: string): boolean { - return typeof env?.[key] === "string" && env[key].trim().length > 0; + if (!env || !isSafeChannelEnvVarTriggerName(key)) { + return false; + } + const normalized = key.trim(); + const value = env[normalized] ?? env[normalized.toUpperCase()]; + return typeof value === "string" && value.trim().length > 0; } function resolveSourceBundledPluginRoot(rootDir: string): { diff --git a/src/config/channel-configured.ts b/src/config/channel-configured.ts index 4f32f62e62db..9ff80ce3f903 100644 --- a/src/config/channel-configured.ts +++ b/src/config/channel-configured.ts @@ -1,6 +1,6 @@ // Determines whether a channel is configured from bootstrap and plugin state. import { getBootstrapChannelPlugin } from "../channels/plugins/bootstrap-registry.js"; -import { hasBundledChannelConfiguredState } from "../channels/plugins/configured-state.js"; +import { hasBundledChannelPackageState } from "../channels/plugins/package-state-probes.js"; import { hasMeaningfulChannelConfigShallow, resolveChannelConfigRecord, @@ -19,7 +19,7 @@ export function isChannelConfigured( return true; } // Bundled channels can expose configured state through env vars or persisted credential files. - if (hasBundledChannelConfiguredState({ channelId, cfg, env })) { + if (hasBundledChannelPackageState({ metadataKey: "configuredState", channelId, cfg, env })) { return true; } // Bootstrap plugins cover channels that are available before full plugin registry loading. diff --git a/src/config/plugin-auto-enable.core.test.ts b/src/config/plugin-auto-enable.core.test.ts index 404038dafa54..9d28bd4a5300 100644 --- a/src/config/plugin-auto-enable.core.test.ts +++ b/src/config/plugin-auto-enable.core.test.ts @@ -20,15 +20,17 @@ import { import type { OpenClawConfig } from "./types.openclaw.js"; import { validateConfigObject } from "./validation.js"; -vi.mock("../channels/plugins/configured-state.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../channels/plugins/package-state-probes.js", async (importOriginal) => { + const actual = + await importOriginal(); return { ...actual, - hasBundledChannelConfiguredState: (params: { - channelId: string; - cfg: OpenClawConfig; - env?: NodeJS.ProcessEnv; - }) => { + hasBundledChannelPackageState: ( + params: Parameters[0], + ) => { + if (params.metadataKey !== "configuredState") { + return actual.hasBundledChannelPackageState(params); + } if (params.channelId === "cache-channel") { return Boolean(params.env?.CACHE_CHANNEL_TOKEN?.trim()); } @@ -40,7 +42,7 @@ vi.mock("../channels/plugins/configured-state.js", async (importOriginal) => { Boolean(params.env?.[key]?.trim()), ); } - return actual.hasBundledChannelConfiguredState(params); + return actual.hasBundledChannelPackageState(params); }, }; }); diff --git a/src/config/plugin-auto-enable.shared.ts b/src/config/plugin-auto-enable.shared.ts index 899175869da2..fddf38e1b4ed 100644 --- a/src/config/plugin-auto-enable.shared.ts +++ b/src/config/plugin-auto-enable.shared.ts @@ -16,9 +16,9 @@ import { type ChannelPresenceSignalSource, } from "../channels/config-presence.js"; import { - hasBundledChannelConfiguredState, - listBundledChannelIdsWithConfiguredState, -} from "../channels/plugins/configured-state.js"; + hasBundledChannelPackageState, + listBundledChannelIdsForPackageState, +} from "../channels/plugins/package-state-probes.js"; import { findChatChannelMeta, normalizeChatChannelId } from "../channels/registry.js"; import { isBlockedObjectKey } from "../infra/prototype-keys.js"; import { normalizePluginsConfig } from "../plugins/config-state.js"; @@ -299,7 +299,9 @@ function collectConfiguredChannelIds( discovery?: PluginDiscoveryResult, ambientEnvTriggers: AmbientEnvTriggerPolicy = "allow", ): string[] { - const configuredStateChannelIds = new Set(listBundledChannelIdsWithConfiguredState(discovery)); + const configuredStateChannelIds = new Set( + listBundledChannelIdsForPackageState("configuredState", discovery), + ); return listPotentialConfiguredChannelPresenceSignals(cfg, env, { includePersistedAuthState: false, discovery, @@ -333,7 +335,8 @@ function isAutoEnableConfiguredChannelSignal(params: { if ( params.source === "env" && params.configuredStateChannelIds.has(params.channelId) && - !hasBundledChannelConfiguredState({ + !hasBundledChannelPackageState({ + metadataKey: "configuredState", channelId: params.channelId, cfg: params.cfg, env: params.env, diff --git a/src/plugins/channel-presence-policy.ts b/src/plugins/channel-presence-policy.ts index 2c65608dd4ba..844d163eedd1 100644 --- a/src/plugins/channel-presence-policy.ts +++ b/src/plugins/channel-presence-policy.ts @@ -12,7 +12,6 @@ import { import { hasChannelPackageState } from "../channels/plugins/package-state-probes.js"; import { resolveConfigWidePluginManifestRegistry } from "../config/io.plugin-metadata.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { isSafeChannelEnvVarTriggerName } from "../secrets/channel-env-var-names.js"; import { resolveManifestActivationPluginIds } from "./activation-planner.js"; import { createPluginActivationSource, @@ -74,15 +73,6 @@ function normalizeChannelIds(channelIds: Iterable): string[] { ); } -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; -} - /** True when config contains meaningful enabled channel settings. */ export function hasExplicitChannelConfig(params: { config: OpenClawConfig; @@ -175,27 +165,20 @@ function listManifestEnvConfiguredChannelSignals(params: { continue; } contractChannelIds.add(normalizedChannelId); - if (hasModuleContract) { - if ( - !params.envSignalChannelIds.has(normalizedChannelId) || - !packageChannel || - !hasChannelPackageState({ - entry: { - pluginId: record.id, - origin: record.origin, - rootDir: record.rootDir, - channel: packageChannel, - }, - metadataKey: "configuredState", - cfg: params.config, - env: params.env, - }) - ) { - continue; - } - } else if ( - !allOf.every((envVar) => hasNonEmptyEnvValue(params.env, envVar)) || - (anyOf.length > 0 && !anyOf.some((envVar) => hasNonEmptyEnvValue(params.env, envVar))) + if ( + (hasModuleContract && !params.envSignalChannelIds.has(normalizedChannelId)) || + !packageChannel || + !hasChannelPackageState({ + entry: { + pluginId: record.id, + origin: record.origin, + rootDir: record.rootDir, + channel: packageChannel, + }, + metadataKey: "configuredState", + cfg: params.config, + env: params.env, + }) ) { continue; }