refactor(plugins): share activation context for provider runtimes

This commit is contained in:
Peter Steinberger
2026-04-04 00:28:55 +09:00
parent c8318754b5
commit 8b6c224554
6 changed files with 312 additions and 154 deletions

View File

@@ -26,6 +26,8 @@ type PluginEnableChange = {
reason: string;
};
export type PluginAutoEnableCandidate = PluginEnableChange;
export type PluginAutoEnableResult = {
config: OpenClawConfig;
changes: string[];
@@ -457,8 +459,8 @@ function resolveConfiguredPlugins(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv,
registry: PluginManifestRegistry,
): PluginEnableChange[] {
const changes: PluginEnableChange[] = [];
): PluginAutoEnableCandidate[] {
const changes: PluginAutoEnableCandidate[] = [];
// Build reverse map: channel ID → plugin ID from installed plugin manifests.
const channelToPluginId = buildChannelToPluginIdMap(registry);
for (const channelId of collectCandidateChannelIds(cfg, env)) {
@@ -644,38 +646,45 @@ function formatAutoEnableChange(entry: PluginEnableChange): string {
return `${reason}, enabled automatically.`;
}
export function applyPluginAutoEnable(params: {
export function detectPluginAutoEnableCandidates(params: {
config?: OpenClawConfig;
env?: NodeJS.ProcessEnv;
/** Pre-loaded manifest registry. When omitted, the registry is loaded from
* the installed plugins on disk. Pass an explicit registry in tests to
* avoid filesystem access and control what plugins are "installed". */
manifestRegistry?: PluginManifestRegistry;
}): PluginAutoEnableResult {
}): PluginAutoEnableCandidate[] {
const env = params.env ?? process.env;
const config = params.config ?? ({} as OpenClawConfig);
if (!configMayNeedPluginAutoEnable(config, env)) {
return { config, changes: [], autoEnabledReasons: {} };
return [];
}
const registry =
params.manifestRegistry ??
(configMayNeedPluginManifestRegistry(config)
? loadPluginManifestRegistry({ config, env })
: EMPTY_PLUGIN_MANIFEST_REGISTRY);
const configured = resolveConfiguredPlugins(config, env, registry);
if (configured.length === 0) {
return { config, changes: [], autoEnabledReasons: {} };
}
return resolveConfiguredPlugins(config, env, registry);
}
let next = config;
export function materializePluginAutoEnableCandidates(params: {
config?: OpenClawConfig;
candidates: readonly PluginAutoEnableCandidate[];
env?: NodeJS.ProcessEnv;
manifestRegistry?: PluginManifestRegistry;
}): PluginAutoEnableResult {
const env = params.env ?? process.env;
let next = params.config ?? {};
const changes: string[] = [];
const autoEnabledReasons = new Map<string, string[]>();
const registry =
params.manifestRegistry ??
(configMayNeedPluginManifestRegistry(next)
? loadPluginManifestRegistry({ config: next, env })
: EMPTY_PLUGIN_MANIFEST_REGISTRY);
if (next.plugins?.enabled === false) {
return { config: next, changes, autoEnabledReasons: {} };
}
for (const entry of configured) {
for (const entry of params.candidates) {
const builtInChannelId = normalizeChatChannelId(entry.pluginId);
if (isPluginDenied(next, entry.pluginId)) {
continue;
@@ -683,7 +692,7 @@ export function applyPluginAutoEnable(params: {
if (isPluginExplicitlyDisabled(next, entry.pluginId)) {
continue;
}
if (shouldSkipPreferredPluginAutoEnable(next, entry, configured, env, registry)) {
if (shouldSkipPreferredPluginAutoEnable(next, entry, [...params.candidates], env, registry)) {
continue;
}
const allow = next.plugins?.allow;
@@ -728,3 +737,20 @@ export function applyPluginAutoEnable(params: {
return { config: next, changes, autoEnabledReasons: autoEnabledReasonRecord };
}
export function applyPluginAutoEnable(params: {
config?: OpenClawConfig;
env?: NodeJS.ProcessEnv;
/** Pre-loaded manifest registry. When omitted, the registry is loaded from
* the installed plugins on disk. Pass an explicit registry in tests to
* avoid filesystem access and control what plugins are "installed". */
manifestRegistry?: PluginManifestRegistry;
}): PluginAutoEnableResult {
const candidates = detectPluginAutoEnableCandidates(params);
return materializePluginAutoEnableCandidates({
config: params.config,
candidates,
env: params.env,
manifestRegistry: params.manifestRegistry,
});
}

View File

@@ -0,0 +1,95 @@
import type { OpenClawConfig } from "../config/config.js";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
import {
withBundledPluginAllowlistCompat,
withBundledPluginEnablementCompat,
withBundledPluginVitestCompat,
} from "./bundled-compat.js";
import {
createPluginActivationSource,
normalizePluginsConfig,
type NormalizedPluginsConfig,
type PluginActivationConfigSource,
} from "./config-state.js";
export type PluginActivationCompatConfig = {
allowlistPluginIds?: readonly string[];
enablementPluginIds?: readonly string[];
vitestPluginIds?: readonly string[];
};
export type PluginActivationInputs = {
rawConfig?: OpenClawConfig;
config?: OpenClawConfig;
normalized: NormalizedPluginsConfig;
activationSourceConfig?: OpenClawConfig;
activationSource: PluginActivationConfigSource;
autoEnabledReasons: Record<string, string[]>;
};
function applyPluginActivationCompat(params: {
config?: OpenClawConfig;
compat?: PluginActivationCompatConfig;
env: NodeJS.ProcessEnv;
}): OpenClawConfig | undefined {
const allowlistCompat = params.compat?.allowlistPluginIds?.length
? withBundledPluginAllowlistCompat({
config: params.config,
pluginIds: params.compat.allowlistPluginIds,
})
: params.config;
const enablementCompat = params.compat?.enablementPluginIds?.length
? withBundledPluginEnablementCompat({
config: allowlistCompat,
pluginIds: params.compat.enablementPluginIds,
})
: allowlistCompat;
const vitestCompat = params.compat?.vitestPluginIds?.length
? withBundledPluginVitestCompat({
config: enablementCompat,
pluginIds: params.compat.vitestPluginIds,
env: params.env,
})
: enablementCompat;
return vitestCompat;
}
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 rawConfig = params.rawConfig ?? params.resolvedConfig;
let resolvedConfig = params.resolvedConfig ?? params.rawConfig;
let autoEnabledReasons = params.autoEnabledReasons;
if (params.applyAutoEnable && rawConfig !== undefined) {
const autoEnabled = applyPluginAutoEnable({
config: rawConfig,
env,
});
resolvedConfig = autoEnabled.config;
autoEnabledReasons = autoEnabled.autoEnabledReasons;
}
const config = applyPluginActivationCompat({
config: resolvedConfig,
compat: params.compat,
env,
});
return {
rawConfig,
config,
normalized: normalizePluginsConfig(config?.plugins),
activationSourceConfig: rawConfig,
activationSource: createPluginActivationSource({
config: rawConfig,
}),
autoEnabledReasons: autoEnabledReasons ?? {},
};
}

View File

@@ -9,6 +9,24 @@ import type { PluginKind, PluginOrigin } from "./types.js";
export type PluginActivationSource = "disabled" | "explicit" | "auto" | "default";
export type PluginExplicitSelectionCause =
| "enabled-in-config"
| "bundled-channel-enabled-in-config"
| "selected-memory-slot"
| "selected-in-allowlist";
export type PluginActivationCause =
| PluginExplicitSelectionCause
| "plugins-disabled"
| "blocked-by-denylist"
| "disabled-in-config"
| "workspace-disabled-by-default"
| "not-in-allowlist"
| "enabled-by-effective-config"
| "bundled-channel-configured"
| "bundled-default-enablement"
| "bundled-disabled-by-default";
export type PluginActivationState = {
enabled: boolean;
activated: boolean;
@@ -17,6 +35,15 @@ export type PluginActivationState = {
reason?: string;
};
type PluginActivationDecision = {
enabled: boolean;
activated: boolean;
explicitlyEnabled: boolean;
source: PluginActivationSource;
cause?: PluginActivationCause;
reason?: string;
};
export type PluginActivationConfigSource = {
plugins: NormalizedPluginsConfig;
rootConfig?: OpenClawConfig;
@@ -79,6 +106,42 @@ const normalizeSlotValue = (value: unknown): string | null | undefined => {
return trimmed;
};
const PLUGIN_ACTIVATION_REASON_BY_CAUSE: Record<PluginActivationCause, string> = {
"enabled-in-config": "enabled in config",
"bundled-channel-enabled-in-config": "channel enabled in config",
"selected-memory-slot": "selected memory slot",
"selected-in-allowlist": "selected in allowlist",
"plugins-disabled": "plugins disabled",
"blocked-by-denylist": "blocked by denylist",
"disabled-in-config": "disabled in config",
"workspace-disabled-by-default": "workspace plugin (disabled by default)",
"not-in-allowlist": "not in allowlist",
"enabled-by-effective-config": "enabled by effective config",
"bundled-channel-configured": "channel configured",
"bundled-default-enablement": "bundled default enablement",
"bundled-disabled-by-default": "bundled (disabled by default)",
};
function resolvePluginActivationReason(
cause?: PluginActivationCause,
reason?: string,
): string | undefined {
if (reason) {
return reason;
}
return cause ? PLUGIN_ACTIVATION_REASON_BY_CAUSE[cause] : undefined;
}
function toPluginActivationState(decision: PluginActivationDecision): PluginActivationState {
return {
enabled: decision.enabled,
activated: decision.activated,
explicitlyEnabled: decision.explicitlyEnabled,
source: decision.source,
reason: resolvePluginActivationReason(decision.cause, decision.reason),
};
}
const normalizePluginEntries = (entries: unknown): NormalizedPluginsConfig["entries"] => {
if (!entries || typeof entries !== "object" || Array.isArray(entries)) {
return {};
@@ -265,21 +328,21 @@ function resolveExplicitPluginSelection(params: {
origin: PluginOrigin;
config: NormalizedPluginsConfig;
rootConfig?: OpenClawConfig;
}): { explicitlyEnabled: boolean; reason?: string } {
}): { explicitlyEnabled: boolean; cause?: PluginExplicitSelectionCause } {
if (params.config.entries[params.id]?.enabled === true) {
return { explicitlyEnabled: true, reason: "enabled in config" };
return { explicitlyEnabled: true, cause: "enabled-in-config" };
}
if (
params.origin === "bundled" &&
isBundledChannelEnabledByChannelConfig(params.rootConfig, params.id)
) {
return { explicitlyEnabled: true, reason: "channel enabled in config" };
return { explicitlyEnabled: true, cause: "bundled-channel-enabled-in-config" };
}
if (params.config.slots.memory === params.id) {
return { explicitlyEnabled: true, reason: "selected memory slot" };
return { explicitlyEnabled: true, cause: "selected-memory-slot" };
}
if (params.origin !== "bundled" && params.config.allow.includes(params.id)) {
return { explicitlyEnabled: true, reason: "selected in allowlist" };
return { explicitlyEnabled: true, cause: "selected-in-allowlist" };
}
return { explicitlyEnabled: false };
}
@@ -308,127 +371,127 @@ export function resolvePluginActivationState(params: {
const explicitlyConfiguredBundledChannel =
params.origin === "bundled" &&
explicitSelection.explicitlyEnabled &&
explicitSelection.reason === "channel enabled in config";
explicitSelection.cause === "bundled-channel-enabled-in-config";
if (!params.config.enabled) {
return {
return toPluginActivationState({
enabled: false,
activated: false,
explicitlyEnabled: explicitSelection.explicitlyEnabled,
source: "disabled",
reason: "plugins disabled",
};
cause: "plugins-disabled",
});
}
if (params.config.deny.includes(params.id)) {
return {
return toPluginActivationState({
enabled: false,
activated: false,
explicitlyEnabled: explicitSelection.explicitlyEnabled,
source: "disabled",
reason: "blocked by denylist",
};
cause: "blocked-by-denylist",
});
}
const entry = params.config.entries[params.id];
if (entry?.enabled === false) {
return {
return toPluginActivationState({
enabled: false,
activated: false,
explicitlyEnabled: explicitSelection.explicitlyEnabled,
source: "disabled",
reason: "disabled in config",
};
cause: "disabled-in-config",
});
}
const explicitlyAllowed = params.config.allow.includes(params.id);
if (params.origin === "workspace" && !explicitlyAllowed && entry?.enabled !== true) {
return {
return toPluginActivationState({
enabled: false,
activated: false,
explicitlyEnabled: explicitSelection.explicitlyEnabled,
source: "disabled",
reason: "workspace plugin (disabled by default)",
};
cause: "workspace-disabled-by-default",
});
}
if (params.config.slots.memory === params.id) {
return {
return toPluginActivationState({
enabled: true,
activated: true,
explicitlyEnabled: true,
source: "explicit",
reason: "selected memory slot",
};
cause: "selected-memory-slot",
});
}
if (params.config.allow.length > 0 && !explicitlyAllowed && !explicitlyConfiguredBundledChannel) {
return {
return toPluginActivationState({
enabled: false,
activated: false,
explicitlyEnabled: explicitSelection.explicitlyEnabled,
source: "disabled",
reason: "not in allowlist",
};
cause: "not-in-allowlist",
});
}
if (explicitSelection.explicitlyEnabled) {
return {
return toPluginActivationState({
enabled: true,
activated: true,
explicitlyEnabled: true,
source: "explicit",
reason: explicitSelection.reason,
};
cause: explicitSelection.cause,
});
}
if (params.autoEnabledReason) {
return {
return toPluginActivationState({
enabled: true,
activated: true,
explicitlyEnabled: false,
source: "auto",
reason: params.autoEnabledReason,
};
});
}
if (entry?.enabled === true) {
return {
return toPluginActivationState({
enabled: true,
activated: true,
explicitlyEnabled: false,
source: "auto",
reason: "enabled by effective config",
};
cause: "enabled-by-effective-config",
});
}
if (
params.origin === "bundled" &&
isBundledChannelEnabledByChannelConfig(params.rootConfig, params.id)
) {
return {
return toPluginActivationState({
enabled: true,
activated: true,
explicitlyEnabled: false,
source: "auto",
reason: "channel configured",
};
cause: "bundled-channel-configured",
});
}
if (params.origin === "bundled" && params.enabledByDefault === true) {
return {
return toPluginActivationState({
enabled: true,
activated: true,
explicitlyEnabled: false,
source: "default",
reason: "bundled default enablement",
};
cause: "bundled-default-enablement",
});
}
if (params.origin === "bundled") {
return {
return toPluginActivationState({
enabled: false,
activated: false,
explicitlyEnabled: false,
source: "disabled",
reason: "bundled (disabled by default)",
};
cause: "bundled-disabled-by-default",
});
}
return {
return toPluginActivationState({
enabled: true,
activated: true,
explicitlyEnabled: explicitSelection.explicitlyEnabled,
source: "default",
};
});
}
export function resolveEnableState(

View File

@@ -1,9 +1,5 @@
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import {
withBundledPluginAllowlistCompat,
withBundledPluginEnablementCompat,
} from "./bundled-compat.js";
import { resolvePluginActivationInputs } from "./activation-context.js";
import { resolveRuntimePluginRegistry, type PluginLoadOptions } from "./loader.js";
import { createPluginLoaderLogger } from "./logger.js";
import {
@@ -28,42 +24,44 @@ export function resolvePluginProviders(params: {
pluginSdkResolution?: PluginLoadOptions["pluginSdkResolution"];
}): ProviderPlugin[] {
const env = params.env ?? process.env;
const autoEnabled =
params.config !== undefined
? applyPluginAutoEnable({
config: params.config,
env,
})
: undefined;
const autoEnabledConfig = autoEnabled?.config;
const autoEnabled = resolvePluginActivationInputs({
rawConfig: params.config,
env,
applyAutoEnable: true,
});
const bundledProviderCompatPluginIds =
params.bundledProviderAllowlistCompat || params.bundledProviderVitestCompat
? resolveBundledProviderCompatPluginIds({
config: autoEnabledConfig,
config: autoEnabled.config,
workspaceDir: params.workspaceDir,
env,
onlyPluginIds: params.onlyPluginIds,
})
: [];
const maybeAllowlistCompat = params.bundledProviderAllowlistCompat
? withBundledPluginAllowlistCompat({
config: autoEnabledConfig,
pluginIds: bundledProviderCompatPluginIds,
})
: autoEnabledConfig;
const allowlistCompatConfig = params.bundledProviderAllowlistCompat
? withBundledPluginEnablementCompat({
config: maybeAllowlistCompat,
pluginIds: bundledProviderCompatPluginIds,
})
: maybeAllowlistCompat;
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,
},
});
const config = params.bundledProviderVitestCompat
? withBundledProviderVitestCompat({
config: allowlistCompatConfig,
config: activation.config,
pluginIds: bundledProviderCompatPluginIds,
env,
})
: allowlistCompatConfig;
: activation.config;
const providerPluginIds = resolveEnabledProviderPluginIds({
config,
workspaceDir: params.workspaceDir,
@@ -72,8 +70,8 @@ export function resolvePluginProviders(params: {
});
const registry = resolveRuntimePluginRegistry({
config,
activationSourceConfig: params.config,
autoEnabledReasons: autoEnabled?.autoEnabledReasons,
activationSourceConfig: activation.activationSourceConfig,
autoEnabledReasons: activation.autoEnabledReasons,
workspaceDir: params.workspaceDir,
env,
onlyPluginIds: providerPluginIds,

View File

@@ -1,11 +1,6 @@
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
import {
withBundledPluginAllowlistCompat,
withBundledPluginEnablementCompat,
withBundledPluginVitestCompat,
} from "./bundled-compat.js";
import { resolvePluginActivationInputs } from "./activation-context.js";
import { resolveBundledWebFetchPluginIds } from "./bundled-web-fetch.js";
import { normalizePluginsConfig, type NormalizedPluginsConfig } from "./config-state.js";
import { type NormalizedPluginsConfig } from "./config-state.js";
import type { PluginLoadOptions } from "./loader.js";
import type { PluginWebFetchProviderEntry } from "./types.js";
@@ -58,39 +53,32 @@ export function resolveBundledWebFetchResolutionConfig(params: {
activationSourceConfig?: PluginLoadOptions["config"];
autoEnabledReasons: Record<string, string[]>;
} {
const autoEnabled =
params.config !== undefined
? applyPluginAutoEnable({
config: params.config,
env: params.env ?? process.env,
})
: undefined;
const autoEnabledConfig = autoEnabled?.config;
const autoEnabled = resolvePluginActivationInputs({
rawConfig: params.config,
env: params.env,
applyAutoEnable: true,
});
const bundledCompatPluginIds = resolveBundledWebFetchCompatPluginIds({
config: autoEnabledConfig,
config: autoEnabled.config,
workspaceDir: params.workspaceDir,
env: params.env,
});
const allowlistCompat = params.bundledAllowlistCompat
? withBundledPluginAllowlistCompat({
config: autoEnabledConfig,
pluginIds: bundledCompatPluginIds,
})
: autoEnabledConfig;
const enablementCompat = withBundledPluginEnablementCompat({
config: allowlistCompat,
pluginIds: bundledCompatPluginIds,
});
const config = withBundledPluginVitestCompat({
config: enablementCompat,
pluginIds: bundledCompatPluginIds,
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,
},
});
return {
config,
normalized: normalizePluginsConfig(config?.plugins),
activationSourceConfig: params.config,
autoEnabledReasons: autoEnabled?.autoEnabledReasons ?? {},
config: activation.config,
normalized: activation.normalized,
activationSourceConfig: activation.activationSourceConfig,
autoEnabledReasons: activation.autoEnabledReasons,
};
}

View File

@@ -1,11 +1,6 @@
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
import {
withBundledPluginAllowlistCompat,
withBundledPluginEnablementCompat,
withBundledPluginVitestCompat,
} from "./bundled-compat.js";
import { resolvePluginActivationInputs } from "./activation-context.js";
import { resolveBundledWebSearchPluginIds } from "./bundled-web-search.js";
import { normalizePluginsConfig, type NormalizedPluginsConfig } from "./config-state.js";
import { type NormalizedPluginsConfig } from "./config-state.js";
import type { PluginLoadOptions } from "./loader.js";
import type { PluginWebSearchProviderEntry } from "./types.js";
@@ -58,39 +53,32 @@ export function resolveBundledWebSearchResolutionConfig(params: {
activationSourceConfig?: PluginLoadOptions["config"];
autoEnabledReasons: Record<string, string[]>;
} {
const autoEnabled =
params.config !== undefined
? applyPluginAutoEnable({
config: params.config,
env: params.env ?? process.env,
})
: undefined;
const autoEnabledConfig = autoEnabled?.config;
const autoEnabled = resolvePluginActivationInputs({
rawConfig: params.config,
env: params.env,
applyAutoEnable: true,
});
const bundledCompatPluginIds = resolveBundledWebSearchCompatPluginIds({
config: autoEnabledConfig,
config: autoEnabled.config,
workspaceDir: params.workspaceDir,
env: params.env,
});
const allowlistCompat = params.bundledAllowlistCompat
? withBundledPluginAllowlistCompat({
config: autoEnabledConfig,
pluginIds: bundledCompatPluginIds,
})
: autoEnabledConfig;
const enablementCompat = withBundledPluginEnablementCompat({
config: allowlistCompat,
pluginIds: bundledCompatPluginIds,
});
const config = withBundledPluginVitestCompat({
config: enablementCompat,
pluginIds: bundledCompatPluginIds,
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,
},
});
return {
config,
normalized: normalizePluginsConfig(config?.plugins),
activationSourceConfig: params.config,
autoEnabledReasons: autoEnabled?.autoEnabledReasons ?? {},
config: activation.config,
normalized: activation.normalized,
activationSourceConfig: activation.activationSourceConfig,
autoEnabledReasons: activation.autoEnabledReasons,
};
}