diff --git a/src/config/plugin-auto-enable.apply.ts b/src/config/plugin-auto-enable.apply.ts new file mode 100644 index 00000000000..2fb0cadaa72 --- /dev/null +++ b/src/config/plugin-auto-enable.apply.ts @@ -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, + }); +} diff --git a/src/config/plugin-auto-enable.channels.test.ts b/src/config/plugin-auto-enable.channels.test.ts new file mode 100644 index 00000000000..1c6d06b0bc2 --- /dev/null +++ b/src/config/plugin-auto-enable.channels.test.ts @@ -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 }; +}) { + return applyPluginAutoEnable({ + config: { + ...makeApnChannelConfig(), + ...(extra?.plugins ? { plugins: extra.plugins } : {}), + }, + env: makeIsolatedEnv(), + manifestRegistry: makeRegistry([{ id: "apn-channel", channels: ["apn"] }]), + }); +} + +function applyWithBluebubblesImessageConfig(extra?: { + plugins?: { entries?: Record; 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(); + }); + }); +}); diff --git a/src/config/plugin-auto-enable.core.test.ts b/src/config/plugin-auto-enable.core.test.ts new file mode 100644 index 00000000000..62202626e06 --- /dev/null +++ b/src/config/plugin-auto-enable.core.test.ts @@ -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..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([]); + }); +}); diff --git a/src/config/plugin-auto-enable.detect.ts b/src/config/plugin-auto-enable.detect.ts new file mode 100644 index 00000000000..b1655174622 --- /dev/null +++ b/src/config/plugin-auto-enable.detect.ts @@ -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, + }); +} diff --git a/src/config/plugin-auto-enable.model-support.test.ts b/src/config/plugin-auto-enable.model-support.test.ts index 81a40df7a97..945bed280f5 100644 --- a/src/config/plugin-auto-enable.model-support.test.ts +++ b/src/config/plugin-auto-enable.model-support.test.ts @@ -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", diff --git a/src/config/plugin-auto-enable.prefer-over.ts b/src/config/plugin-auto-enable.prefer-over.ts new file mode 100644 index 00000000000..d938b011a27 --- /dev/null +++ b/src/config/plugin-auto-enable.prefer-over.ts @@ -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; +} diff --git a/src/config/plugin-auto-enable.providers.test.ts b/src/config/plugin-auto-enable.providers.test.ts new file mode 100644 index 00000000000..fe92a6e9780 --- /dev/null +++ b/src/config/plugin-auto-enable.providers.test.ts @@ -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(); + }); +}); diff --git a/src/config/plugin-auto-enable.shared.ts b/src/config/plugin-auto-enable.shared.ts new file mode 100644 index 00000000000..47f95471727 --- /dev/null +++ b/src/config/plugin-auto-enable.shared.ts @@ -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; +}; + +const EMPTY_PLUGIN_MANIFEST_REGISTRY: PluginManifestRegistry = { + plugins: [], + diagnostics: [], +}; + +function resolveAutoEnableProviderPluginIds( + registry: PluginManifestRegistry, +): Readonly> { + const entries = new Map(); + 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 | 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 | 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 | undefined; + return Boolean( + isRecord(web?.x_search) || + (isRecord(pluginConfig) && + (isRecord(pluginConfig.xSearch) || isRecord(pluginConfig.codeExecution))), + ); +} + +function resolveProviderPluginsWithOwnedWebSearch( + registry: PluginManifestRegistry, +): ReadonlySet { + 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 { + 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 { + const map = new Map(); + 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 { + 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["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 | 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 | 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 | 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 | 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 | undefined; + const existing = channels?.[builtInChannelId]; + const existingRecord = + existing && typeof existing === "object" && !Array.isArray(existing) + ? (existing as Record) + : {}; + 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 | 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(); + + 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 = Object.create(null); + for (const [pluginId, reasons] of autoEnabledReasons) { + if (!isBlockedObjectKey(pluginId)) { + autoEnabledReasonRecord[pluginId] = [...reasons]; + } + } + + return { config: next, changes, autoEnabledReasons: autoEnabledReasonRecord }; +} diff --git a/src/config/plugin-auto-enable.test-helpers.ts b/src/config/plugin-auto-enable.test-helpers.ts new file mode 100644 index 00000000000..1a772c6195a --- /dev/null +++ b/src/config/plugin-auto-enable.test-helpers.ts @@ -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; 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" }, + }; +} diff --git a/src/config/plugin-auto-enable.test.ts b/src/config/plugin-auto-enable.test.ts deleted file mode 100644 index a695a7641d7..00000000000 --- a/src/config/plugin-auto-enable.test.ts +++ /dev/null @@ -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; 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 }; -}) { - return applyPluginAutoEnable({ - config: { - ...makeApnChannelConfig(), - ...(extra?.plugins ? { plugins: extra.plugins } : {}), - }, - env: {}, - manifestRegistry: makeRegistry([{ id: "apn-channel", channels: ["apn"] }]), - }); -} - -function applyWithBluebubblesImessageConfig(extra?: { - plugins?: { entries?: Record; 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..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(); - }); - }); -}); diff --git a/src/config/plugin-auto-enable.ts b/src/config/plugin-auto-enable.ts index 9f755988d7e..6ea4c05522b 100644 --- a/src/config/plugin-auto-enable.ts +++ b/src/config/plugin-auto-enable.ts @@ -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; -}; - -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> { - const entries = new Map(); - 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 | 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 | 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 | undefined; - return Boolean( - isRecord(web?.x_search) || - (isRecord(pluginConfig) && - (isRecord(pluginConfig.xSearch) || isRecord(pluginConfig.codeExecution))), - ); - } - return false; -} - -function resolveProviderPluginsWithOwnedWebSearch( - registry: PluginManifestRegistry, -): ReadonlySet { - const pluginIds = new Set(); - 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 { - 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 { - const map = new Map(); - 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 { - // 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 | 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 | 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["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 | 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 | undefined; - const existing = channels?.[builtInChannelId]; - const existingRecord = - existing && typeof existing === "object" && !Array.isArray(existing) - ? (existing as Record) - : {}; - return { - ...cfg, - channels: { - ...cfg.channels, - [builtInChannelId]: { - ...existingRecord, - enabled: true, - }, - }, - }; - } - const entries = { - ...cfg.plugins?.entries, - [pluginId]: { - ...(cfg.plugins?.entries?.[pluginId] as Record | 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(); - 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 | 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 = 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"; diff --git a/src/plugins/bundled-web-search-registry.ts b/src/plugins/web-search-credential-presence.ts similarity index 77% rename from src/plugins/bundled-web-search-registry.ts rename to src/plugins/web-search-credential-presence.ts index cb813c73a9e..6a8e4731f3e 100644 --- a/src/plugins/bundled-web-search-registry.ts +++ b/src/plugins/web-search-credential-presence.ts @@ -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; + 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) ?? diff --git a/src/security/audit-extra.sync.ts b/src/security/audit-extra.sync.ts index 45370dea89b..7458e70a942 100644 --- a/src/security/audit-extra.sync.ts +++ b/src/security/audit-extra.sync.ts @@ -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 {