refactor(plugins): split activation snapshot and compat flow

This commit is contained in:
Peter Steinberger
2026-04-04 00:42:01 +09:00
parent eb3481fca9
commit 41ce3269f4
8 changed files with 275 additions and 108 deletions

View File

@@ -11,7 +11,11 @@ import {
makeTrackedTempDir,
mkdirSafeDir,
} from "../plugins/test-helpers/fs-fixtures.js";
import { applyPluginAutoEnable } from "./plugin-auto-enable.js";
import {
applyPluginAutoEnable,
detectPluginAutoEnableCandidates,
resolvePluginAutoEnableCandidateReason,
} from "./plugin-auto-enable.js";
import { validateConfigObject } from "./validation.js";
const tempDirs: string[] = [];
@@ -121,6 +125,33 @@ afterEach(() => {
});
describe("applyPluginAutoEnable", () => {
it("detects typed channel-configured candidates", () => {
const candidates = detectPluginAutoEnableCandidates({
config: {
channels: { slack: { botToken: "x" } },
},
env: {},
});
expect(candidates).toEqual([
{
pluginId: "slack",
kind: "channel-configured",
channelId: "slack",
},
]);
});
it("formats typed provider-auth candidates into stable reasons", () => {
expect(
resolvePluginAutoEnableCandidateReason({
pluginId: "google",
kind: "provider-auth-configured",
providerId: "google",
}),
).toBe("google auth configured");
});
it("treats an undefined config as empty", () => {
const result = applyPluginAutoEnable({
config: undefined,

View File

@@ -21,12 +21,43 @@ import type { OpenClawConfig } from "./config.js";
import { ensurePluginAllowlisted } from "./plugins-allowlist.js";
import { isBlockedObjectKey } from "./prototype-keys.js";
type PluginEnableChange = {
pluginId: string;
reason: string;
};
export type PluginAutoEnableCandidate = PluginEnableChange;
export type PluginAutoEnableCandidate =
| {
pluginId: string;
kind: "channel-configured";
channelId: string;
}
| {
pluginId: "browser";
kind: "browser-configured";
source: "browser-configured" | "browser-plugin-configured" | "browser-tool-referenced";
}
| {
pluginId: string;
kind: "provider-auth-configured";
providerId: string;
}
| {
pluginId: string;
kind: "web-fetch-provider-selected";
providerId: string;
}
| {
pluginId: string;
kind: "plugin-web-search-configured";
}
| {
pluginId: string;
kind: "plugin-web-fetch-configured";
}
| {
pluginId: string;
kind: "plugin-tool-configured";
}
| {
pluginId: "acpx";
kind: "acp-runtime-configured";
};
export type PluginAutoEnableResult = {
config: OpenClawConfig;
@@ -370,7 +401,7 @@ function configMayNeedPluginAutoEnable(cfg: OpenClawConfig, env: NodeJS.ProcessE
if (hasPotentialConfiguredChannels(cfg, env)) {
return true;
}
if (resolveBrowserAutoEnableReason(cfg)) {
if (resolveBrowserAutoEnableSource(cfg)) {
return true;
}
if (cfg.acp?.enabled === true || cfg.acp?.dispatch?.enabled === true) {
@@ -435,26 +466,59 @@ function hasExplicitBrowserPluginEntry(cfg: OpenClawConfig): boolean {
);
}
function resolveBrowserAutoEnableReason(cfg: OpenClawConfig): string | null {
function resolveBrowserAutoEnableSource(
cfg: OpenClawConfig,
): Extract<PluginAutoEnableCandidate, { kind: "browser-configured" }>["source"] | null {
if (cfg.browser?.enabled === false || cfg.plugins?.entries?.browser?.enabled === false) {
return null;
}
if (Object.prototype.hasOwnProperty.call(cfg, "browser")) {
return "browser configured";
return "browser-configured";
}
if (hasExplicitBrowserPluginEntry(cfg)) {
return "browser plugin configured";
return "browser-plugin-configured";
}
if (hasBrowserToolReference(cfg)) {
return "browser tool referenced";
return "browser-tool-referenced";
}
return null;
}
export function resolvePluginAutoEnableCandidateReason(
candidate: PluginAutoEnableCandidate,
): string {
switch (candidate.kind) {
case "channel-configured":
return `${candidate.channelId} configured`;
case "browser-configured":
switch (candidate.source) {
case "browser-configured":
return "browser configured";
case "browser-plugin-configured":
return "browser plugin configured";
case "browser-tool-referenced":
return "browser tool referenced";
}
break;
case "provider-auth-configured":
return `${candidate.providerId} auth configured`;
case "web-fetch-provider-selected":
return `${candidate.providerId} web fetch provider selected`;
case "plugin-web-search-configured":
return `${candidate.pluginId} web search configured`;
case "plugin-web-fetch-configured":
return `${candidate.pluginId} web fetch configured`;
case "plugin-tool-configured":
return `${candidate.pluginId} tool configured`;
case "acp-runtime-configured":
return "ACP runtime configured";
}
}
function resolveConfiguredPlugins(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv,
@@ -466,13 +530,13 @@ function resolveConfiguredPlugins(
for (const channelId of collectCandidateChannelIds(cfg, env)) {
const pluginId = resolvePluginIdForChannel(channelId, channelToPluginId);
if (isChannelConfigured(cfg, channelId, env)) {
changes.push({ pluginId, reason: `${channelId} configured` });
changes.push({ pluginId, kind: "channel-configured", channelId });
}
}
const browserReason = resolveBrowserAutoEnableReason(cfg);
if (browserReason) {
changes.push({ pluginId: "browser", reason: browserReason });
const browserSource = resolveBrowserAutoEnableSource(cfg);
if (browserSource) {
changes.push({ pluginId: "browser", kind: "browser-configured", source: browserSource });
}
for (const [providerId, pluginId] of Object.entries(
@@ -481,7 +545,8 @@ function resolveConfiguredPlugins(
if (isProviderConfigured(cfg, providerId)) {
changes.push({
pluginId,
reason: `${providerId} auth configured`,
kind: "provider-auth-configured",
providerId,
});
}
}
@@ -491,14 +556,15 @@ function resolveConfiguredPlugins(
if (webFetchPluginId) {
changes.push({
pluginId: webFetchPluginId,
reason: `${String(webFetchProvider).trim().toLowerCase()} web fetch provider selected`,
kind: "web-fetch-provider-selected",
providerId: String(webFetchProvider).trim().toLowerCase(),
});
}
for (const pluginId of resolveProviderPluginsWithOwnedWebSearch(registry)) {
if (hasPluginOwnedWebSearchConfig(cfg, pluginId)) {
changes.push({
pluginId,
reason: `${pluginId} web search configured`,
kind: "plugin-web-search-configured",
});
}
}
@@ -506,7 +572,7 @@ function resolveConfiguredPlugins(
if (hasPluginOwnedWebFetchConfig(cfg, pluginId)) {
changes.push({
pluginId,
reason: `${pluginId} web fetch configured`,
kind: "plugin-web-fetch-configured",
});
}
}
@@ -514,7 +580,7 @@ function resolveConfiguredPlugins(
if (hasPluginOwnedToolConfig(cfg, pluginId)) {
changes.push({
pluginId,
reason: `${pluginId} tool configured`,
kind: "plugin-tool-configured",
});
}
}
@@ -525,7 +591,7 @@ function resolveConfiguredPlugins(
if (acpConfigured && (!backendRaw || backendRaw === "acpx")) {
changes.push({
pluginId: "acpx",
reason: "ACP runtime configured",
kind: "acp-runtime-configured",
});
}
return changes;
@@ -577,8 +643,8 @@ function resolvePreferredOverIds(
function shouldSkipPreferredPluginAutoEnable(
cfg: OpenClawConfig,
entry: PluginEnableChange,
configured: PluginEnableChange[],
entry: PluginAutoEnableCandidate,
configured: PluginAutoEnableCandidate[],
env: NodeJS.ProcessEnv,
registry: PluginManifestRegistry,
): boolean {
@@ -636,8 +702,8 @@ function registerPluginEntry(cfg: OpenClawConfig, pluginId: string): OpenClawCon
};
}
function formatAutoEnableChange(entry: PluginEnableChange): string {
let reason = entry.reason.trim();
function formatAutoEnableChange(entry: PluginAutoEnableCandidate): string {
let reason = resolvePluginAutoEnableCandidateReason(entry).trim();
const channelId = normalizeChatChannelId(entry.pluginId);
if (channelId) {
const label = getChatChannelMeta(channelId).label;
@@ -720,9 +786,10 @@ export function materializePluginAutoEnableCandidates(params: {
if (!builtInChannelId) {
next = ensurePluginAllowlisted(next, entry.pluginId);
}
const reason = resolvePluginAutoEnableCandidateReason(entry);
autoEnabledReasons.set(entry.pluginId, [
...(autoEnabledReasons.get(entry.pluginId) ?? []),
entry.reason,
reason,
]);
changes.push(formatAutoEnableChange(entry));
}

View File

@@ -1,6 +1,6 @@
import { primeConfiguredBindingRegistry } from "../channels/plugins/binding-registry.js";
import type { loadConfig } from "../config/config.js";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
import { resolvePluginActivationSnapshot } from "../plugins/activation-context.js";
import type { PluginRegistry } from "../plugins/registry.js";
import { pinActivePluginChannelRegistry } from "../plugins/runtime.js";
import { setGatewaySubagentRuntime } from "../plugins/runtime/index.js";
@@ -59,16 +59,17 @@ function logGatewayPluginDiagnostics(params: {
}
export function prepareGatewayPluginLoad(params: GatewayPluginBootstrapParams) {
const autoEnabled = applyPluginAutoEnable({
config: params.activationSourceConfig ?? params.cfg,
const activation = resolvePluginActivationSnapshot({
rawConfig: params.activationSourceConfig ?? params.cfg,
env: process.env,
applyAutoEnable: true,
});
const resolvedConfig = autoEnabled.config;
const resolvedConfig = activation.config ?? params.cfg;
installGatewayPluginRuntimeEnvironment(resolvedConfig);
const loaded = loadGatewayPlugins({
cfg: resolvedConfig,
activationSourceConfig: params.activationSourceConfig ?? params.cfg,
autoEnabledReasons: autoEnabled.autoEnabledReasons,
autoEnabledReasons: activation.autoEnabledReasons,
workspaceDir: params.workspaceDir,
log: params.log,
coreGatewayHandlers: params.coreGatewayHandlers,

View File

@@ -18,6 +18,12 @@ export type PluginActivationCompatConfig = {
vitestPluginIds?: readonly string[];
};
export type PluginActivationBundledCompatMode = {
allowlist?: boolean;
enablement?: "always" | "allowlist";
vitest?: boolean;
};
export type PluginActivationInputs = {
rawConfig?: OpenClawConfig;
config?: OpenClawConfig;
@@ -27,7 +33,21 @@ export type PluginActivationInputs = {
autoEnabledReasons: Record<string, string[]>;
};
function applyPluginActivationCompat(params: {
export type PluginActivationSnapshot = Pick<
PluginActivationInputs,
| "rawConfig"
| "config"
| "normalized"
| "activationSourceConfig"
| "activationSource"
| "autoEnabledReasons"
>;
export type BundledPluginCompatibleActivationInputs = PluginActivationInputs & {
compatPluginIds: string[];
};
export function applyPluginCompatibilityOverrides(params: {
config?: OpenClawConfig;
compat?: PluginActivationCompatConfig;
env: NodeJS.ProcessEnv;
@@ -54,14 +74,13 @@ function applyPluginActivationCompat(params: {
return vitestCompat;
}
export function resolvePluginActivationInputs(params: {
export function resolvePluginActivationSnapshot(params: {
rawConfig?: OpenClawConfig;
resolvedConfig?: OpenClawConfig;
autoEnabledReasons?: Record<string, string[]>;
env?: NodeJS.ProcessEnv;
compat?: PluginActivationCompatConfig;
applyAutoEnable?: boolean;
}): PluginActivationInputs {
}): PluginActivationSnapshot {
const env = params.env ?? process.env;
const rawConfig = params.rawConfig ?? params.resolvedConfig;
let resolvedConfig = params.resolvedConfig ?? params.rawConfig;
@@ -76,16 +95,10 @@ export function resolvePluginActivationInputs(params: {
autoEnabledReasons = autoEnabled.autoEnabledReasons;
}
const config = applyPluginActivationCompat({
config: resolvedConfig,
compat: params.compat,
env,
});
return {
rawConfig,
config,
normalized: normalizePluginsConfig(config?.plugins),
config: resolvedConfig,
normalized: normalizePluginsConfig(resolvedConfig?.plugins),
activationSourceConfig: rawConfig,
activationSource: createPluginActivationSource({
config: rawConfig,
@@ -93,3 +106,94 @@ export function resolvePluginActivationInputs(params: {
autoEnabledReasons: autoEnabledReasons ?? {},
};
}
export function resolvePluginActivationInputs(params: {
rawConfig?: OpenClawConfig;
resolvedConfig?: OpenClawConfig;
autoEnabledReasons?: Record<string, string[]>;
env?: NodeJS.ProcessEnv;
compat?: PluginActivationCompatConfig;
applyAutoEnable?: boolean;
}): PluginActivationInputs {
const env = params.env ?? process.env;
const snapshot = resolvePluginActivationSnapshot({
rawConfig: params.rawConfig,
resolvedConfig: params.resolvedConfig,
autoEnabledReasons: params.autoEnabledReasons,
env,
applyAutoEnable: params.applyAutoEnable,
});
const config = applyPluginCompatibilityOverrides({
config: snapshot.config,
compat: params.compat,
env,
});
return {
rawConfig: snapshot.rawConfig,
config,
normalized: normalizePluginsConfig(config?.plugins),
activationSourceConfig: snapshot.activationSourceConfig,
activationSource: snapshot.activationSource,
autoEnabledReasons: snapshot.autoEnabledReasons,
};
}
export function resolveBundledPluginCompatibleActivationInputs(params: {
rawConfig?: OpenClawConfig;
resolvedConfig?: OpenClawConfig;
autoEnabledReasons?: Record<string, string[]>;
env?: NodeJS.ProcessEnv;
workspaceDir?: string;
onlyPluginIds?: readonly string[];
applyAutoEnable?: boolean;
compatMode: PluginActivationBundledCompatMode;
resolveCompatPluginIds: (params: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
onlyPluginIds?: readonly string[];
}) => string[];
}): BundledPluginCompatibleActivationInputs {
const snapshot = resolvePluginActivationSnapshot({
rawConfig: params.rawConfig,
resolvedConfig: params.resolvedConfig,
autoEnabledReasons: params.autoEnabledReasons,
env: params.env,
applyAutoEnable: params.applyAutoEnable,
});
const allowlistCompatEnabled = params.compatMode.allowlist === true;
const shouldResolveCompatPluginIds =
allowlistCompatEnabled ||
params.compatMode.enablement === "always" ||
(params.compatMode.enablement === "allowlist" && allowlistCompatEnabled) ||
params.compatMode.vitest === true;
const compatPluginIds = shouldResolveCompatPluginIds
? params.resolveCompatPluginIds({
config: snapshot.config,
workspaceDir: params.workspaceDir,
env: params.env,
onlyPluginIds: params.onlyPluginIds,
})
: [];
const activation = resolvePluginActivationInputs({
rawConfig: snapshot.rawConfig,
resolvedConfig: snapshot.config,
autoEnabledReasons: snapshot.autoEnabledReasons,
env: params.env,
compat: {
allowlistPluginIds: allowlistCompatEnabled ? compatPluginIds : undefined,
enablementPluginIds:
params.compatMode.enablement === "always" ||
(params.compatMode.enablement === "allowlist" && allowlistCompatEnabled)
? compatPluginIds
: undefined,
vitestPluginIds: params.compatMode.vitest ? compatPluginIds : undefined,
},
});
return {
...activation,
compatPluginIds,
};
}

View File

@@ -1,5 +1,5 @@
import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolvePluginActivationInputs } from "./activation-context.js";
import { resolveBundledPluginCompatibleActivationInputs } from "./activation-context.js";
import { resolveRuntimePluginRegistry, type PluginLoadOptions } from "./loader.js";
import { createPluginLoaderLogger } from "./logger.js";
import {
@@ -24,41 +24,23 @@ export function resolvePluginProviders(params: {
pluginSdkResolution?: PluginLoadOptions["pluginSdkResolution"];
}): ProviderPlugin[] {
const env = params.env ?? process.env;
const autoEnabled = resolvePluginActivationInputs({
const activation = resolveBundledPluginCompatibleActivationInputs({
rawConfig: params.config,
env,
workspaceDir: params.workspaceDir,
onlyPluginIds: params.onlyPluginIds,
applyAutoEnable: true,
});
const bundledProviderCompatPluginIds =
params.bundledProviderAllowlistCompat || params.bundledProviderVitestCompat
? resolveBundledProviderCompatPluginIds({
config: autoEnabled.config,
workspaceDir: params.workspaceDir,
env,
onlyPluginIds: params.onlyPluginIds,
})
: [];
const activation = resolvePluginActivationInputs({
rawConfig: params.config,
resolvedConfig: autoEnabled.config,
autoEnabledReasons: autoEnabled.autoEnabledReasons,
env,
compat: {
allowlistPluginIds: params.bundledProviderAllowlistCompat
? bundledProviderCompatPluginIds
: undefined,
enablementPluginIds: params.bundledProviderAllowlistCompat
? bundledProviderCompatPluginIds
: undefined,
vitestPluginIds: params.bundledProviderVitestCompat
? bundledProviderCompatPluginIds
: undefined,
compatMode: {
allowlist: params.bundledProviderAllowlistCompat,
enablement: "allowlist",
vitest: params.bundledProviderVitestCompat,
},
resolveCompatPluginIds: resolveBundledProviderCompatPluginIds,
});
const config = params.bundledProviderVitestCompat
? withBundledProviderVitestCompat({
config: activation.config,
pluginIds: bundledProviderCompatPluginIds,
pluginIds: activation.compatPluginIds,
env,
})
: activation.config;

View File

@@ -16,7 +16,7 @@ export function resolveBundledProviderCompatPluginIds(params: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
onlyPluginIds?: string[];
onlyPluginIds?: readonly string[];
}): string[] {
const onlyPluginIdSet = params.onlyPluginIds ? new Set(params.onlyPluginIds) : null;
const registry = loadPluginManifestRegistry({
@@ -39,7 +39,7 @@ export function resolveEnabledProviderPluginIds(params: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
onlyPluginIds?: string[];
onlyPluginIds?: readonly string[];
}): string[] {
const onlyPluginIdSet = params.onlyPluginIds ? new Set(params.onlyPluginIds) : null;
const registry = loadPluginManifestRegistry({

View File

@@ -1,4 +1,4 @@
import { resolvePluginActivationInputs } from "./activation-context.js";
import { resolveBundledPluginCompatibleActivationInputs } from "./activation-context.js";
import { resolveBundledWebFetchPluginIds } from "./bundled-web-fetch.js";
import { type NormalizedPluginsConfig } from "./config-state.js";
import type { PluginLoadOptions } from "./loader.js";
@@ -53,26 +53,17 @@ export function resolveBundledWebFetchResolutionConfig(params: {
activationSourceConfig?: PluginLoadOptions["config"];
autoEnabledReasons: Record<string, string[]>;
} {
const autoEnabled = resolvePluginActivationInputs({
const activation = resolveBundledPluginCompatibleActivationInputs({
rawConfig: params.config,
env: params.env,
applyAutoEnable: true,
});
const bundledCompatPluginIds = resolveBundledWebFetchCompatPluginIds({
config: autoEnabled.config,
workspaceDir: params.workspaceDir,
env: params.env,
});
const activation = resolvePluginActivationInputs({
rawConfig: params.config,
resolvedConfig: autoEnabled.config,
autoEnabledReasons: autoEnabled.autoEnabledReasons,
env: params.env,
compat: {
allowlistPluginIds: params.bundledAllowlistCompat ? bundledCompatPluginIds : undefined,
enablementPluginIds: bundledCompatPluginIds,
vitestPluginIds: bundledCompatPluginIds,
applyAutoEnable: true,
compatMode: {
allowlist: params.bundledAllowlistCompat,
enablement: "always",
vitest: true,
},
resolveCompatPluginIds: resolveBundledWebFetchCompatPluginIds,
});
return {

View File

@@ -1,4 +1,4 @@
import { resolvePluginActivationInputs } from "./activation-context.js";
import { resolveBundledPluginCompatibleActivationInputs } from "./activation-context.js";
import { resolveBundledWebSearchPluginIds } from "./bundled-web-search.js";
import { type NormalizedPluginsConfig } from "./config-state.js";
import type { PluginLoadOptions } from "./loader.js";
@@ -53,26 +53,17 @@ export function resolveBundledWebSearchResolutionConfig(params: {
activationSourceConfig?: PluginLoadOptions["config"];
autoEnabledReasons: Record<string, string[]>;
} {
const autoEnabled = resolvePluginActivationInputs({
const activation = resolveBundledPluginCompatibleActivationInputs({
rawConfig: params.config,
env: params.env,
applyAutoEnable: true,
});
const bundledCompatPluginIds = resolveBundledWebSearchCompatPluginIds({
config: autoEnabled.config,
workspaceDir: params.workspaceDir,
env: params.env,
});
const activation = resolvePluginActivationInputs({
rawConfig: params.config,
resolvedConfig: autoEnabled.config,
autoEnabledReasons: autoEnabled.autoEnabledReasons,
env: params.env,
compat: {
allowlistPluginIds: params.bundledAllowlistCompat ? bundledCompatPluginIds : undefined,
enablementPluginIds: bundledCompatPluginIds,
vitestPluginIds: bundledCompatPluginIds,
applyAutoEnable: true,
compatMode: {
allowlist: params.bundledAllowlistCompat,
enablement: "always",
vitest: true,
},
resolveCompatPluginIds: resolveBundledWebSearchCompatPluginIds,
});
return {