refactor: resolve channel env vars from plugin manifests

This commit is contained in:
Peter Steinberger
2026-04-06 19:10:17 +01:00
parent bc18e69fbf
commit 8ff570ee42
28 changed files with 278 additions and 16 deletions

View File

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

View File

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

View File

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

View File

@@ -73,6 +73,7 @@ export type PluginManifestRecord = {
modelSupport?: PluginManifestModelSupport;
cliBackends: string[];
providerAuthEnvVars?: Record<string, string[]>;
channelEnvVars?: Record<string, string[]>;
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: [],

View File

@@ -89,6 +89,8 @@ export type PluginManifest = {
cliBackends?: string[];
/** Cheap provider-auth env lookup without booting plugin runtime. */
providerAuthEnvVars?: Record<string, string[]>;
/** Cheap channel env lookup without booting plugin runtime. */
channelEnvVars?: Record<string, string[]>;
/**
* 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,

View File

@@ -0,0 +1,48 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
type MockManifestRegistry = {
plugins: Array<{
id: string;
origin: string;
channelEnvVars?: Record<string, string[]>;
}>;
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"]),
);
});
});

View File

@@ -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<string, string[]>,
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<string, readonly string[]> {
const registry = loadPluginManifestRegistry({
config: params?.config,
workspaceDir: params?.workspaceDir,
env: params?.env,
});
const candidates: Record<string, string[]> = Object.create(null) as Record<string, string[]>;
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))];
}