diff --git a/docs/plugins/architecture.md b/docs/plugins/architecture.md index fbac3ec0c9..225c367154 100644 --- a/docs/plugins/architecture.md +++ b/docs/plugins/architecture.md @@ -609,8 +609,9 @@ conversation, and it runs after core approval handling finishes. Provider plugins now have two layers: -- manifest metadata: `providerAuthEnvVars` for cheap env-auth lookup before - runtime load, plus `providerAuthChoices` for cheap onboarding/auth-choice +- manifest metadata: `providerAuthEnvVars` for cheap provider env-auth lookup + before runtime load, `channelEnvVars` for cheap channel env/setup lookup + before runtime load, plus `providerAuthChoices` for cheap onboarding/auth-choice labels and CLI flag metadata before runtime load - config-time hooks: `catalog` / legacy `discovery` plus `applyConfigDefaults` - runtime hooks: `normalizeModelId`, `normalizeTransport`, @@ -645,6 +646,10 @@ one-flag auth wiring without loading provider runtime. Keep provider runtime `envVars` for operator-facing hints such as onboarding labels or OAuth client-id/client-secret setup vars. +Use manifest `channelEnvVars` when a channel has env-driven auth or setup that +generic shell-env fallback, config/status checks, or setup prompts should see +without loading channel runtime. + ### Hook order and usage For model/provider plugins, OpenClaw calls hooks in this rough order. diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index 986325277f..50aa7c2a22 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -93,6 +93,9 @@ Those belong in your plugin code and `package.json`. "providerAuthEnvVars": { "openrouter": ["OPENROUTER_API_KEY"] }, + "channelEnvVars": { + "openrouter-chatops": ["OPENROUTER_CHATOPS_TOKEN"] + }, "providerAuthChoices": [ { "provider": "openrouter", @@ -142,6 +145,7 @@ Those belong in your plugin code and `package.json`. | `modelSupport` | No | `object` | Manifest-owned shorthand model-family metadata used to auto-load the plugin before runtime. | | `cliBackends` | No | `string[]` | CLI inference backend ids owned by this plugin. Used for startup auto-activation from explicit config refs. | | `providerAuthEnvVars` | No | `Record` | Cheap provider-auth env metadata that OpenClaw can inspect without loading plugin code. | +| `channelEnvVars` | No | `Record` | Cheap channel env metadata that OpenClaw can inspect without loading plugin code. Use this for env-driven channel setup or auth surfaces that generic startup/config helpers should see. | | `providerAuthChoices` | No | `object[]` | Cheap auth-choice metadata for onboarding pickers, preferred-provider resolution, and simple CLI flag wiring. | | `contracts` | No | `object` | Static bundled capability snapshot for speech, realtime transcription, realtime voice, media-understanding, image-generation, music-generation, video-generation, web-fetch, web search, and tool ownership. | | `channelConfigs` | No | `Record` | Manifest-owned channel config metadata merged into discovery and validation surfaces before runtime loads. | @@ -436,6 +440,9 @@ See [Configuration reference](/gateway/configuration) for the full `plugins.*` s - `providerAuthEnvVars` is the cheap metadata path for auth probes, env-marker validation, and similar provider-auth surfaces that should not boot plugin runtime just to inspect env names. +- `channelEnvVars` is the cheap metadata path for shell-env fallback, setup + prompts, and similar channel surfaces that should not boot plugin runtime + just to inspect env names. - `providerAuthChoices` is the cheap metadata path for auth-choice pickers, `--auth-choice` resolution, preferred-provider mapping, and simple onboarding CLI flag registration before provider runtime loads. For runtime wizard diff --git a/docs/plugins/sdk-channel-plugins.md b/docs/plugins/sdk-channel-plugins.md index 4474afa246..1bd6d5a7ff 100644 --- a/docs/plugins/sdk-channel-plugins.md +++ b/docs/plugins/sdk-channel-plugins.md @@ -108,9 +108,15 @@ For setup specifically: - `openclaw/plugin-sdk/channel-setup` covers the optional-install setup builders plus a few setup-safe primitives: `createOptionalChannelSetupSurface`, `createOptionalChannelSetupAdapter`, - `createOptionalChannelSetupWizard`, `DEFAULT_ACCOUNT_ID`, - `createTopLevelChannelDmPolicy`, `setSetupChannelEnabled`, and - `splitSetupEntries` + +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. +`createOptionalChannelSetupWizard`, `DEFAULT_ACCOUNT_ID`, +`createTopLevelChannelDmPolicy`, `setSetupChannelEnabled`, and +`splitSetupEntries` + - use the broader `openclaw/plugin-sdk/setup` seam only when you also need the heavier shared setup/config helpers such as `moveSingleAccountChannelSectionToDefaultAccount(...)` diff --git a/extensions/discord/openclaw.plugin.json b/extensions/discord/openclaw.plugin.json index 62db8b8aac..69f800ff45 100644 --- a/extensions/discord/openclaw.plugin.json +++ b/extensions/discord/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "discord", "channels": ["discord"], + "channelEnvVars": { + "discord": ["DISCORD_BOT_TOKEN"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/feishu/openclaw.plugin.json b/extensions/feishu/openclaw.plugin.json index 90358d7ec5..312c531014 100644 --- a/extensions/feishu/openclaw.plugin.json +++ b/extensions/feishu/openclaw.plugin.json @@ -1,6 +1,14 @@ { "id": "feishu", "channels": ["feishu"], + "channelEnvVars": { + "feishu": [ + "FEISHU_APP_ID", + "FEISHU_APP_SECRET", + "FEISHU_VERIFICATION_TOKEN", + "FEISHU_ENCRYPT_KEY" + ] + }, "skills": ["./skills"], "configSchema": { "type": "object", diff --git a/extensions/googlechat/openclaw.plugin.json b/extensions/googlechat/openclaw.plugin.json index aac96a5858..34294ef19d 100644 --- a/extensions/googlechat/openclaw.plugin.json +++ b/extensions/googlechat/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "googlechat", "channels": ["googlechat"], + "channelEnvVars": { + "googlechat": ["GOOGLE_CHAT_SERVICE_ACCOUNT", "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/irc/openclaw.plugin.json b/extensions/irc/openclaw.plugin.json index df5404ce38..9579b04486 100644 --- a/extensions/irc/openclaw.plugin.json +++ b/extensions/irc/openclaw.plugin.json @@ -1,6 +1,20 @@ { "id": "irc", "channels": ["irc"], + "channelEnvVars": { + "irc": [ + "IRC_HOST", + "IRC_PORT", + "IRC_TLS", + "IRC_NICK", + "IRC_USERNAME", + "IRC_REALNAME", + "IRC_PASSWORD", + "IRC_CHANNELS", + "IRC_NICKSERV_PASSWORD", + "IRC_NICKSERV_REGISTER_EMAIL" + ] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/line/openclaw.plugin.json b/extensions/line/openclaw.plugin.json index 813fd1de45..a30c69815a 100644 --- a/extensions/line/openclaw.plugin.json +++ b/extensions/line/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "line", "channels": ["line"], + "channelEnvVars": { + "line": ["LINE_CHANNEL_ACCESS_TOKEN", "LINE_CHANNEL_SECRET"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/matrix/openclaw.plugin.json b/extensions/matrix/openclaw.plugin.json index 2c179d9c6a..eb728d4f07 100644 --- a/extensions/matrix/openclaw.plugin.json +++ b/extensions/matrix/openclaw.plugin.json @@ -1,6 +1,20 @@ { "id": "matrix", "channels": ["matrix"], + "channelEnvVars": { + "matrix": [ + "MATRIX_HOMESERVER", + "MATRIX_USER_ID", + "MATRIX_ACCESS_TOKEN", + "MATRIX_PASSWORD", + "MATRIX_DEVICE_ID", + "MATRIX_DEVICE_NAME", + "MATRIX_OPS_HOMESERVER", + "MATRIX_OPS_ACCESS_TOKEN", + "MATRIX_OPS_DEVICE_ID", + "MATRIX_OPS_DEVICE_NAME" + ] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/mattermost/openclaw.plugin.json b/extensions/mattermost/openclaw.plugin.json index 91bbca1f21..7f5164299c 100644 --- a/extensions/mattermost/openclaw.plugin.json +++ b/extensions/mattermost/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "mattermost", "channels": ["mattermost"], + "channelEnvVars": { + "mattermost": ["MATTERMOST_BOT_TOKEN", "MATTERMOST_URL"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/msteams/openclaw.plugin.json b/extensions/msteams/openclaw.plugin.json index 0509a84660..0e7e5b776a 100644 --- a/extensions/msteams/openclaw.plugin.json +++ b/extensions/msteams/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "msteams", "channels": ["msteams"], + "channelEnvVars": { + "msteams": ["MSTEAMS_APP_ID", "MSTEAMS_APP_PASSWORD", "MSTEAMS_TENANT_ID"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/nextcloud-talk/openclaw.plugin.json b/extensions/nextcloud-talk/openclaw.plugin.json index 6730737886..4400f0875a 100644 --- a/extensions/nextcloud-talk/openclaw.plugin.json +++ b/extensions/nextcloud-talk/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "nextcloud-talk", "channels": ["nextcloud-talk"], + "channelEnvVars": { + "nextcloud-talk": ["NEXTCLOUD_TALK_BOT_SECRET", "NEXTCLOUD_TALK_API_PASSWORD"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/nostr/openclaw.plugin.json b/extensions/nostr/openclaw.plugin.json index 83adc53c02..1d98ba52e5 100644 --- a/extensions/nostr/openclaw.plugin.json +++ b/extensions/nostr/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "nostr", "channels": ["nostr"], + "channelEnvVars": { + "nostr": ["NOSTR_PRIVATE_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/qqbot/openclaw.plugin.json b/extensions/qqbot/openclaw.plugin.json index 5c33614b30..ec3e9e9a8e 100644 --- a/extensions/qqbot/openclaw.plugin.json +++ b/extensions/qqbot/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "qqbot", "channels": ["qqbot"], + "channelEnvVars": { + "qqbot": ["QQBOT_APP_ID", "QQBOT_CLIENT_SECRET"] + }, "skills": ["./skills"], "configSchema": { "type": "object", diff --git a/extensions/slack/openclaw.plugin.json b/extensions/slack/openclaw.plugin.json index 2e7a95a0be..3a0b1dd5b4 100644 --- a/extensions/slack/openclaw.plugin.json +++ b/extensions/slack/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "slack", "channels": ["slack"], + "channelEnvVars": { + "slack": ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_USER_TOKEN"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/synology-chat/openclaw.plugin.json b/extensions/synology-chat/openclaw.plugin.json index ec82a5cc52..f96e6be582 100644 --- a/extensions/synology-chat/openclaw.plugin.json +++ b/extensions/synology-chat/openclaw.plugin.json @@ -1,6 +1,16 @@ { "id": "synology-chat", "channels": ["synology-chat"], + "channelEnvVars": { + "synology-chat": [ + "SYNOLOGY_CHAT_TOKEN", + "SYNOLOGY_CHAT_INCOMING_URL", + "SYNOLOGY_NAS_HOST", + "SYNOLOGY_ALLOWED_USER_IDS", + "SYNOLOGY_RATE_LIMIT", + "OPENCLAW_BOT_NAME" + ] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/telegram/openclaw.plugin.json b/extensions/telegram/openclaw.plugin.json index 25d7e2abb7..e59466f9f6 100644 --- a/extensions/telegram/openclaw.plugin.json +++ b/extensions/telegram/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "telegram", "channels": ["telegram"], + "channelEnvVars": { + "telegram": ["TELEGRAM_BOT_TOKEN"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/twitch/openclaw.plugin.json b/extensions/twitch/openclaw.plugin.json index 3e7d1ec268..71777f9b82 100644 --- a/extensions/twitch/openclaw.plugin.json +++ b/extensions/twitch/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "twitch", "channels": ["twitch"], + "channelEnvVars": { + "twitch": ["OPENCLAW_TWITCH_ACCESS_TOKEN"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/voice-call/openclaw.plugin.json b/extensions/voice-call/openclaw.plugin.json index bf6c1238bc..ca3658e5e2 100644 --- a/extensions/voice-call/openclaw.plugin.json +++ b/extensions/voice-call/openclaw.plugin.json @@ -1,5 +1,18 @@ { "id": "voice-call", + "channelEnvVars": { + "voice-call": [ + "TELNYX_API_KEY", + "TELNYX_CONNECTION_ID", + "TELNYX_PUBLIC_KEY", + "TWILIO_ACCOUNT_SID", + "TWILIO_AUTH_TOKEN", + "PLIVO_AUTH_ID", + "PLIVO_AUTH_TOKEN", + "NGROK_AUTHTOKEN", + "NGROK_DOMAIN" + ] + }, "uiHints": { "provider": { "label": "Provider", diff --git a/extensions/zalo/openclaw.plugin.json b/extensions/zalo/openclaw.plugin.json index 7153122bb5..9b06865ece 100644 --- a/extensions/zalo/openclaw.plugin.json +++ b/extensions/zalo/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "zalo", "channels": ["zalo"], + "channelEnvVars": { + "zalo": ["ZALO_BOT_TOKEN", "ZALO_WEBHOOK_SECRET"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/zalouser/openclaw.plugin.json b/extensions/zalouser/openclaw.plugin.json index 2d24d8e251..ea3794e510 100644 --- a/extensions/zalouser/openclaw.plugin.json +++ b/extensions/zalouser/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "zalouser", "channels": ["zalouser"], + "channelEnvVars": { + "zalouser": ["ZALOUSER_PROFILE", "ZCA_PROFILE"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/src/config/io.shell-env-expected-keys.test.ts b/src/config/io.shell-env-expected-keys.test.ts index b62928346e..6c961318db 100644 --- a/src/config/io.shell-env-expected-keys.test.ts +++ b/src/config/io.shell-env-expected-keys.test.ts @@ -1,18 +1,28 @@ import { describe, expect, it, vi } from "vitest"; +const listKnownChannelEnvVarNames = vi.hoisted(() => vi.fn(() => ["DISCORD_BOT_TOKEN"])); const listKnownProviderAuthEnvVarNames = vi.hoisted(() => vi.fn(() => ["OPENAI_API_KEY"])); +vi.mock("../secrets/channel-env-vars.js", () => ({ + listKnownChannelEnvVarNames, +})); + vi.mock("../secrets/provider-env-vars.js", () => ({ listKnownProviderAuthEnvVarNames, })); describe("config io shell env expected keys", () => { - it("includes provider auth env vars from manifest-driven provider metadata", async () => { + it("includes provider and channel env vars from manifest-driven plugin metadata", async () => { listKnownProviderAuthEnvVarNames.mockReturnValueOnce([ "OPENAI_API_KEY", "ARCEEAI_API_KEY", "FIREWORKS_ALT_API_KEY", ]); + listKnownChannelEnvVarNames.mockReturnValueOnce([ + "DISCORD_BOT_TOKEN", + "SLACK_BOT_TOKEN", + "SLACK_APP_TOKEN", + ]); vi.resetModules(); const { resolveShellEnvExpectedKeys } = await import("./shell-env-expected-keys.js"); @@ -22,8 +32,9 @@ describe("config io shell env expected keys", () => { "OPENAI_API_KEY", "ARCEEAI_API_KEY", "FIREWORKS_ALT_API_KEY", - "OPENCLAW_GATEWAY_TOKEN", + "DISCORD_BOT_TOKEN", "SLACK_BOT_TOKEN", + "OPENCLAW_GATEWAY_TOKEN", ]), ); }); diff --git a/src/config/shell-env-expected-keys.ts b/src/config/shell-env-expected-keys.ts index dead115a4d..a50b817384 100644 --- a/src/config/shell-env-expected-keys.ts +++ b/src/config/shell-env-expected-keys.ts @@ -1,16 +1,14 @@ +import { listKnownChannelEnvVarNames } from "../secrets/channel-env-vars.js"; import { listKnownProviderAuthEnvVarNames } from "../secrets/provider-env-vars.js"; -const CORE_SHELL_ENV_EXPECTED_KEYS = [ - "TELEGRAM_BOT_TOKEN", - "DISCORD_BOT_TOKEN", - "SLACK_BOT_TOKEN", - "SLACK_APP_TOKEN", - "OPENCLAW_GATEWAY_TOKEN", - "OPENCLAW_GATEWAY_PASSWORD", -]; +const CORE_SHELL_ENV_EXPECTED_KEYS = ["OPENCLAW_GATEWAY_TOKEN", "OPENCLAW_GATEWAY_PASSWORD"]; export function resolveShellEnvExpectedKeys(env: NodeJS.ProcessEnv): string[] { return [ - ...new Set([...listKnownProviderAuthEnvVarNames({ env }), ...CORE_SHELL_ENV_EXPECTED_KEYS]), + ...new Set([ + ...listKnownProviderAuthEnvVarNames({ env }), + ...listKnownChannelEnvVarNames({ env }), + ...CORE_SHELL_ENV_EXPECTED_KEYS, + ]), ]; } diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index 91efd9bdea..f86d59b0fe 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -417,6 +417,28 @@ describe("loadPluginManifestRegistry", () => { ]); }); + it("preserves channel env metadata from plugin manifests", () => { + const dir = makeTempDir(); + writeManifest(dir, { + id: "slack", + channels: ["slack"], + channelEnvVars: { + slack: ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_USER_TOKEN"], + }, + configSchema: { type: "object" }, + }); + + const registry = loadSingleCandidateRegistry({ + idHint: "slack", + rootDir: dir, + origin: "bundled", + }); + + expect(registry.plugins[0]?.channelEnvVars).toEqual({ + slack: ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_USER_TOKEN"], + }); + }); + it("preserves channel config metadata from plugin manifests", () => { const dir = makeTempDir(); writeManifest(dir, { diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 31fd0ae1c4..49f9c6ca00 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -73,6 +73,7 @@ export type PluginManifestRecord = { modelSupport?: PluginManifestModelSupport; cliBackends: string[]; providerAuthEnvVars?: Record; + channelEnvVars?: Record; providerAuthChoices?: PluginManifest["providerAuthChoices"]; skills: string[]; settingsFiles?: string[]; @@ -292,6 +293,7 @@ function buildRecord(params: { modelSupport: params.manifest.modelSupport, cliBackends: params.manifest.cliBackends ?? [], providerAuthEnvVars: params.manifest.providerAuthEnvVars, + channelEnvVars: params.manifest.channelEnvVars, providerAuthChoices: params.manifest.providerAuthChoices, skills: params.manifest.skills ?? [], settingsFiles: [], diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index c9df4f23f1..a8ea6ff041 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -89,6 +89,8 @@ export type PluginManifest = { cliBackends?: string[]; /** Cheap provider-auth env lookup without booting plugin runtime. */ providerAuthEnvVars?: Record; + /** Cheap channel env lookup without booting plugin runtime. */ + channelEnvVars?: Record; /** * Cheap onboarding/auth-choice metadata used by config validation, CLI help, * and non-runtime auth-choice routing before provider runtime loads. @@ -500,6 +502,7 @@ export function loadPluginManifest( const modelSupport = normalizeManifestModelSupport(raw.modelSupport); const cliBackends = normalizeStringList(raw.cliBackends); const providerAuthEnvVars = normalizeStringListRecord(raw.providerAuthEnvVars); + const channelEnvVars = normalizeStringListRecord(raw.channelEnvVars); const providerAuthChoices = normalizeProviderAuthChoices(raw.providerAuthChoices); const skills = normalizeStringList(raw.skills); const contracts = normalizeManifestContracts(raw.contracts); @@ -527,6 +530,7 @@ export function loadPluginManifest( modelSupport, cliBackends, providerAuthEnvVars, + channelEnvVars, providerAuthChoices, skills, name, diff --git a/src/secrets/channel-env-vars.dynamic.test.ts b/src/secrets/channel-env-vars.dynamic.test.ts new file mode 100644 index 0000000000..4cf1855fd4 --- /dev/null +++ b/src/secrets/channel-env-vars.dynamic.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type MockManifestRegistry = { + plugins: Array<{ + id: string; + origin: string; + channelEnvVars?: Record; + }>; + diagnostics: unknown[]; +}; + +const loadPluginManifestRegistry = vi.hoisted(() => + vi.fn<() => MockManifestRegistry>(() => ({ plugins: [], diagnostics: [] })), +); + +vi.mock("../plugins/manifest-registry.js", () => ({ + loadPluginManifestRegistry, +})); + +describe("channel env vars dynamic manifest metadata", () => { + beforeEach(() => { + vi.resetModules(); + loadPluginManifestRegistry.mockReset(); + loadPluginManifestRegistry.mockReturnValue({ plugins: [], diagnostics: [] }); + }); + + it("includes later-installed plugin env vars without a bundled generated map", async () => { + loadPluginManifestRegistry.mockReturnValue({ + plugins: [ + { + id: "external-mattermost", + origin: "global", + channelEnvVars: { + mattermost: ["MATTERMOST_BOT_TOKEN", "MATTERMOST_URL"], + }, + }, + ], + diagnostics: [], + }); + + const mod = await import("./channel-env-vars.js"); + + expect(mod.getChannelEnvVars("mattermost")).toEqual(["MATTERMOST_BOT_TOKEN", "MATTERMOST_URL"]); + expect(mod.listKnownChannelEnvVarNames()).toEqual( + expect.arrayContaining(["MATTERMOST_BOT_TOKEN", "MATTERMOST_URL"]), + ); + }); +}); diff --git a/src/secrets/channel-env-vars.ts b/src/secrets/channel-env-vars.ts new file mode 100644 index 0000000000..d68b6d02c6 --- /dev/null +++ b/src/secrets/channel-env-vars.ts @@ -0,0 +1,61 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js"; + +type ChannelEnvVarLookupParams = { + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}; + +function appendUniqueEnvVarCandidates( + target: Record, + channelId: string, + keys: readonly string[], +) { + const normalizedChannelId = channelId.trim(); + if (!normalizedChannelId || keys.length === 0) { + return; + } + const bucket = (target[normalizedChannelId] ??= []); + const seen = new Set(bucket); + for (const key of keys) { + const normalizedKey = key.trim(); + if (!normalizedKey || seen.has(normalizedKey)) { + continue; + } + seen.add(normalizedKey); + bucket.push(normalizedKey); + } +} + +export function resolveChannelEnvVars( + params?: ChannelEnvVarLookupParams, +): Record { + const registry = loadPluginManifestRegistry({ + config: params?.config, + workspaceDir: params?.workspaceDir, + env: params?.env, + }); + const candidates: Record = Object.create(null) as Record; + for (const plugin of registry.plugins) { + if (!plugin.channelEnvVars) { + continue; + } + for (const [channelId, keys] of Object.entries(plugin.channelEnvVars).toSorted( + ([left], [right]) => left.localeCompare(right), + )) { + appendUniqueEnvVarCandidates(candidates, channelId, keys); + } + } + return candidates; +} + +export function getChannelEnvVars(channelId: string, params?: ChannelEnvVarLookupParams): string[] { + const channelEnvVars = resolveChannelEnvVars(params); + const envVars = Object.hasOwn(channelEnvVars, channelId) ? channelEnvVars[channelId] : undefined; + return Array.isArray(envVars) ? [...envVars] : []; +} + +export function listKnownChannelEnvVarNames(params?: ChannelEnvVarLookupParams): string[] { + return [...new Set(Object.values(resolveChannelEnvVars(params)).flatMap((keys) => keys))]; +}