refactor: simplify plugin auto-enable structure

This commit is contained in:
Peter Steinberger
2026-04-05 09:33:57 +01:00
parent 22db77d2b6
commit 1afa076cfa
13 changed files with 1894 additions and 1792 deletions

View File

@@ -0,0 +1,44 @@
import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
import type { OpenClawConfig } from "./config.js";
import { detectPluginAutoEnableCandidates } from "./plugin-auto-enable.detect.js";
import {
materializePluginAutoEnableCandidatesInternal,
resolvePluginAutoEnableManifestRegistry,
type PluginAutoEnableCandidate,
type PluginAutoEnableResult,
} from "./plugin-auto-enable.shared.js";
export function materializePluginAutoEnableCandidates(params: {
config?: OpenClawConfig;
candidates: readonly PluginAutoEnableCandidate[];
env?: NodeJS.ProcessEnv;
manifestRegistry?: PluginManifestRegistry;
}): PluginAutoEnableResult {
const env = params.env ?? process.env;
const config = params.config ?? {};
const manifestRegistry = resolvePluginAutoEnableManifestRegistry({
config,
env,
manifestRegistry: params.manifestRegistry,
});
return materializePluginAutoEnableCandidatesInternal({
config,
candidates: params.candidates,
env,
manifestRegistry,
});
}
export function applyPluginAutoEnable(params: {
config?: OpenClawConfig;
env?: NodeJS.ProcessEnv;
manifestRegistry?: PluginManifestRegistry;
}): PluginAutoEnableResult {
const candidates = detectPluginAutoEnableCandidates(params);
return materializePluginAutoEnableCandidates({
config: params.config,
candidates,
env: params.env,
manifestRegistry: params.manifestRegistry,
});
}

View File

@@ -0,0 +1,238 @@
import fs from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { applyPluginAutoEnable } from "./plugin-auto-enable.js";
import {
makeApnChannelConfig,
makeBluebubblesAndImessageChannels,
makeIsolatedEnv,
makeRegistry,
makeTempDir,
resetPluginAutoEnableTestState,
writePluginManifestFixture,
} from "./plugin-auto-enable.test-helpers.js";
function applyWithApnChannelConfig(extra?: {
plugins?: { entries?: Record<string, { enabled: boolean }> };
}) {
return applyPluginAutoEnable({
config: {
...makeApnChannelConfig(),
...(extra?.plugins ? { plugins: extra.plugins } : {}),
},
env: makeIsolatedEnv(),
manifestRegistry: makeRegistry([{ id: "apn-channel", channels: ["apn"] }]),
});
}
function applyWithBluebubblesImessageConfig(extra?: {
plugins?: { entries?: Record<string, { enabled: boolean }>; deny?: string[] };
}) {
return applyPluginAutoEnable({
config: {
channels: makeBluebubblesAndImessageChannels(),
...(extra?.plugins ? { plugins: extra.plugins } : {}),
},
env: makeIsolatedEnv(),
});
}
afterEach(() => {
resetPluginAutoEnableTestState();
});
describe("applyPluginAutoEnable channels", () => {
it("uses env-scoped catalog metadata for preferOver auto-enable decisions", () => {
const stateDir = makeTempDir();
const catalogPath = path.join(stateDir, "plugins", "catalog.json");
fs.mkdirSync(path.dirname(catalogPath), { recursive: true });
fs.writeFileSync(
catalogPath,
JSON.stringify({
entries: [
{
name: "@openclaw/env-secondary",
openclaw: {
channel: {
id: "env-secondary",
label: "Env Secondary",
selectionLabel: "Env Secondary",
docsPath: "/channels/env-secondary",
blurb: "Env secondary entry",
preferOver: ["env-primary"],
},
install: {
npmSpec: "@openclaw/env-secondary",
},
},
},
],
}),
"utf-8",
);
const result = applyPluginAutoEnable({
config: {
channels: {
"env-primary": { token: "primary" },
"env-secondary": { token: "secondary" },
},
},
env: {
...makeIsolatedEnv(),
OPENCLAW_STATE_DIR: stateDir,
},
manifestRegistry: makeRegistry([]),
});
expect(result.config.plugins?.entries?.["env-secondary"]?.enabled).toBe(true);
expect(result.config.plugins?.entries?.["env-primary"]).toBeUndefined();
});
describe("third-party channel plugins (pluginId ≠ channelId)", () => {
it("uses the plugin manifest id, not the channel id, for plugins.entries", () => {
const result = applyWithApnChannelConfig();
expect(result.config.plugins?.entries?.["apn-channel"]?.enabled).toBe(true);
expect(result.config.plugins?.entries?.apn).toBeUndefined();
expect(result.changes.join("\n")).toContain("apn configured, enabled automatically.");
});
it("does not double-enable when plugin is already enabled under its plugin id", () => {
const result = applyWithApnChannelConfig({
plugins: { entries: { "apn-channel": { enabled: true } } },
});
expect(result.changes).toEqual([]);
});
it("respects explicit disable of the plugin by its plugin id", () => {
const result = applyWithApnChannelConfig({
plugins: { entries: { "apn-channel": { enabled: false } } },
});
expect(result.config.plugins?.entries?.["apn-channel"]?.enabled).toBe(false);
expect(result.changes).toEqual([]);
});
it("falls back to channel key as plugin id when no installed manifest declares the channel", () => {
const result = applyPluginAutoEnable({
config: {
channels: { "unknown-chan": { someKey: "value" } },
},
env: makeIsolatedEnv(),
manifestRegistry: makeRegistry([]),
});
expect(result.config.plugins?.entries?.["unknown-chan"]?.enabled).toBe(true);
});
});
describe("preferOver channel prioritization", () => {
it("uses manifest channel config preferOver metadata for plugin channels", () => {
const result = applyPluginAutoEnable({
config: {
channels: {
primary: { someKey: "value" },
secondary: { someKey: "value" },
},
},
env: makeIsolatedEnv(),
manifestRegistry: makeRegistry([
{
id: "primary",
channels: ["primary"],
channelConfigs: {
primary: {
schema: { type: "object" },
preferOver: ["secondary"],
},
},
},
{ id: "secondary", channels: ["secondary"] },
]),
});
expect(result.config.plugins?.entries?.primary?.enabled).toBe(true);
expect(result.config.plugins?.entries?.secondary?.enabled).toBeUndefined();
expect(result.changes.join("\n")).toContain("primary configured, enabled automatically.");
expect(result.changes.join("\n")).not.toContain(
"secondary configured, enabled automatically.",
);
});
it("prefers bluebubbles: skips imessage auto-configure when both are configured", () => {
const result = applyWithBluebubblesImessageConfig();
expect(result.config.channels?.bluebubbles?.enabled).toBe(true);
expect(result.config.plugins?.entries?.imessage?.enabled).toBeUndefined();
expect(result.changes.join("\n")).toContain("BlueBubbles configured, enabled automatically.");
expect(result.changes.join("\n")).not.toContain(
"iMessage configured, enabled automatically.",
);
});
it("keeps imessage enabled if already explicitly enabled (non-destructive)", () => {
const result = applyWithBluebubblesImessageConfig({
plugins: { entries: { imessage: { enabled: true } } },
});
expect(result.config.channels?.bluebubbles?.enabled).toBe(true);
expect(result.config.plugins?.entries?.imessage?.enabled).toBe(true);
});
it("allows imessage auto-configure when bluebubbles is explicitly disabled", () => {
const result = applyWithBluebubblesImessageConfig({
plugins: { entries: { bluebubbles: { enabled: false } } },
});
expect(result.config.plugins?.entries?.bluebubbles?.enabled).toBe(false);
expect(result.config.channels?.imessage?.enabled).toBe(true);
expect(result.changes.join("\n")).toContain("iMessage configured, enabled automatically.");
});
it("allows imessage auto-configure when bluebubbles is in deny list", () => {
const result = applyWithBluebubblesImessageConfig({
plugins: { deny: ["bluebubbles"] },
});
expect(result.config.plugins?.entries?.bluebubbles).toBeUndefined();
expect(result.config.channels?.imessage?.enabled).toBe(true);
});
it("auto-enables imessage when only imessage is configured", () => {
const result = applyPluginAutoEnable({
config: {
channels: { imessage: { cliPath: "/usr/local/bin/imsg" } },
},
env: makeIsolatedEnv(),
});
expect(result.config.channels?.imessage?.enabled).toBe(true);
expect(result.changes.join("\n")).toContain("iMessage configured, enabled automatically.");
});
it("uses the provided env when loading installed plugin manifests", () => {
const stateDir = makeTempDir();
const pluginDir = path.join(stateDir, "extensions", "apn-channel");
writePluginManifestFixture({
rootDir: pluginDir,
id: "apn-channel",
channels: ["apn"],
});
const result = applyPluginAutoEnable({
config: makeApnChannelConfig(),
env: {
...makeIsolatedEnv(),
OPENCLAW_HOME: undefined,
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins",
},
});
expect(result.config.plugins?.entries?.["apn-channel"]?.enabled).toBe(true);
expect(result.config.plugins?.entries?.apn).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,405 @@
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
applyPluginAutoEnable,
detectPluginAutoEnableCandidates,
resolvePluginAutoEnableCandidateReason,
} from "./plugin-auto-enable.js";
import {
makeIsolatedEnv,
makeRegistry,
makeTempDir,
resetPluginAutoEnableTestState,
writePluginManifestFixture,
} from "./plugin-auto-enable.test-helpers.js";
import { validateConfigObject } from "./validation.js";
afterEach(() => {
resetPluginAutoEnableTestState();
});
describe("applyPluginAutoEnable core", () => {
it("detects typed channel-configured candidates", () => {
const candidates = detectPluginAutoEnableCandidates({
config: {
channels: { slack: { botToken: "x" } },
},
env: makeIsolatedEnv(),
});
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,
env: makeIsolatedEnv(),
});
expect(result.config).toEqual({});
expect(result.changes).toEqual([]);
expect(result.autoEnabledReasons).toEqual({});
});
it("auto-enables built-in channels without appending to plugins.allow", () => {
const result = applyPluginAutoEnable({
config: {
channels: { slack: { botToken: "x" } },
plugins: { allow: ["telegram"] },
},
env: makeIsolatedEnv(),
});
expect(result.config.channels?.slack?.enabled).toBe(true);
expect(result.config.plugins?.entries?.slack).toBeUndefined();
expect(result.config.plugins?.allow).toEqual(["telegram"]);
expect(result.autoEnabledReasons).toEqual({
slack: ["slack configured"],
});
expect(result.changes.join("\n")).toContain("Slack configured, enabled automatically.");
});
it("does not create plugins.allow when allowlist is unset", () => {
const result = applyPluginAutoEnable({
config: {
channels: { slack: { botToken: "x" } },
},
env: makeIsolatedEnv(),
});
expect(result.config.channels?.slack?.enabled).toBe(true);
expect(result.config.plugins?.allow).toBeUndefined();
});
it("stores auto-enable reasons in a null-prototype dictionary", () => {
const result = applyPluginAutoEnable({
config: {
channels: { slack: { botToken: "x" } },
},
env: makeIsolatedEnv(),
});
expect(Object.getPrototypeOf(result.autoEnabledReasons)).toBeNull();
});
it("auto-enables browser when browser config exists under a restrictive plugins.allow", () => {
const result = applyPluginAutoEnable({
config: {
browser: {
defaultProfile: "openclaw",
},
plugins: {
allow: ["telegram"],
},
},
env: makeIsolatedEnv(),
});
expect(result.config.plugins?.allow).toEqual(["telegram", "browser"]);
expect(result.config.plugins?.entries?.browser?.enabled).toBe(true);
expect(result.changes).toContain("browser configured, enabled automatically.");
});
it("auto-enables browser when tools.alsoAllow references browser", () => {
const result = applyPluginAutoEnable({
config: {
tools: {
alsoAllow: ["browser"],
},
plugins: {
allow: ["telegram"],
},
},
env: makeIsolatedEnv(),
});
expect(result.config.plugins?.allow).toEqual(["telegram", "browser"]);
expect(result.config.plugins?.entries?.browser?.enabled).toBe(true);
expect(result.changes).toContain("browser tool referenced, enabled automatically.");
});
it("keeps restrictive plugins.allow unchanged when browser is not referenced", () => {
const result = applyPluginAutoEnable({
config: {
plugins: {
allow: ["telegram"],
},
},
env: makeIsolatedEnv(),
});
expect(result.config.plugins?.allow).toEqual(["telegram"]);
expect(result.config.plugins?.entries?.browser).toBeUndefined();
expect(result.changes).toEqual([]);
});
it("does not auto-enable or allowlist non-bundled web fetch providers from config", () => {
const result = applyPluginAutoEnable({
config: {
tools: {
web: {
fetch: {
provider: "evilfetch",
},
},
},
plugins: {
allow: ["telegram"],
},
},
env: makeIsolatedEnv(),
manifestRegistry: makeRegistry([
{
id: "evil-plugin",
channels: [],
contracts: { webFetchProviders: ["evilfetch"] },
},
]),
});
expect(result.config.plugins?.entries?.["evil-plugin"]).toBeUndefined();
expect(result.config.plugins?.allow).toEqual(["telegram"]);
expect(result.changes).toEqual([]);
});
it("auto-enables bundled firecrawl when plugin-owned webFetch config exists", () => {
const result = applyPluginAutoEnable({
config: {
plugins: {
allow: ["telegram"],
entries: {
firecrawl: {
config: {
webFetch: {
apiKey: "firecrawl-key",
},
},
},
},
},
},
env: makeIsolatedEnv(),
});
expect(result.config.plugins?.entries?.firecrawl?.enabled).toBe(true);
expect(result.config.plugins?.allow).toEqual(["telegram", "firecrawl"]);
expect(result.changes).toContain("firecrawl web fetch configured, enabled automatically.");
});
it("skips auto-enable work for configs without channel or plugin-owned surfaces", () => {
const result = applyPluginAutoEnable({
config: {
gateway: {
auth: {
mode: "token",
token: "ok",
},
},
agents: {
list: [{ id: "pi" }],
},
},
env: makeIsolatedEnv(),
});
expect(result.config).toEqual({
gateway: {
auth: {
mode: "token",
token: "ok",
},
},
agents: {
list: [{ id: "pi" }],
},
});
expect(result.changes).toEqual([]);
});
it("ignores channels.modelByChannel for plugin auto-enable", () => {
const result = applyPluginAutoEnable({
config: {
channels: {
modelByChannel: {
openai: {
whatsapp: "openai/gpt-5.4",
},
},
},
},
env: makeIsolatedEnv(),
});
expect(result.config.plugins?.entries?.modelByChannel).toBeUndefined();
expect(result.config.plugins?.allow).toBeUndefined();
expect(result.changes).toEqual([]);
});
it("keeps auto-enabled WhatsApp config schema-valid", () => {
const result = applyPluginAutoEnable({
config: {
channels: {
whatsapp: {
allowFrom: ["+15555550123"],
},
},
},
env: makeIsolatedEnv(),
});
expect(result.config.channels?.whatsapp?.enabled).toBe(true);
expect(validateConfigObject(result.config).ok).toBe(true);
});
it("does not append built-in WhatsApp to plugins.allow during auto-enable", () => {
const result = applyPluginAutoEnable({
config: {
channels: {
whatsapp: {
allowFrom: ["+15555550123"],
},
},
plugins: {
allow: ["telegram"],
},
},
env: makeIsolatedEnv(),
});
expect(result.config.channels?.whatsapp?.enabled).toBe(true);
expect(result.config.plugins?.allow).toEqual(["telegram"]);
expect(validateConfigObject(result.config).ok).toBe(true);
});
it("does not re-emit built-in auto-enable changes when rerun with plugins.allow set", () => {
const first = applyPluginAutoEnable({
config: {
channels: {
whatsapp: {
allowFrom: ["+15555550123"],
},
},
plugins: {
allow: ["telegram"],
},
},
env: makeIsolatedEnv(),
});
const second = applyPluginAutoEnable({
config: first.config,
env: makeIsolatedEnv(),
});
expect(first.changes).toHaveLength(1);
expect(second.changes).toEqual([]);
expect(second.config).toEqual(first.config);
});
it("respects explicit disable", () => {
const result = applyPluginAutoEnable({
config: {
channels: { slack: { botToken: "x" } },
plugins: { entries: { slack: { enabled: false } } },
},
env: makeIsolatedEnv(),
});
expect(result.config.plugins?.entries?.slack?.enabled).toBe(false);
expect(result.changes).toEqual([]);
});
it("respects built-in channel explicit disable via channels.<id>.enabled", () => {
const result = applyPluginAutoEnable({
config: {
channels: { slack: { botToken: "x", enabled: false } },
},
env: makeIsolatedEnv(),
});
expect(result.config.channels?.slack?.enabled).toBe(false);
expect(result.config.plugins?.entries?.slack).toBeUndefined();
expect(result.changes).toEqual([]);
});
it("does not auto-enable plugin channels when only enabled=false is set", () => {
const result = applyPluginAutoEnable({
config: {
channels: { matrix: { enabled: false } },
},
env: makeIsolatedEnv(),
manifestRegistry: makeRegistry([{ id: "matrix", channels: ["matrix"] }]),
});
expect(result.config.plugins?.entries?.matrix).toBeUndefined();
expect(result.changes).toEqual([]);
});
it("auto-enables irc when configured via env", () => {
const result = applyPluginAutoEnable({
config: {},
env: {
...makeIsolatedEnv(),
IRC_HOST: "irc.libera.chat",
IRC_NICK: "openclaw-bot",
},
});
expect(result.config.channels?.irc?.enabled).toBe(true);
expect(result.changes.join("\n")).toContain("IRC configured, enabled automatically.");
});
it("uses the provided env when loading plugin manifests automatically", () => {
const stateDir = makeTempDir();
const pluginDir = path.join(stateDir, "extensions", "apn-channel");
writePluginManifestFixture({
rootDir: pluginDir,
id: "apn-channel",
channels: ["apn"],
});
const result = applyPluginAutoEnable({
config: {
channels: { apn: { someKey: "value" } },
},
env: {
...makeIsolatedEnv(),
OPENCLAW_HOME: undefined,
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins",
},
});
expect(result.config.plugins?.entries?.["apn-channel"]?.enabled).toBe(true);
expect(result.config.plugins?.entries?.apn).toBeUndefined();
});
it("skips when plugins are globally disabled", () => {
const result = applyPluginAutoEnable({
config: {
channels: { slack: { botToken: "x" } },
plugins: { enabled: false },
},
env: makeIsolatedEnv(),
});
expect(result.config.plugins?.entries?.slack?.enabled).toBeUndefined();
expect(result.changes).toEqual([]);
});
});

View File

@@ -0,0 +1,30 @@
import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
import type { OpenClawConfig } from "./config.js";
import {
configMayNeedPluginAutoEnable,
resolveConfiguredPluginAutoEnableCandidates,
resolvePluginAutoEnableManifestRegistry,
type PluginAutoEnableCandidate,
} from "./plugin-auto-enable.shared.js";
export function detectPluginAutoEnableCandidates(params: {
config?: OpenClawConfig;
env?: NodeJS.ProcessEnv;
manifestRegistry?: PluginManifestRegistry;
}): PluginAutoEnableCandidate[] {
const env = params.env ?? process.env;
const config = params.config ?? ({} as OpenClawConfig);
if (!configMayNeedPluginAutoEnable(config, env)) {
return [];
}
const registry = resolvePluginAutoEnableManifestRegistry({
config,
env,
manifestRegistry: params.manifestRegistry,
});
return resolveConfiguredPluginAutoEnableCandidates({
config,
env,
registry,
});
}

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
import { applyPluginAutoEnable } from "./plugin-auto-enable.js";
import { makeIsolatedEnv } from "./plugin-auto-enable.test-helpers.js";
function makeRegistry(
plugins: Array<{
@@ -36,7 +37,7 @@ describe("applyPluginAutoEnable modelSupport", () => {
},
},
},
env: {},
env: makeIsolatedEnv(),
manifestRegistry: makeRegistry([
{
id: "openai",
@@ -60,7 +61,7 @@ describe("applyPluginAutoEnable modelSupport", () => {
},
},
},
env: {},
env: makeIsolatedEnv(),
manifestRegistry: makeRegistry([
{
id: "openai",

View File

@@ -0,0 +1,143 @@
import fs from "node:fs";
import path from "node:path";
import { getChatChannelMeta, normalizeChatChannelId } from "../channels/registry.js";
import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
import { isRecord, resolveConfigDir, resolveUserPath } from "../utils.js";
import type { OpenClawConfig } from "./config.js";
import type { PluginAutoEnableCandidate } from "./plugin-auto-enable.shared.js";
type ExternalCatalogChannelEntry = {
id: string;
preferOver: string[];
};
const ENV_CATALOG_PATHS = ["OPENCLAW_PLUGIN_CATALOG_PATHS", "OPENCLAW_MPM_CATALOG_PATHS"];
function splitEnvPaths(value: string): string[] {
const trimmed = value.trim();
if (!trimmed) {
return [];
}
return trimmed
.split(/[;,]/g)
.flatMap((chunk) => chunk.split(path.delimiter))
.map((entry) => entry.trim())
.filter(Boolean);
}
function resolveExternalCatalogPaths(env: NodeJS.ProcessEnv): string[] {
for (const key of ENV_CATALOG_PATHS) {
const raw = env[key];
if (raw && raw.trim()) {
return splitEnvPaths(raw);
}
}
const configDir = resolveConfigDir(env);
return [
path.join(configDir, "mpm", "plugins.json"),
path.join(configDir, "mpm", "catalog.json"),
path.join(configDir, "plugins", "catalog.json"),
];
}
function parseExternalCatalogChannelEntries(raw: unknown): ExternalCatalogChannelEntry[] {
const list = (() => {
if (Array.isArray(raw)) {
return raw;
}
if (!isRecord(raw)) {
return [];
}
const entries = raw.entries ?? raw.packages ?? raw.plugins;
return Array.isArray(entries) ? entries : [];
})();
const channels: ExternalCatalogChannelEntry[] = [];
for (const entry of list) {
if (!isRecord(entry) || !isRecord(entry.openclaw) || !isRecord(entry.openclaw.channel)) {
continue;
}
const channel = entry.openclaw.channel;
const id = typeof channel.id === "string" ? channel.id.trim() : "";
if (!id) {
continue;
}
const preferOver = Array.isArray(channel.preferOver)
? channel.preferOver.filter((value): value is string => typeof value === "string")
: [];
channels.push({ id, preferOver });
}
return channels;
}
function resolveExternalCatalogPreferOver(channelId: string, env: NodeJS.ProcessEnv): string[] {
for (const rawPath of resolveExternalCatalogPaths(env)) {
const resolved = resolveUserPath(rawPath, env);
if (!fs.existsSync(resolved)) {
continue;
}
try {
const payload = JSON.parse(fs.readFileSync(resolved, "utf-8")) as unknown;
const channel = parseExternalCatalogChannelEntries(payload).find(
(entry) => entry.id === channelId,
);
if (channel) {
return channel.preferOver;
}
} catch {
// Ignore invalid catalog files.
}
}
return [];
}
function resolvePreferredOverIds(
pluginId: string,
env: NodeJS.ProcessEnv,
registry: PluginManifestRegistry,
): string[] {
const normalized = normalizeChatChannelId(pluginId);
if (normalized) {
return [...(getChatChannelMeta(normalized).preferOver ?? [])];
}
const installedPlugin = registry.plugins.find((record) => record.id === pluginId);
const manifestChannelPreferOver = installedPlugin?.channelConfigs?.[pluginId]?.preferOver;
if (manifestChannelPreferOver?.length) {
return [...manifestChannelPreferOver];
}
const installedChannelMeta = installedPlugin?.channelCatalogMeta;
if (installedChannelMeta?.preferOver?.length) {
return [...installedChannelMeta.preferOver];
}
return resolveExternalCatalogPreferOver(pluginId, env);
}
export function shouldSkipPreferredPluginAutoEnable(params: {
config: OpenClawConfig;
entry: PluginAutoEnableCandidate;
configured: readonly PluginAutoEnableCandidate[];
env: NodeJS.ProcessEnv;
registry: PluginManifestRegistry;
isPluginDenied: (config: OpenClawConfig, pluginId: string) => boolean;
isPluginExplicitlyDisabled: (config: OpenClawConfig, pluginId: string) => boolean;
}): boolean {
for (const other of params.configured) {
if (other.pluginId === params.entry.pluginId) {
continue;
}
if (
params.isPluginDenied(params.config, other.pluginId) ||
params.isPluginExplicitlyDisabled(params.config, other.pluginId)
) {
continue;
}
if (
resolvePreferredOverIds(other.pluginId, params.env, params.registry).includes(
params.entry.pluginId,
)
) {
return true;
}
}
return false;
}

View File

@@ -0,0 +1,239 @@
import { afterEach, describe, expect, it } from "vitest";
import { applyPluginAutoEnable } from "./plugin-auto-enable.js";
import {
makeIsolatedEnv,
makeRegistry,
resetPluginAutoEnableTestState,
} from "./plugin-auto-enable.test-helpers.js";
afterEach(() => {
resetPluginAutoEnableTestState();
});
describe("applyPluginAutoEnable providers", () => {
it("auto-enables provider auth plugins when profiles exist", () => {
const result = applyPluginAutoEnable({
config: {
auth: {
profiles: {
"google-gemini-cli:default": {
provider: "google-gemini-cli",
mode: "oauth",
},
},
},
},
env: makeIsolatedEnv(),
});
expect(result.config.plugins?.entries?.google?.enabled).toBe(true);
});
it("auto-enables bundled provider plugins when plugin-owned web search config exists", () => {
const result = applyPluginAutoEnable({
config: {
plugins: {
entries: {
xai: {
config: {
webSearch: {
apiKey: "xai-plugin-config-key",
},
},
},
},
},
},
env: makeIsolatedEnv(),
});
expect(result.config.plugins?.entries?.xai?.enabled).toBe(true);
expect(result.changes).toContain("xai web search configured, enabled automatically.");
});
it("auto-enables xai when the plugin-owned x_search tool is configured", () => {
const result = applyPluginAutoEnable({
config: {
plugins: {
entries: {
xai: {
config: {
xSearch: {
enabled: true,
},
},
},
},
},
},
env: makeIsolatedEnv(),
});
expect(result.config.plugins?.entries?.xai?.enabled).toBe(true);
expect(result.changes).toContain("xai tool configured, enabled automatically.");
});
it("auto-enables xai when the plugin-owned codeExecution config is configured", () => {
const result = applyPluginAutoEnable({
config: {
plugins: {
entries: {
xai: {
config: {
codeExecution: {
enabled: true,
model: "grok-4-1-fast",
},
},
},
},
},
},
env: makeIsolatedEnv(),
});
expect(result.config.plugins?.entries?.xai?.enabled).toBe(true);
expect(result.changes).toContain("xai tool configured, enabled automatically.");
});
it("auto-enables minimax when minimax-portal profiles exist", () => {
const result = applyPluginAutoEnable({
config: {
auth: {
profiles: {
"minimax-portal:default": {
provider: "minimax-portal",
mode: "oauth",
},
},
},
},
env: makeIsolatedEnv(),
});
expect(result.config.plugins?.entries?.minimax?.enabled).toBe(true);
expect(result.config.plugins?.entries?.["minimax-portal-auth"]).toBeUndefined();
});
it("auto-enables minimax when minimax API key auth is configured", () => {
const result = applyPluginAutoEnable({
config: {
auth: {
profiles: {
"minimax:default": {
provider: "minimax",
mode: "api_key",
},
},
},
},
env: makeIsolatedEnv(),
});
expect(result.config.plugins?.entries?.minimax?.enabled).toBe(true);
});
it("does not auto-enable unrelated provider plugins just because auth profiles exist", () => {
const result = applyPluginAutoEnable({
config: {
auth: {
profiles: {
"openai:default": {
provider: "openai",
mode: "api_key",
},
},
},
},
env: makeIsolatedEnv(),
});
expect(result.config.plugins?.entries?.openai).toBeUndefined();
expect(result.changes).toEqual([]);
});
it("uses manifest-owned provider auto-enable metadata for third-party plugins", () => {
const result = applyPluginAutoEnable({
config: {
auth: {
profiles: {
"acme-oauth:default": {
provider: "acme-oauth",
mode: "oauth",
},
},
},
},
env: makeIsolatedEnv(),
manifestRegistry: makeRegistry([
{
id: "acme",
channels: [],
autoEnableWhenConfiguredProviders: ["acme-oauth"],
},
]),
});
expect(result.config.plugins?.entries?.acme?.enabled).toBe(true);
});
it("auto-enables third-party provider plugins when manifest-owned web search config exists", () => {
const result = applyPluginAutoEnable({
config: {
plugins: {
entries: {
acme: {
config: {
webSearch: {
apiKey: "acme-search-key",
},
},
},
},
},
},
env: makeIsolatedEnv(),
manifestRegistry: makeRegistry([
{
id: "acme",
channels: [],
providers: ["acme-ai"],
contracts: {
webSearchProviders: ["acme-search"],
},
},
]),
});
expect(result.config.plugins?.entries?.acme?.enabled).toBe(true);
expect(result.changes).toContain("acme web search configured, enabled automatically.");
});
it("auto-enables acpx plugin when ACP is configured", () => {
const result = applyPluginAutoEnable({
config: {
acp: {
enabled: true,
},
},
env: makeIsolatedEnv(),
});
expect(result.config.plugins?.entries?.acpx?.enabled).toBe(true);
expect(result.changes.join("\n")).toContain("ACP runtime configured, enabled automatically.");
});
it("does not auto-enable acpx when a different ACP backend is configured", () => {
const result = applyPluginAutoEnable({
config: {
acp: {
enabled: true,
backend: "custom-runtime",
},
},
env: makeIsolatedEnv(),
});
expect(result.config.plugins?.entries?.acpx?.enabled).toBeUndefined();
});
});

View File

@@ -0,0 +1,672 @@
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 PluginManifestRegistry,
} from "../plugins/manifest-registry.js";
import { resolveOwningPluginIdsForModelRef } from "../plugins/providers.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: "browser";
kind: "browser-configured";
source: "browser-configured" | "browser-plugin-configured" | "browser-tool-referenced";
}
| {
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: "acpx";
kind: "acp-runtime-configured";
};
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 hasPluginOwnedToolConfig(cfg: OpenClawConfig, pluginId: string): boolean {
if (pluginId !== "xai") {
return false;
}
const pluginConfig = cfg.plugins?.entries?.xai?.config;
const web = cfg.tools?.web as Record<string, unknown> | undefined;
return Boolean(
isRecord(web?.x_search) ||
(isRecord(pluginConfig) &&
(isRecord(pluginConfig.xSearch) || isRecord(pluginConfig.codeExecution))),
);
}
function resolveProviderPluginsWithOwnedWebSearch(
registry: PluginManifestRegistry,
): ReadonlySet<string> {
return new Set(
registry.plugins
.filter((plugin) => plugin.providers.length > 0)
.filter((plugin) => (plugin.contracts?.webSearchProviders?.length ?? 0) > 0)
.map((plugin) => plugin.id),
);
}
function resolveProviderPluginsWithOwnedWebFetch(
registry: PluginManifestRegistry,
): ReadonlySet<string> {
return new Set(
registry.plugins
.filter((plugin) => (plugin.contracts?.webFetchProviders?.length ?? 0) > 0)
.map((plugin) => plugin.id),
);
}
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 listContainsBrowser(value: unknown): boolean {
return (
Array.isArray(value) &&
value.some((entry) => typeof entry === "string" && entry.trim().toLowerCase() === "browser")
);
}
function toolPolicyReferencesBrowser(value: unknown): boolean {
return (
isRecord(value) && (listContainsBrowser(value.allow) || listContainsBrowser(value.alsoAllow))
);
}
function hasBrowserToolReference(cfg: OpenClawConfig): boolean {
if (toolPolicyReferencesBrowser(cfg.tools)) {
return true;
}
const agentList = cfg.agents?.list;
return Array.isArray(agentList)
? agentList.some((entry) => isRecord(entry) && toolPolicyReferencesBrowser(entry.tools))
: false;
}
function hasExplicitBrowserPluginEntry(cfg: OpenClawConfig): boolean {
return Boolean(
cfg.plugins?.entries && Object.prototype.hasOwnProperty.call(cfg.plugins.entries, "browser"),
);
}
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";
}
if (hasExplicitBrowserPluginEntry(cfg)) {
return "browser-plugin-configured";
}
if (hasBrowserToolReference(cfg)) {
return "browser-tool-referenced";
}
return null;
}
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 (hasPotentialConfiguredChannels(cfg, env)) {
return true;
}
if (resolveBrowserAutoEnableSource(cfg)) {
return true;
}
if (cfg.acp?.enabled === true || cfg.acp?.dispatch?.enabled === true) {
return true;
}
if (typeof cfg.acp?.backend === "string" && cfg.acp.backend.trim().length > 0) {
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 web = cfg.tools?.web as Record<string, unknown> | undefined;
return (
isRecord(web?.x_search) ||
isRecord(cfg.plugins?.entries?.xai?.config) ||
hasConfiguredWebSearchPluginEntry(cfg) ||
hasConfiguredWebFetchPluginEntry(cfg)
);
}
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 "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 "acp-runtime-configured":
return "ACP runtime configured";
}
}
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 });
}
}
const browserSource = resolveBrowserAutoEnableSource(params.config);
if (browserSource) {
changes.push({ pluginId: "browser", kind: "browser-configured", source: browserSource });
}
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 pluginId of resolveProviderPluginsWithOwnedWebSearch(params.registry)) {
if (hasPluginOwnedWebSearchConfig(params.config, pluginId)) {
changes.push({ pluginId, kind: "plugin-web-search-configured" });
}
if (hasPluginOwnedToolConfig(params.config, pluginId)) {
changes.push({ pluginId, kind: "plugin-tool-configured" });
}
}
for (const pluginId of resolveProviderPluginsWithOwnedWebFetch(params.registry)) {
if (hasPluginOwnedWebFetchConfig(params.config, pluginId)) {
changes.push({ pluginId, kind: "plugin-web-fetch-configured" });
}
}
const backendRaw =
typeof params.config.acp?.backend === "string"
? params.config.acp.backend.trim().toLowerCase()
: "";
const acpConfigured =
params.config.acp?.enabled === true ||
params.config.acp?.dispatch?.enabled === true ||
backendRaw === "acpx";
if (acpConfigured && (!backendRaw || backendRaw === "acpx")) {
changes.push({ pluginId: "acpx", kind: "acp-runtime-configured" });
}
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 };
}

View File

@@ -0,0 +1,97 @@
import fs from "node:fs";
import path from "node:path";
import { clearPluginDiscoveryCache } from "../plugins/discovery.js";
import {
clearPluginManifestRegistryCache,
type PluginManifestRegistry,
} from "../plugins/manifest-registry.js";
import {
cleanupTrackedTempDirs,
makeTrackedTempDir,
mkdirSafeDir,
} from "../plugins/test-helpers/fs-fixtures.js";
const tempDirs: string[] = [];
export function resetPluginAutoEnableTestState(): void {
clearPluginDiscoveryCache();
clearPluginManifestRegistryCache();
cleanupTrackedTempDirs(tempDirs);
}
export function makeTempDir(): string {
return makeTrackedTempDir("openclaw-plugin-auto-enable", tempDirs);
}
export function makeIsolatedEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
const rootDir = makeTempDir();
return {
OPENCLAW_STATE_DIR: path.join(rootDir, "state"),
...overrides,
};
}
export function writePluginManifestFixture(params: {
rootDir: string;
id: string;
channels: string[];
}): void {
mkdirSafeDir(params.rootDir);
fs.writeFileSync(
path.join(params.rootDir, "openclaw.plugin.json"),
JSON.stringify(
{
id: params.id,
channels: params.channels,
configSchema: { type: "object" },
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(path.join(params.rootDir, "index.ts"), "export default {}", "utf-8");
}
export function makeRegistry(
plugins: Array<{
id: string;
channels: string[];
autoEnableWhenConfiguredProviders?: string[];
modelSupport?: { modelPrefixes?: string[]; modelPatterns?: string[] };
contracts?: { webSearchProviders?: string[]; webFetchProviders?: string[] };
providers?: string[];
channelConfigs?: Record<string, { schema: Record<string, unknown>; preferOver?: string[] }>;
}>,
): PluginManifestRegistry {
return {
plugins: plugins.map((plugin) => ({
id: plugin.id,
channels: plugin.channels,
autoEnableWhenConfiguredProviders: plugin.autoEnableWhenConfiguredProviders,
modelSupport: plugin.modelSupport,
contracts: plugin.contracts,
channelConfigs: plugin.channelConfigs,
providers: plugin.providers ?? [],
cliBackends: [],
skills: [],
hooks: [],
origin: "config" as const,
rootDir: `/fake/${plugin.id}`,
source: `/fake/${plugin.id}/index.js`,
manifestPath: `/fake/${plugin.id}/openclaw.plugin.json`,
})),
diagnostics: [],
};
}
export function makeApnChannelConfig() {
return { channels: { apn: { someKey: "value" } } };
}
export function makeBluebubblesAndImessageChannels() {
return {
bluebubbles: { serverUrl: "http://localhost:1234", password: "x" },
imessage: { cliPath: "/usr/local/bin/imsg" },
};
}

View File

@@ -1,935 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { clearPluginDiscoveryCache } from "../plugins/discovery.js";
import {
clearPluginManifestRegistryCache,
type PluginManifestRegistry,
} from "../plugins/manifest-registry.js";
import {
cleanupTrackedTempDirs,
makeTrackedTempDir,
mkdirSafeDir,
} from "../plugins/test-helpers/fs-fixtures.js";
import {
applyPluginAutoEnable,
detectPluginAutoEnableCandidates,
resolvePluginAutoEnableCandidateReason,
} from "./plugin-auto-enable.js";
import { validateConfigObject } from "./validation.js";
const tempDirs: string[] = [];
function makeTempDir() {
return makeTrackedTempDir("openclaw-plugin-auto-enable", tempDirs);
}
function writePluginManifestFixture(params: { rootDir: string; id: string; channels: string[] }) {
mkdirSafeDir(params.rootDir);
fs.writeFileSync(
path.join(params.rootDir, "openclaw.plugin.json"),
JSON.stringify(
{
id: params.id,
channels: params.channels,
configSchema: { type: "object" },
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(path.join(params.rootDir, "index.ts"), "export default {}", "utf-8");
}
/** Helper to build a minimal PluginManifestRegistry for testing. */
function makeRegistry(
plugins: Array<{
id: string;
channels: string[];
autoEnableWhenConfiguredProviders?: string[];
modelSupport?: { modelPrefixes?: string[]; modelPatterns?: string[] };
contracts?: { webFetchProviders?: string[] };
channelConfigs?: Record<string, { schema: Record<string, unknown>; preferOver?: string[] }>;
}>,
): PluginManifestRegistry {
return {
plugins: plugins.map((p) => ({
id: p.id,
channels: p.channels,
autoEnableWhenConfiguredProviders: p.autoEnableWhenConfiguredProviders,
modelSupport: p.modelSupport,
contracts: p.contracts,
channelConfigs: p.channelConfigs,
providers: [],
cliBackends: [],
skills: [],
hooks: [],
origin: "config" as const,
rootDir: `/fake/${p.id}`,
source: `/fake/${p.id}/index.js`,
manifestPath: `/fake/${p.id}/openclaw.plugin.json`,
})),
diagnostics: [],
};
}
function makeApnChannelConfig() {
return { channels: { apn: { someKey: "value" } } };
}
function makeBluebubblesAndImessageChannels() {
return {
bluebubbles: { serverUrl: "http://localhost:1234", password: "x" },
imessage: { cliPath: "/usr/local/bin/imsg" },
};
}
function applyWithSlackConfig(extra?: { plugins?: { allow?: string[] } }) {
return applyPluginAutoEnable({
config: {
channels: { slack: { botToken: "x" } },
...(extra?.plugins ? { plugins: extra.plugins } : {}),
},
env: {},
});
}
function applyWithApnChannelConfig(extra?: {
plugins?: { entries?: Record<string, { enabled: boolean }> };
}) {
return applyPluginAutoEnable({
config: {
...makeApnChannelConfig(),
...(extra?.plugins ? { plugins: extra.plugins } : {}),
},
env: {},
manifestRegistry: makeRegistry([{ id: "apn-channel", channels: ["apn"] }]),
});
}
function applyWithBluebubblesImessageConfig(extra?: {
plugins?: { entries?: Record<string, { enabled: boolean }>; deny?: string[] };
}) {
return applyPluginAutoEnable({
config: {
channels: makeBluebubblesAndImessageChannels(),
...(extra?.plugins ? { plugins: extra.plugins } : {}),
},
env: {},
});
}
afterEach(() => {
clearPluginDiscoveryCache();
clearPluginManifestRegistryCache();
cleanupTrackedTempDirs(tempDirs);
});
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,
env: {},
});
expect(result.config).toEqual({});
expect(result.changes).toEqual([]);
expect(result.autoEnabledReasons).toEqual({});
});
it("auto-enables built-in channels without appending to plugins.allow", () => {
const result = applyWithSlackConfig({ plugins: { allow: ["telegram"] } });
expect(result.config.channels?.slack?.enabled).toBe(true);
expect(result.config.plugins?.entries?.slack).toBeUndefined();
expect(result.config.plugins?.allow).toEqual(["telegram"]);
expect(result.autoEnabledReasons).toEqual({
slack: ["slack configured"],
});
expect(result.changes.join("\n")).toContain("Slack configured, enabled automatically.");
});
it("does not create plugins.allow when allowlist is unset", () => {
const result = applyWithSlackConfig();
expect(result.config.channels?.slack?.enabled).toBe(true);
expect(result.config.plugins?.allow).toBeUndefined();
});
it("stores auto-enable reasons in a null-prototype dictionary", () => {
const result = applyWithSlackConfig();
expect(Object.getPrototypeOf(result.autoEnabledReasons)).toBeNull();
});
it("auto-enables browser when browser config exists under a restrictive plugins.allow", () => {
const result = applyPluginAutoEnable({
config: {
browser: {
defaultProfile: "openclaw",
},
plugins: {
allow: ["telegram"],
},
},
env: {},
});
expect(result.config.plugins?.allow).toEqual(["telegram", "browser"]);
expect(result.config.plugins?.entries?.browser?.enabled).toBe(true);
expect(result.autoEnabledReasons).toEqual({
browser: ["browser configured"],
});
expect(result.changes).toContain("browser configured, enabled automatically.");
});
it("auto-enables browser when tools.alsoAllow references browser", () => {
const result = applyPluginAutoEnable({
config: {
tools: {
alsoAllow: ["browser"],
},
plugins: {
allow: ["telegram"],
},
},
env: {},
});
expect(result.config.plugins?.allow).toEqual(["telegram", "browser"]);
expect(result.config.plugins?.entries?.browser?.enabled).toBe(true);
expect(result.changes).toContain("browser tool referenced, enabled automatically.");
});
it("keeps restrictive plugins.allow unchanged when browser is not referenced", () => {
const result = applyPluginAutoEnable({
config: {
plugins: {
allow: ["telegram"],
},
},
env: {},
});
expect(result.config.plugins?.allow).toEqual(["telegram"]);
expect(result.config.plugins?.entries?.browser).toBeUndefined();
expect(result.changes).toEqual([]);
});
it("does not auto-enable or allowlist non-bundled web fetch providers from config", () => {
const result = applyPluginAutoEnable({
config: {
tools: {
web: {
fetch: {
provider: "evilfetch",
},
},
},
plugins: {
allow: ["telegram"],
},
},
env: {},
manifestRegistry: makeRegistry([
{
id: "evil-plugin",
channels: [],
contracts: { webFetchProviders: ["evilfetch"] },
},
]),
});
expect(result.config.plugins?.entries?.["evil-plugin"]).toBeUndefined();
expect(result.config.plugins?.allow).toEqual(["telegram"]);
expect(result.changes).toEqual([]);
});
it("auto-enables bundled firecrawl when plugin-owned webFetch config exists", () => {
const result = applyPluginAutoEnable({
config: {
plugins: {
allow: ["telegram"],
entries: {
firecrawl: {
config: {
webFetch: {
apiKey: "firecrawl-key",
},
},
},
},
},
},
env: {},
});
expect(result.config.plugins?.entries?.firecrawl?.enabled).toBe(true);
expect(result.config.plugins?.allow).toEqual(["telegram", "firecrawl"]);
expect(result.changes).toContain("firecrawl web fetch configured, enabled automatically.");
});
it("skips auto-enable work for configs without channel or plugin-owned surfaces", () => {
const result = applyPluginAutoEnable({
config: {
gateway: {
auth: {
mode: "token",
token: "ok",
},
},
agents: {
list: [{ id: "pi" }],
},
},
env: {},
});
expect(result.config).toEqual({
gateway: {
auth: {
mode: "token",
token: "ok",
},
},
agents: {
list: [{ id: "pi" }],
},
});
expect(result.changes).toEqual([]);
});
it("ignores channels.modelByChannel for plugin auto-enable", () => {
const result = applyPluginAutoEnable({
config: {
channels: {
modelByChannel: {
openai: {
whatsapp: "openai/gpt-5.4",
},
},
},
},
env: {},
});
expect(result.config.plugins?.entries?.modelByChannel).toBeUndefined();
expect(result.config.plugins?.allow).toBeUndefined();
expect(result.changes).toEqual([]);
});
it("keeps auto-enabled WhatsApp config schema-valid", () => {
const result = applyPluginAutoEnable({
config: {
channels: {
whatsapp: {
allowFrom: ["+15555550123"],
},
},
},
env: {},
});
expect(result.config.channels?.whatsapp?.enabled).toBe(true);
const validated = validateConfigObject(result.config);
expect(validated.ok).toBe(true);
});
it("does not append built-in WhatsApp to plugins.allow during auto-enable", () => {
const result = applyPluginAutoEnable({
config: {
channels: {
whatsapp: {
allowFrom: ["+15555550123"],
},
},
plugins: {
allow: ["telegram"],
},
},
env: {},
});
expect(result.config.channels?.whatsapp?.enabled).toBe(true);
expect(result.config.plugins?.allow).toEqual(["telegram"]);
const validated = validateConfigObject(result.config);
expect(validated.ok).toBe(true);
});
it("does not re-emit built-in auto-enable changes when rerun with plugins.allow set", () => {
const first = applyPluginAutoEnable({
config: {
channels: {
whatsapp: {
allowFrom: ["+15555550123"],
},
},
plugins: {
allow: ["telegram"],
},
},
env: {},
});
const second = applyPluginAutoEnable({
config: first.config,
env: {},
});
expect(first.changes).toHaveLength(1);
expect(second.changes).toEqual([]);
expect(second.config).toEqual(first.config);
});
it("respects explicit disable", () => {
const result = applyPluginAutoEnable({
config: {
channels: { slack: { botToken: "x" } },
plugins: { entries: { slack: { enabled: false } } },
},
env: {},
});
expect(result.config.plugins?.entries?.slack?.enabled).toBe(false);
expect(result.changes).toEqual([]);
});
it("respects built-in channel explicit disable via channels.<id>.enabled", () => {
const result = applyPluginAutoEnable({
config: {
channels: { slack: { botToken: "x", enabled: false } },
},
env: {},
});
expect(result.config.channels?.slack?.enabled).toBe(false);
expect(result.config.plugins?.entries?.slack).toBeUndefined();
expect(result.changes).toEqual([]);
});
it("does not auto-enable plugin channels when only enabled=false is set", () => {
const result = applyPluginAutoEnable({
config: {
channels: { matrix: { enabled: false } },
},
env: {},
manifestRegistry: makeRegistry([{ id: "matrix", channels: ["matrix"] }]),
});
expect(result.config.plugins?.entries?.matrix).toBeUndefined();
expect(result.changes).toEqual([]);
});
it("auto-enables irc when configured via env", () => {
const result = applyPluginAutoEnable({
config: {},
env: {
IRC_HOST: "irc.libera.chat",
IRC_NICK: "openclaw-bot",
},
});
expect(result.config.channels?.irc?.enabled).toBe(true);
expect(result.changes.join("\n")).toContain("IRC configured, enabled automatically.");
});
it("uses the provided env when loading plugin manifests automatically", () => {
const stateDir = makeTempDir();
const pluginDir = path.join(stateDir, "extensions", "apn-channel");
writePluginManifestFixture({
rootDir: pluginDir,
id: "apn-channel",
channels: ["apn"],
});
const result = applyPluginAutoEnable({
config: {
channels: { apn: { someKey: "value" } },
},
env: {
...process.env,
OPENCLAW_HOME: undefined,
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins",
},
});
expect(result.config.plugins?.entries?.["apn-channel"]?.enabled).toBe(true);
expect(result.config.plugins?.entries?.apn).toBeUndefined();
});
it("uses env-scoped catalog metadata for preferOver auto-enable decisions", () => {
const stateDir = makeTempDir();
const catalogPath = path.join(stateDir, "plugins", "catalog.json");
mkdirSafeDir(path.dirname(catalogPath));
fs.writeFileSync(
catalogPath,
JSON.stringify({
entries: [
{
name: "@openclaw/env-secondary",
openclaw: {
channel: {
id: "env-secondary",
label: "Env Secondary",
selectionLabel: "Env Secondary",
docsPath: "/channels/env-secondary",
blurb: "Env secondary entry",
preferOver: ["env-primary"],
},
install: {
npmSpec: "@openclaw/env-secondary",
},
},
},
],
}),
"utf-8",
);
const result = applyPluginAutoEnable({
config: {
channels: {
"env-primary": { token: "primary" },
"env-secondary": { token: "secondary" },
},
},
env: {
...process.env,
OPENCLAW_STATE_DIR: stateDir,
},
manifestRegistry: makeRegistry([]),
});
expect(result.config.plugins?.entries?.["env-secondary"]?.enabled).toBe(true);
expect(result.config.plugins?.entries?.["env-primary"]?.enabled).toBeUndefined();
});
it("auto-enables provider auth plugins when profiles exist", () => {
const result = applyPluginAutoEnable({
config: {
auth: {
profiles: {
"google-gemini-cli:default": {
provider: "google-gemini-cli",
mode: "oauth",
},
},
},
},
env: {},
});
expect(result.config.plugins?.entries?.google?.enabled).toBe(true);
});
it("auto-enables bundled provider plugins when plugin-owned web search config exists", () => {
const result = applyPluginAutoEnable({
config: {
plugins: {
entries: {
xai: {
config: {
webSearch: {
apiKey: "xai-plugin-config-key",
},
},
},
},
},
},
env: {},
});
expect(result.config.plugins?.entries?.xai?.enabled).toBe(true);
expect(result.changes).toContain("xai web search configured, enabled automatically.");
});
it("auto-enables xai when the plugin-owned x_search tool is configured", () => {
const result = applyPluginAutoEnable({
config: {
plugins: {
entries: {
xai: {
config: {
xSearch: {
enabled: true,
},
},
},
},
},
},
env: {},
});
expect(result.config.plugins?.entries?.xai?.enabled).toBe(true);
expect(result.changes).toContain("xai tool configured, enabled automatically.");
});
it("auto-enables xai when the plugin-owned codeExecution config is configured", () => {
const result = applyPluginAutoEnable({
config: {
plugins: {
entries: {
xai: {
config: {
codeExecution: {
enabled: true,
model: "grok-4-1-fast",
},
},
},
},
},
},
env: {},
});
expect(result.config.plugins?.entries?.xai?.enabled).toBe(true);
expect(result.changes).toContain("xai tool configured, enabled automatically.");
});
it("auto-enables minimax when minimax-portal profiles exist", () => {
const result = applyPluginAutoEnable({
config: {
auth: {
profiles: {
"minimax-portal:default": {
provider: "minimax-portal",
mode: "oauth",
},
},
},
},
env: {},
});
expect(result.config.plugins?.entries?.minimax?.enabled).toBe(true);
expect(result.config.plugins?.entries?.["minimax-portal-auth"]).toBeUndefined();
});
it("auto-enables minimax when minimax API key auth is configured", () => {
const result = applyPluginAutoEnable({
config: {
auth: {
profiles: {
"minimax:default": {
provider: "minimax",
mode: "api_key",
},
},
},
},
env: {},
});
expect(result.config.plugins?.entries?.minimax?.enabled).toBe(true);
});
it("does not auto-enable unrelated provider plugins just because auth profiles exist", () => {
const result = applyPluginAutoEnable({
config: {
auth: {
profiles: {
"openai:default": {
provider: "openai",
mode: "api_key",
},
},
},
},
env: {},
});
expect(result.config.plugins?.entries?.openai).toBeUndefined();
expect(result.changes).toEqual([]);
});
it("uses manifest-owned provider auto-enable metadata for third-party plugins", () => {
const result = applyPluginAutoEnable({
config: {
auth: {
profiles: {
"acme-oauth:default": {
provider: "acme-oauth",
mode: "oauth",
},
},
},
},
env: {},
manifestRegistry: makeRegistry([
{
id: "acme",
channels: [],
autoEnableWhenConfiguredProviders: ["acme-oauth"],
},
]),
});
expect(result.config.plugins?.entries?.acme?.enabled).toBe(true);
});
it("auto-enables third-party provider plugins when manifest-owned web search config exists", () => {
const result = applyPluginAutoEnable({
config: {
plugins: {
entries: {
acme: {
config: {
webSearch: {
apiKey: "acme-search-key",
},
},
},
},
},
},
env: {},
manifestRegistry: {
plugins: [
{
id: "acme",
channels: [],
providers: ["acme-ai"],
cliBackends: [],
skills: [],
hooks: [],
origin: "config" as const,
rootDir: "/fake/acme",
source: "/fake/acme/index.js",
manifestPath: "/fake/acme/openclaw.plugin.json",
contracts: {
webSearchProviders: ["acme-search"],
},
},
],
diagnostics: [],
},
});
expect(result.config.plugins?.entries?.acme?.enabled).toBe(true);
expect(result.changes).toContain("acme web search configured, enabled automatically.");
});
it("auto-enables acpx plugin when ACP is configured", () => {
const result = applyPluginAutoEnable({
config: {
acp: {
enabled: true,
},
},
env: {},
});
expect(result.config.plugins?.entries?.acpx?.enabled).toBe(true);
expect(result.changes.join("\n")).toContain("ACP runtime configured, enabled automatically.");
});
it("does not auto-enable acpx when a different ACP backend is configured", () => {
const result = applyPluginAutoEnable({
config: {
acp: {
enabled: true,
backend: "custom-runtime",
},
},
env: {},
});
expect(result.config.plugins?.entries?.acpx?.enabled).toBeUndefined();
});
it("skips when plugins are globally disabled", () => {
const result = applyPluginAutoEnable({
config: {
channels: { slack: { botToken: "x" } },
plugins: { enabled: false },
},
env: {},
});
expect(result.config.plugins?.entries?.slack?.enabled).toBeUndefined();
expect(result.changes).toEqual([]);
});
describe("third-party channel plugins (pluginId ≠ channelId)", () => {
it("uses the plugin manifest id, not the channel id, for plugins.entries", () => {
// Reproduces: https://github.com/openclaw/openclaw/issues/25261
// Plugin "apn-channel" declares channels: ["apn"]. Doctor must write
// plugins.entries["apn-channel"], not plugins.entries["apn"].
const result = applyWithApnChannelConfig();
expect(result.config.plugins?.entries?.["apn-channel"]?.enabled).toBe(true);
expect(result.config.plugins?.entries?.["apn"]).toBeUndefined();
expect(result.changes.join("\n")).toContain("apn configured, enabled automatically.");
});
it("does not double-enable when plugin is already enabled under its plugin id", () => {
const result = applyWithApnChannelConfig({
plugins: { entries: { "apn-channel": { enabled: true } } },
});
expect(result.changes).toEqual([]);
});
it("respects explicit disable of the plugin by its plugin id", () => {
const result = applyWithApnChannelConfig({
plugins: { entries: { "apn-channel": { enabled: false } } },
});
expect(result.config.plugins?.entries?.["apn-channel"]?.enabled).toBe(false);
expect(result.changes).toEqual([]);
});
it("falls back to channel key as plugin id when no installed manifest declares the channel", () => {
// Without a matching manifest entry, behavior is unchanged (backward compat).
const result = applyPluginAutoEnable({
config: {
channels: { "unknown-chan": { someKey: "value" } },
},
env: {},
manifestRegistry: makeRegistry([]),
});
expect(result.config.plugins?.entries?.["unknown-chan"]?.enabled).toBe(true);
});
});
describe("preferOver channel prioritization", () => {
it("uses manifest channel config preferOver metadata for plugin channels", () => {
const result = applyPluginAutoEnable({
config: {
channels: {
primary: { someKey: "value" },
secondary: { someKey: "value" },
},
},
env: {},
manifestRegistry: makeRegistry([
{
id: "primary",
channels: ["primary"],
channelConfigs: {
primary: {
schema: { type: "object" },
preferOver: ["secondary"],
},
},
},
{ id: "secondary", channels: ["secondary"] },
]),
});
expect(result.config.plugins?.entries?.primary?.enabled).toBe(true);
expect(result.config.plugins?.entries?.secondary?.enabled).toBeUndefined();
expect(result.changes.join("\n")).toContain("primary configured, enabled automatically.");
expect(result.changes.join("\n")).not.toContain(
"secondary configured, enabled automatically.",
);
});
it("prefers bluebubbles: skips imessage auto-configure when both are configured", () => {
const result = applyWithBluebubblesImessageConfig();
expect(result.config.channels?.bluebubbles?.enabled).toBe(true);
expect(result.config.plugins?.entries?.imessage?.enabled).toBeUndefined();
expect(result.changes.join("\n")).toContain("BlueBubbles configured, enabled automatically.");
expect(result.changes.join("\n")).not.toContain(
"iMessage configured, enabled automatically.",
);
});
it("keeps imessage enabled if already explicitly enabled (non-destructive)", () => {
const result = applyWithBluebubblesImessageConfig({
plugins: { entries: { imessage: { enabled: true } } },
});
expect(result.config.channels?.bluebubbles?.enabled).toBe(true);
expect(result.config.plugins?.entries?.imessage?.enabled).toBe(true);
});
it("allows imessage auto-configure when bluebubbles is explicitly disabled", () => {
const result = applyWithBluebubblesImessageConfig({
plugins: { entries: { bluebubbles: { enabled: false } } },
});
expect(result.config.plugins?.entries?.bluebubbles?.enabled).toBe(false);
expect(result.config.channels?.imessage?.enabled).toBe(true);
expect(result.changes.join("\n")).toContain("iMessage configured, enabled automatically.");
});
it("allows imessage auto-configure when bluebubbles is in deny list", () => {
const result = applyWithBluebubblesImessageConfig({
plugins: { deny: ["bluebubbles"] },
});
expect(result.config.plugins?.entries?.bluebubbles?.enabled).toBeUndefined();
expect(result.config.channels?.imessage?.enabled).toBe(true);
});
it("auto-enables imessage when only imessage is configured", () => {
const result = applyPluginAutoEnable({
config: {
channels: { imessage: { cliPath: "/usr/local/bin/imsg" } },
},
env: {},
});
expect(result.config.channels?.imessage?.enabled).toBe(true);
expect(result.changes.join("\n")).toContain("iMessage configured, enabled automatically.");
});
it("uses the provided env when loading installed plugin manifests", () => {
const stateDir = makeTempDir();
const pluginDir = path.join(stateDir, "extensions", "apn-channel");
writePluginManifestFixture({
rootDir: pluginDir,
id: "apn-channel",
channels: ["apn"],
});
const result = applyPluginAutoEnable({
config: makeApnChannelConfig(),
env: {
...process.env,
OPENCLAW_HOME: undefined,
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins",
},
});
expect(result.config.plugins?.entries?.["apn-channel"]?.enabled).toBe(true);
expect(result.config.plugins?.entries?.apn).toBeUndefined();
});
});
});

View File

@@ -1,850 +1,10 @@
import fs from "node:fs";
import path from "node:path";
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 PluginManifestRegistry,
} from "../plugins/manifest-registry.js";
import { resolveOwningPluginIdsForModelRef } from "../plugins/providers.js";
import { isRecord, resolveConfigDir, resolveUserPath } from "../utils.js";
import { isChannelConfigured } from "./channel-configured.js";
import type { OpenClawConfig } from "./config.js";
import { ensurePluginAllowlisted } from "./plugins-allowlist.js";
import { isBlockedObjectKey } from "./prototype-keys.js";
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: "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: "acpx";
kind: "acp-runtime-configured";
};
export type PluginAutoEnableResult = {
config: OpenClawConfig;
changes: string[];
autoEnabledReasons: Record<string, string[]>;
};
const EMPTY_PLUGIN_MANIFEST_REGISTRY: PluginManifestRegistry = {
plugins: [],
diagnostics: [],
};
const ENV_CATALOG_PATHS = ["OPENCLAW_PLUGIN_CATALOG_PATHS", "OPENCLAW_MPM_CATALOG_PATHS"];
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);
}
}
};
const defaults = cfg.agents?.defaults as Record<string, unknown> | undefined;
collectFromAgent(defaults);
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;
}
}
}
const modelRefs = collectModelRefs(cfg);
for (const ref of modelRefs) {
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;
if (!isRecord(pluginConfig)) {
return false;
}
return isRecord(pluginConfig.webSearch);
}
function hasPluginOwnedWebFetchConfig(cfg: OpenClawConfig, pluginId: string): boolean {
const pluginConfig = cfg.plugins?.entries?.[pluginId]?.config;
if (!isRecord(pluginConfig)) {
return false;
}
return isRecord(pluginConfig.webFetch);
}
function hasPluginOwnedToolConfig(cfg: OpenClawConfig, pluginId: string): boolean {
if (pluginId === "xai") {
const pluginConfig = cfg.plugins?.entries?.xai?.config;
const web = cfg.tools?.web as Record<string, unknown> | undefined;
return Boolean(
isRecord(web?.x_search) ||
(isRecord(pluginConfig) &&
(isRecord(pluginConfig.xSearch) || isRecord(pluginConfig.codeExecution))),
);
}
return false;
}
function resolveProviderPluginsWithOwnedWebSearch(
registry: PluginManifestRegistry,
): ReadonlySet<string> {
const pluginIds = new Set<string>();
for (const plugin of registry.plugins) {
if (plugin.providers.length > 0 && (plugin.contracts?.webSearchProviders?.length ?? 0) > 0) {
pluginIds.add(plugin.id);
}
}
return pluginIds;
}
function resolveProviderPluginsWithOwnedWebFetch(
registry: PluginManifestRegistry,
): ReadonlySet<string> {
return new Set(
registry.plugins
.filter((plugin) => (plugin.contracts?.webFetchProviders?.length ?? 0) > 0)
.map((plugin) => plugin.id),
);
}
function resolvePluginIdForConfiguredWebFetchProvider(
providerId: string | undefined,
): string | undefined {
return resolveManifestContractOwnerPluginId({
contract: "webFetchProviders",
value: typeof providerId === "string" ? providerId.trim().toLowerCase() : "",
origin: "bundled",
env: process.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;
}
type ExternalCatalogChannelEntry = {
id: string;
preferOver: string[];
};
function splitEnvPaths(value: string): string[] {
const trimmed = value.trim();
if (!trimmed) {
return [];
}
return trimmed
.split(/[;,]/g)
.flatMap((chunk) => chunk.split(path.delimiter))
.map((entry) => entry.trim())
.filter(Boolean);
}
function resolveExternalCatalogPaths(env: NodeJS.ProcessEnv): string[] {
for (const key of ENV_CATALOG_PATHS) {
const raw = env[key];
if (raw && raw.trim()) {
return splitEnvPaths(raw);
}
}
const configDir = resolveConfigDir(env);
return [
path.join(configDir, "mpm", "plugins.json"),
path.join(configDir, "mpm", "catalog.json"),
path.join(configDir, "plugins", "catalog.json"),
];
}
function parseExternalCatalogChannelEntries(raw: unknown): ExternalCatalogChannelEntry[] {
const list = (() => {
if (Array.isArray(raw)) {
return raw;
}
if (!isRecord(raw)) {
return [];
}
const entries = raw.entries ?? raw.packages ?? raw.plugins;
return Array.isArray(entries) ? entries : [];
})();
const channels: ExternalCatalogChannelEntry[] = [];
for (const entry of list) {
if (!isRecord(entry) || !isRecord(entry.openclaw) || !isRecord(entry.openclaw.channel)) {
continue;
}
const channel = entry.openclaw.channel;
const id = typeof channel.id === "string" ? channel.id.trim() : "";
if (!id) {
continue;
}
const preferOver = Array.isArray(channel.preferOver)
? channel.preferOver.filter((value): value is string => typeof value === "string")
: [];
channels.push({ id, preferOver });
}
return channels;
}
function resolveExternalCatalogPreferOver(channelId: string, env: NodeJS.ProcessEnv): string[] {
for (const rawPath of resolveExternalCatalogPaths(env)) {
const resolved = resolveUserPath(rawPath, env);
if (!fs.existsSync(resolved)) {
continue;
}
try {
const payload = JSON.parse(fs.readFileSync(resolved, "utf-8")) as unknown;
const channel = parseExternalCatalogChannelEntries(payload).find(
(entry) => entry.id === channelId,
);
if (channel) {
return channel.preferOver;
}
} catch {
// Ignore invalid catalog files.
}
}
return [];
}
function resolvePluginIdForChannel(
channelId: string,
channelToPluginId: ReadonlyMap<string, string>,
): string {
// Third-party plugins can expose a channel id that differs from their
// manifest id; plugins.entries must always be keyed by manifest id.
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;
if (!entries || typeof entries !== "object") {
return false;
}
return Object.values(entries).some(
(entry) => isRecord(entry) && isRecord(entry.config) && isRecord(entry.config.webSearch),
);
}
function hasConfiguredWebFetchPluginEntry(cfg: OpenClawConfig): boolean {
const entries = cfg.plugins?.entries;
if (!entries || typeof entries !== "object") {
return false;
}
return Object.values(entries).some(
(entry) => isRecord(entry) && isRecord(entry.config) && isRecord(entry.config.webFetch),
);
}
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;
}
function configMayNeedPluginAutoEnable(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean {
if (hasPotentialConfiguredChannels(cfg, env)) {
return true;
}
if (resolveBrowserAutoEnableSource(cfg)) {
return true;
}
if (cfg.acp?.enabled === true || cfg.acp?.dispatch?.enabled === true) {
return true;
}
if (typeof cfg.acp?.backend === "string" && cfg.acp.backend.trim().length > 0) {
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 web = cfg.tools?.web as Record<string, unknown> | undefined;
if (isRecord(web?.x_search)) {
return true;
}
if (
isRecord(cfg.plugins?.entries?.xai?.config) ||
hasConfiguredWebSearchPluginEntry(cfg) ||
hasConfiguredWebFetchPluginEntry(cfg)
) {
return true;
}
return false;
}
function listContainsBrowser(value: unknown): boolean {
return (
Array.isArray(value) &&
value.some((entry) => typeof entry === "string" && entry.trim().toLowerCase() === "browser")
);
}
function toolPolicyReferencesBrowser(value: unknown): boolean {
if (!isRecord(value)) {
return false;
}
return listContainsBrowser(value.allow) || listContainsBrowser(value.alsoAllow);
}
function hasBrowserToolReference(cfg: OpenClawConfig): boolean {
if (toolPolicyReferencesBrowser(cfg.tools)) {
return true;
}
const agentList = cfg.agents?.list;
if (!Array.isArray(agentList)) {
return false;
}
return agentList.some((entry) => isRecord(entry) && toolPolicyReferencesBrowser(entry.tools));
}
function hasExplicitBrowserPluginEntry(cfg: OpenClawConfig): boolean {
return Boolean(
cfg.plugins?.entries && Object.prototype.hasOwnProperty.call(cfg.plugins.entries, "browser"),
);
}
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";
}
if (hasExplicitBrowserPluginEntry(cfg)) {
return "browser-plugin-configured";
}
if (hasBrowserToolReference(cfg)) {
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 "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 "acp-runtime-configured":
return "ACP runtime configured";
}
}
function resolveConfiguredPlugins(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv,
registry: PluginManifestRegistry,
): 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)) {
const pluginId = resolvePluginIdForChannel(channelId, channelToPluginId);
if (isChannelConfigured(cfg, channelId, env)) {
changes.push({ pluginId, kind: "channel-configured", channelId });
}
}
const browserSource = resolveBrowserAutoEnableSource(cfg);
if (browserSource) {
changes.push({ pluginId: "browser", kind: "browser-configured", source: browserSource });
}
for (const [providerId, pluginId] of Object.entries(
resolveAutoEnableProviderPluginIds(registry),
)) {
if (isProviderConfigured(cfg, providerId)) {
changes.push({
pluginId,
kind: "provider-auth-configured",
providerId,
});
}
}
for (const modelRef of collectModelRefs(cfg)) {
const owningPluginIds = resolveOwningPluginIdsForModelRef({
model: modelRef,
config: cfg,
env,
manifestRegistry: registry,
});
if (owningPluginIds?.length !== 1) {
continue;
}
changes.push({
pluginId: owningPluginIds[0],
kind: "provider-model-configured",
modelRef,
});
}
const webFetchProvider =
typeof cfg.tools?.web?.fetch?.provider === "string" ? cfg.tools.web.fetch.provider : undefined;
const webFetchPluginId = resolvePluginIdForConfiguredWebFetchProvider(webFetchProvider);
if (webFetchPluginId) {
changes.push({
pluginId: webFetchPluginId,
kind: "web-fetch-provider-selected",
providerId: String(webFetchProvider).trim().toLowerCase(),
});
}
for (const pluginId of resolveProviderPluginsWithOwnedWebSearch(registry)) {
if (hasPluginOwnedWebSearchConfig(cfg, pluginId)) {
changes.push({
pluginId,
kind: "plugin-web-search-configured",
});
}
}
for (const pluginId of resolveProviderPluginsWithOwnedWebFetch(registry)) {
if (hasPluginOwnedWebFetchConfig(cfg, pluginId)) {
changes.push({
pluginId,
kind: "plugin-web-fetch-configured",
});
}
}
for (const pluginId of resolveProviderPluginsWithOwnedWebSearch(registry)) {
if (hasPluginOwnedToolConfig(cfg, pluginId)) {
changes.push({
pluginId,
kind: "plugin-tool-configured",
});
}
}
const backendRaw =
typeof cfg.acp?.backend === "string" ? cfg.acp.backend.trim().toLowerCase() : "";
const acpConfigured =
cfg.acp?.enabled === true || cfg.acp?.dispatch?.enabled === true || backendRaw === "acpx";
if (acpConfigured && (!backendRaw || backendRaw === "acpx")) {
changes.push({
pluginId: "acpx",
kind: "acp-runtime-configured",
});
}
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;
}
}
const entry = cfg.plugins?.entries?.[pluginId];
return entry?.enabled === false;
}
function isPluginDenied(cfg: OpenClawConfig, pluginId: string): boolean {
const deny = cfg.plugins?.deny;
return Array.isArray(deny) && deny.includes(pluginId);
}
function resolvePreferredOverIds(
pluginId: string,
env: NodeJS.ProcessEnv,
registry: PluginManifestRegistry,
): string[] {
const normalized = normalizeChatChannelId(pluginId);
if (normalized) {
return [...(getChatChannelMeta(normalized).preferOver ?? [])];
}
const installedPlugin = registry.plugins.find((record) => record.id === pluginId);
const manifestChannelPreferOver = installedPlugin?.channelConfigs?.[pluginId]?.preferOver;
if (manifestChannelPreferOver?.length) {
return [...manifestChannelPreferOver];
}
const installedChannelMeta = installedPlugin?.channelCatalogMeta;
if (installedChannelMeta?.preferOver?.length) {
return [...installedChannelMeta.preferOver];
}
return resolveExternalCatalogPreferOver(pluginId, env);
}
function shouldSkipPreferredPluginAutoEnable(
cfg: OpenClawConfig,
entry: PluginAutoEnableCandidate,
configured: PluginAutoEnableCandidate[],
env: NodeJS.ProcessEnv,
registry: PluginManifestRegistry,
): boolean {
for (const other of configured) {
if (other.pluginId === entry.pluginId) {
continue;
}
if (isPluginDenied(cfg, other.pluginId)) {
continue;
}
if (isPluginExplicitlyDisabled(cfg, other.pluginId)) {
continue;
}
const preferOver = resolvePreferredOverIds(other.pluginId, env, registry);
if (preferOver.includes(entry.pluginId)) {
return true;
}
}
return false;
}
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,
},
},
};
}
const entries = {
...cfg.plugins?.entries,
[pluginId]: {
...(cfg.plugins?.entries?.[pluginId] as Record<string, unknown> | undefined),
enabled: true,
},
};
return {
...cfg,
plugins: {
...cfg.plugins,
entries,
},
};
}
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 detectPluginAutoEnableCandidates(params: {
config?: OpenClawConfig;
env?: NodeJS.ProcessEnv;
manifestRegistry?: PluginManifestRegistry;
}): PluginAutoEnableCandidate[] {
const env = params.env ?? process.env;
const config = params.config ?? ({} as OpenClawConfig);
if (!configMayNeedPluginAutoEnable(config, env)) {
return [];
}
const registry =
params.manifestRegistry ??
(configMayNeedPluginManifestRegistry(config)
? loadPluginManifestRegistry({ config, env })
: EMPTY_PLUGIN_MANIFEST_REGISTRY);
return resolveConfiguredPlugins(config, env, registry);
}
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 params.candidates) {
const builtInChannelId = normalizeChatChannelId(entry.pluginId);
if (isPluginDenied(next, entry.pluginId)) {
continue;
}
if (isPluginExplicitlyDisabled(next, entry.pluginId)) {
continue;
}
if (shouldSkipPreferredPluginAutoEnable(next, entry, [...params.candidates], env, registry)) {
continue;
}
const allow = next.plugins?.allow;
const allowMissing =
builtInChannelId == null && Array.isArray(allow) && !allow.includes(entry.pluginId);
const alreadyEnabled =
builtInChannelId != null
? (() => {
const channels = next.channels as Record<string, unknown> | undefined;
const channelConfig = channels?.[builtInChannelId];
if (
!channelConfig ||
typeof channelConfig !== "object" ||
Array.isArray(channelConfig)
) {
return false;
}
return (channelConfig as { enabled?: unknown }).enabled === true;
})()
: 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)) {
continue;
}
autoEnabledReasonRecord[pluginId] = [...reasons];
}
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,
});
}
export {
applyPluginAutoEnable,
materializePluginAutoEnableCandidates,
} from "./plugin-auto-enable.apply.js";
export { detectPluginAutoEnableCandidates } from "./plugin-auto-enable.detect.js";
export type {
PluginAutoEnableCandidate,
PluginAutoEnableResult,
} from "./plugin-auto-enable.shared.js";
export { resolvePluginAutoEnableCandidateReason } from "./plugin-auto-enable.shared.js";

View File

@@ -1,4 +1,5 @@
import type { OpenClawConfig } from "../config/config.js";
import type { PluginManifestRecord } from "./manifest-registry.js";
import { resolvePluginWebSearchProviders } from "./web-search-providers.runtime.js";
function hasConfiguredCredentialValue(value: unknown): boolean {
@@ -8,10 +9,12 @@ function hasConfiguredCredentialValue(value: unknown): boolean {
return value !== undefined && value !== null;
}
export function hasBundledWebSearchCredential(params: {
export function hasConfiguredWebSearchCredential(params: {
config: OpenClawConfig;
env?: NodeJS.ProcessEnv;
searchConfig?: Record<string, unknown>;
origin?: PluginManifestRecord["origin"];
bundledAllowlistCompat?: boolean;
}): boolean {
const searchConfig =
params.searchConfig ??
@@ -19,8 +22,8 @@ export function hasBundledWebSearchCredential(params: {
return resolvePluginWebSearchProviders({
config: params.config,
env: params.env,
bundledAllowlistCompat: true,
origin: "bundled",
bundledAllowlistCompat: params.bundledAllowlistCompat ?? false,
origin: params.origin,
}).some((provider) => {
const configuredCredential =
provider.getConfiguredCredentialValue?.(params.config) ??

View File

@@ -23,7 +23,7 @@ import {
DEFAULT_DANGEROUS_NODE_COMMANDS,
resolveNodeCommandAllowlist,
} from "../gateway/node-command-policy.js";
import { hasBundledWebSearchCredential } from "../plugins/bundled-web-search-registry.js";
import { hasConfiguredWebSearchCredential } from "../plugins/web-search-credential-presence.js";
import { inferParamBFromIdOrName } from "../shared/model-param-b.js";
import { pickSandboxToolPolicy } from "./audit-tool-policy.js";
@@ -326,7 +326,12 @@ function resolveToolPolicies(params: {
}
function hasWebSearchKey(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean {
return hasBundledWebSearchCredential({ config: cfg, env });
return hasConfiguredWebSearchCredential({
config: cfg,
env,
origin: "bundled",
bundledAllowlistCompat: true,
});
}
function isWebSearchEnabled(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean {