Files
openclaw/src/config/plugin-auto-enable.shared.ts
2026-04-06 12:08:44 +01:00

626 lines
19 KiB
TypeScript

import { normalizeProviderId } from "../agents/model-selection.js";
import {
hasPotentialConfiguredChannels,
listPotentialConfiguredChannelIds,
} from "../channels/config-presence.js";
import { getChatChannelMeta, normalizeChatChannelId } from "../channels/registry.js";
import {
loadPluginManifestRegistry,
resolveManifestContractOwnerPluginId,
type PluginManifestRecord,
type PluginManifestRegistry,
} from "../plugins/manifest-registry.js";
import { resolveOwningPluginIdsForModelRef } from "../plugins/providers.js";
import { resolvePluginSetupAutoEnableReasons } from "../plugins/setup-registry.js";
import { isRecord } from "../utils.js";
import { isChannelConfigured } from "./channel-configured.js";
import type { OpenClawConfig } from "./config.js";
import { shouldSkipPreferredPluginAutoEnable } from "./plugin-auto-enable.prefer-over.js";
import { ensurePluginAllowlisted } from "./plugins-allowlist.js";
import { isBlockedObjectKey } from "./prototype-keys.js";
export type PluginAutoEnableCandidate =
| {
pluginId: string;
kind: "channel-configured";
channelId: string;
}
| {
pluginId: string;
kind: "provider-auth-configured";
providerId: string;
}
| {
pluginId: string;
kind: "provider-model-configured";
modelRef: 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: string;
kind: "setup-auto-enable";
reason: string;
};
export type PluginAutoEnableResult = {
config: OpenClawConfig;
changes: string[];
autoEnabledReasons: Record<string, string[]>;
};
const EMPTY_PLUGIN_MANIFEST_REGISTRY: PluginManifestRegistry = {
plugins: [],
diagnostics: [],
};
function resolveAutoEnableProviderPluginIds(
registry: PluginManifestRegistry,
): Readonly<Record<string, string>> {
const entries = new Map<string, string>();
for (const plugin of registry.plugins) {
for (const providerId of plugin.autoEnableWhenConfiguredProviders ?? []) {
if (!entries.has(providerId)) {
entries.set(providerId, plugin.id);
}
}
}
return Object.fromEntries(entries);
}
function collectModelRefs(cfg: OpenClawConfig): string[] {
const refs: string[] = [];
const pushModelRef = (value: unknown) => {
if (typeof value === "string" && value.trim()) {
refs.push(value.trim());
}
};
const collectFromAgent = (agent: Record<string, unknown> | null | undefined) => {
if (!agent) {
return;
}
const model = agent.model;
if (typeof model === "string") {
pushModelRef(model);
} else if (isRecord(model)) {
pushModelRef(model.primary);
const fallbacks = model.fallbacks;
if (Array.isArray(fallbacks)) {
for (const entry of fallbacks) {
pushModelRef(entry);
}
}
}
const models = agent.models;
if (isRecord(models)) {
for (const key of Object.keys(models)) {
pushModelRef(key);
}
}
};
collectFromAgent(cfg.agents?.defaults as Record<string, unknown> | undefined);
const list = cfg.agents?.list;
if (Array.isArray(list)) {
for (const entry of list) {
if (isRecord(entry)) {
collectFromAgent(entry);
}
}
}
return refs;
}
function extractProviderFromModelRef(value: string): string | null {
const trimmed = value.trim();
const slash = trimmed.indexOf("/");
if (slash <= 0) {
return null;
}
return normalizeProviderId(trimmed.slice(0, slash));
}
function isProviderConfigured(cfg: OpenClawConfig, providerId: string): boolean {
const normalized = normalizeProviderId(providerId);
const profiles = cfg.auth?.profiles;
if (profiles && typeof profiles === "object") {
for (const profile of Object.values(profiles)) {
if (!isRecord(profile)) {
continue;
}
const provider = normalizeProviderId(String(profile.provider ?? ""));
if (provider === normalized) {
return true;
}
}
}
const providerConfig = cfg.models?.providers;
if (providerConfig && typeof providerConfig === "object") {
for (const key of Object.keys(providerConfig)) {
if (normalizeProviderId(key) === normalized) {
return true;
}
}
}
for (const ref of collectModelRefs(cfg)) {
const provider = extractProviderFromModelRef(ref);
if (provider && provider === normalized) {
return true;
}
}
return false;
}
function hasPluginOwnedWebSearchConfig(cfg: OpenClawConfig, pluginId: string): boolean {
const pluginConfig = cfg.plugins?.entries?.[pluginId]?.config;
return isRecord(pluginConfig) && isRecord(pluginConfig.webSearch);
}
function hasPluginOwnedWebFetchConfig(cfg: OpenClawConfig, pluginId: string): boolean {
const pluginConfig = cfg.plugins?.entries?.[pluginId]?.config;
return isRecord(pluginConfig) && isRecord(pluginConfig.webFetch);
}
function resolvePluginOwnedToolConfigKeys(plugin: PluginManifestRecord): string[] {
if ((plugin.contracts?.tools?.length ?? 0) === 0) {
return [];
}
const properties = isRecord(plugin.configSchema) ? plugin.configSchema.properties : undefined;
if (!isRecord(properties)) {
return [];
}
return Object.keys(properties).filter((key) => key !== "webSearch" && key !== "webFetch");
}
function hasPluginOwnedToolConfig(cfg: OpenClawConfig, plugin: PluginManifestRecord): boolean {
const pluginConfig = cfg.plugins?.entries?.[plugin.id]?.config;
if (!isRecord(pluginConfig)) {
return false;
}
return resolvePluginOwnedToolConfigKeys(plugin).some((key) => pluginConfig[key] !== undefined);
}
function resolveProviderPluginsWithOwnedWebSearch(
registry: PluginManifestRegistry,
): PluginManifestRecord[] {
return registry.plugins
.filter((plugin) => plugin.providers.length > 0)
.filter((plugin) => (plugin.contracts?.webSearchProviders?.length ?? 0) > 0);
}
function resolveProviderPluginsWithOwnedWebFetch(
registry: PluginManifestRegistry,
): PluginManifestRecord[] {
return registry.plugins.filter(
(plugin) => (plugin.contracts?.webFetchProviders?.length ?? 0) > 0,
);
}
function resolvePluginsWithOwnedToolConfig(
registry: PluginManifestRegistry,
): PluginManifestRecord[] {
return registry.plugins.filter((plugin) => (plugin.contracts?.tools?.length ?? 0) > 0);
}
function resolvePluginIdForConfiguredWebFetchProvider(
providerId: string | undefined,
env: NodeJS.ProcessEnv,
): string | undefined {
return resolveManifestContractOwnerPluginId({
contract: "webFetchProviders",
value: typeof providerId === "string" ? providerId.trim().toLowerCase() : "",
origin: "bundled",
env,
});
}
function buildChannelToPluginIdMap(registry: PluginManifestRegistry): Map<string, string> {
const map = new Map<string, string>();
for (const record of registry.plugins) {
for (const channelId of record.channels) {
if (channelId && !map.has(channelId)) {
map.set(channelId, record.id);
}
}
}
return map;
}
function resolvePluginIdForChannel(
channelId: string,
channelToPluginId: ReadonlyMap<string, string>,
): string {
const builtInId = normalizeChatChannelId(channelId);
if (builtInId) {
return builtInId;
}
return channelToPluginId.get(channelId) ?? channelId;
}
function collectCandidateChannelIds(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): string[] {
return listPotentialConfiguredChannelIds(cfg, env).map(
(channelId) => normalizeChatChannelId(channelId) ?? channelId,
);
}
function hasConfiguredWebSearchPluginEntry(cfg: OpenClawConfig): boolean {
const entries = cfg.plugins?.entries;
return (
!!entries &&
typeof entries === "object" &&
Object.values(entries).some(
(entry) => isRecord(entry) && isRecord(entry.config) && isRecord(entry.config.webSearch),
)
);
}
function hasConfiguredWebFetchPluginEntry(cfg: OpenClawConfig): boolean {
const entries = cfg.plugins?.entries;
return (
!!entries &&
typeof entries === "object" &&
Object.values(entries).some(
(entry) => isRecord(entry) && isRecord(entry.config) && isRecord(entry.config.webFetch),
)
);
}
function hasConfiguredPluginConfigEntry(cfg: OpenClawConfig): boolean {
const entries = cfg.plugins?.entries;
return (
!!entries &&
typeof entries === "object" &&
Object.values(entries).some((entry) => isRecord(entry) && isRecord(entry.config))
);
}
function configMayNeedPluginManifestRegistry(cfg: OpenClawConfig): boolean {
const pluginEntries = cfg.plugins?.entries;
if (
pluginEntries &&
Object.values(pluginEntries).some((entry) => isRecord(entry) && isRecord(entry.config))
) {
return true;
}
if (cfg.auth?.profiles && Object.keys(cfg.auth.profiles).length > 0) {
return true;
}
if (cfg.models?.providers && Object.keys(cfg.models.providers).length > 0) {
return true;
}
if (collectModelRefs(cfg).length > 0) {
return true;
}
const configuredChannels = cfg.channels as Record<string, unknown> | undefined;
if (!configuredChannels || typeof configuredChannels !== "object") {
return false;
}
for (const key of Object.keys(configuredChannels)) {
if (key === "defaults" || key === "modelByChannel") {
continue;
}
if (!normalizeChatChannelId(key)) {
return true;
}
}
return false;
}
export function configMayNeedPluginAutoEnable(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv,
): boolean {
if (hasConfiguredPluginConfigEntry(cfg)) {
return true;
}
if (hasPotentialConfiguredChannels(cfg, env)) {
return true;
}
if (cfg.auth?.profiles && Object.keys(cfg.auth.profiles).length > 0) {
return true;
}
if (cfg.models?.providers && Object.keys(cfg.models.providers).length > 0) {
return true;
}
if (collectModelRefs(cfg).length > 0) {
return true;
}
if (hasConfiguredWebSearchPluginEntry(cfg) || hasConfiguredWebFetchPluginEntry(cfg)) {
return true;
}
return (
resolvePluginSetupAutoEnableReasons({
config: cfg,
env,
}).length > 0
);
}
export function resolvePluginAutoEnableCandidateReason(
candidate: PluginAutoEnableCandidate,
): string {
switch (candidate.kind) {
case "channel-configured":
return `${candidate.channelId} configured`;
case "provider-auth-configured":
return `${candidate.providerId} auth configured`;
case "provider-model-configured":
return `${candidate.modelRef} model 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 "setup-auto-enable":
return candidate.reason;
}
}
export function resolveConfiguredPluginAutoEnableCandidates(params: {
config: OpenClawConfig;
env: NodeJS.ProcessEnv;
registry: PluginManifestRegistry;
}): PluginAutoEnableCandidate[] {
const changes: PluginAutoEnableCandidate[] = [];
const channelToPluginId = buildChannelToPluginIdMap(params.registry);
for (const channelId of collectCandidateChannelIds(params.config, params.env)) {
const pluginId = resolvePluginIdForChannel(channelId, channelToPluginId);
if (isChannelConfigured(params.config, channelId, params.env)) {
changes.push({ pluginId, kind: "channel-configured", channelId });
}
}
for (const [providerId, pluginId] of Object.entries(
resolveAutoEnableProviderPluginIds(params.registry),
)) {
if (isProviderConfigured(params.config, providerId)) {
changes.push({ pluginId, kind: "provider-auth-configured", providerId });
}
}
for (const modelRef of collectModelRefs(params.config)) {
const owningPluginIds = resolveOwningPluginIdsForModelRef({
model: modelRef,
config: params.config,
env: params.env,
manifestRegistry: params.registry,
});
if (owningPluginIds?.length === 1) {
changes.push({
pluginId: owningPluginIds[0],
kind: "provider-model-configured",
modelRef,
});
}
}
const webFetchProvider =
typeof params.config.tools?.web?.fetch?.provider === "string"
? params.config.tools.web.fetch.provider
: undefined;
const webFetchPluginId = resolvePluginIdForConfiguredWebFetchProvider(
webFetchProvider,
params.env,
);
if (webFetchPluginId) {
changes.push({
pluginId: webFetchPluginId,
kind: "web-fetch-provider-selected",
providerId: String(webFetchProvider).trim().toLowerCase(),
});
}
for (const plugin of resolveProviderPluginsWithOwnedWebSearch(params.registry)) {
const pluginId = plugin.id;
if (hasPluginOwnedWebSearchConfig(params.config, pluginId)) {
changes.push({ pluginId, kind: "plugin-web-search-configured" });
}
}
for (const plugin of resolvePluginsWithOwnedToolConfig(params.registry)) {
const pluginId = plugin.id;
if (hasPluginOwnedToolConfig(params.config, plugin)) {
changes.push({ pluginId, kind: "plugin-tool-configured" });
}
}
for (const plugin of resolveProviderPluginsWithOwnedWebFetch(params.registry)) {
const pluginId = plugin.id;
if (hasPluginOwnedWebFetchConfig(params.config, pluginId)) {
changes.push({ pluginId, kind: "plugin-web-fetch-configured" });
}
}
for (const entry of resolvePluginSetupAutoEnableReasons({
config: params.config,
env: params.env,
})) {
changes.push({
pluginId: entry.pluginId,
kind: "setup-auto-enable",
reason: entry.reason,
});
}
return changes;
}
function isPluginExplicitlyDisabled(cfg: OpenClawConfig, pluginId: string): boolean {
const builtInChannelId = normalizeChatChannelId(pluginId);
if (builtInChannelId) {
const channels = cfg.channels as Record<string, unknown> | undefined;
const channelConfig = channels?.[builtInChannelId];
if (
channelConfig &&
typeof channelConfig === "object" &&
!Array.isArray(channelConfig) &&
(channelConfig as { enabled?: unknown }).enabled === false
) {
return true;
}
}
return cfg.plugins?.entries?.[pluginId]?.enabled === false;
}
function isPluginDenied(cfg: OpenClawConfig, pluginId: string): boolean {
const deny = cfg.plugins?.deny;
return Array.isArray(deny) && deny.includes(pluginId);
}
function isBuiltInChannelAlreadyEnabled(cfg: OpenClawConfig, channelId: string): boolean {
const channels = cfg.channels as Record<string, unknown> | undefined;
const channelConfig = channels?.[channelId];
return (
!!channelConfig &&
typeof channelConfig === "object" &&
!Array.isArray(channelConfig) &&
(channelConfig as { enabled?: unknown }).enabled === true
);
}
function registerPluginEntry(cfg: OpenClawConfig, pluginId: string): OpenClawConfig {
const builtInChannelId = normalizeChatChannelId(pluginId);
if (builtInChannelId) {
const channels = cfg.channels as Record<string, unknown> | undefined;
const existing = channels?.[builtInChannelId];
const existingRecord =
existing && typeof existing === "object" && !Array.isArray(existing)
? (existing as Record<string, unknown>)
: {};
return {
...cfg,
channels: {
...cfg.channels,
[builtInChannelId]: {
...existingRecord,
enabled: true,
},
},
};
}
return {
...cfg,
plugins: {
...cfg.plugins,
entries: {
...cfg.plugins?.entries,
[pluginId]: {
...(cfg.plugins?.entries?.[pluginId] as Record<string, unknown> | undefined),
enabled: true,
},
},
},
};
}
function formatAutoEnableChange(entry: PluginAutoEnableCandidate): string {
let reason = resolvePluginAutoEnableCandidateReason(entry).trim();
const channelId = normalizeChatChannelId(entry.pluginId);
if (channelId) {
const label = getChatChannelMeta(channelId).label;
reason = reason.replace(new RegExp(`^${channelId}\\b`, "i"), label);
}
return `${reason}, enabled automatically.`;
}
export function resolvePluginAutoEnableManifestRegistry(params: {
config: OpenClawConfig;
env: NodeJS.ProcessEnv;
manifestRegistry?: PluginManifestRegistry;
}): PluginManifestRegistry {
return (
params.manifestRegistry ??
(configMayNeedPluginManifestRegistry(params.config)
? loadPluginManifestRegistry({ config: params.config, env: params.env })
: EMPTY_PLUGIN_MANIFEST_REGISTRY)
);
}
export function materializePluginAutoEnableCandidatesInternal(params: {
config?: OpenClawConfig;
candidates: readonly PluginAutoEnableCandidate[];
env: NodeJS.ProcessEnv;
manifestRegistry: PluginManifestRegistry;
}): PluginAutoEnableResult {
let next = params.config ?? {};
const changes: string[] = [];
const autoEnabledReasons = new Map<string, string[]>();
if (next.plugins?.enabled === false) {
return { config: next, changes, autoEnabledReasons: {} };
}
for (const entry of params.candidates) {
const builtInChannelId = normalizeChatChannelId(entry.pluginId);
if (isPluginDenied(next, entry.pluginId) || isPluginExplicitlyDisabled(next, entry.pluginId)) {
continue;
}
if (
shouldSkipPreferredPluginAutoEnable({
config: next,
entry,
configured: params.candidates,
env: params.env,
registry: params.manifestRegistry,
isPluginDenied,
isPluginExplicitlyDisabled,
})
) {
continue;
}
const allow = next.plugins?.allow;
const allowMissing =
builtInChannelId == null && Array.isArray(allow) && !allow.includes(entry.pluginId);
const alreadyEnabled =
builtInChannelId != null
? isBuiltInChannelAlreadyEnabled(next, builtInChannelId)
: next.plugins?.entries?.[entry.pluginId]?.enabled === true;
if (alreadyEnabled && !allowMissing) {
continue;
}
next = registerPluginEntry(next, entry.pluginId);
if (!builtInChannelId) {
next = ensurePluginAllowlisted(next, entry.pluginId);
}
const reason = resolvePluginAutoEnableCandidateReason(entry);
autoEnabledReasons.set(entry.pluginId, [
...(autoEnabledReasons.get(entry.pluginId) ?? []),
reason,
]);
changes.push(formatAutoEnableChange(entry));
}
const autoEnabledReasonRecord: Record<string, string[]> = Object.create(null);
for (const [pluginId, reasons] of autoEnabledReasons) {
if (!isBlockedObjectKey(pluginId)) {
autoEnabledReasonRecord[pluginId] = [...reasons];
}
}
return { config: next, changes, autoEnabledReasons: autoEnabledReasonRecord };
}