mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-21 05:32:53 +00:00
refactor: simplify plugin auto-enable structure
This commit is contained in:
44
src/config/plugin-auto-enable.apply.ts
Normal file
44
src/config/plugin-auto-enable.apply.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
|
||||
import type { OpenClawConfig } from "./config.js";
|
||||
import { detectPluginAutoEnableCandidates } from "./plugin-auto-enable.detect.js";
|
||||
import {
|
||||
materializePluginAutoEnableCandidatesInternal,
|
||||
resolvePluginAutoEnableManifestRegistry,
|
||||
type PluginAutoEnableCandidate,
|
||||
type PluginAutoEnableResult,
|
||||
} from "./plugin-auto-enable.shared.js";
|
||||
|
||||
export function materializePluginAutoEnableCandidates(params: {
|
||||
config?: OpenClawConfig;
|
||||
candidates: readonly PluginAutoEnableCandidate[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
manifestRegistry?: PluginManifestRegistry;
|
||||
}): PluginAutoEnableResult {
|
||||
const env = params.env ?? process.env;
|
||||
const config = params.config ?? {};
|
||||
const manifestRegistry = resolvePluginAutoEnableManifestRegistry({
|
||||
config,
|
||||
env,
|
||||
manifestRegistry: params.manifestRegistry,
|
||||
});
|
||||
return materializePluginAutoEnableCandidatesInternal({
|
||||
config,
|
||||
candidates: params.candidates,
|
||||
env,
|
||||
manifestRegistry,
|
||||
});
|
||||
}
|
||||
|
||||
export function applyPluginAutoEnable(params: {
|
||||
config?: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
manifestRegistry?: PluginManifestRegistry;
|
||||
}): PluginAutoEnableResult {
|
||||
const candidates = detectPluginAutoEnableCandidates(params);
|
||||
return materializePluginAutoEnableCandidates({
|
||||
config: params.config,
|
||||
candidates,
|
||||
env: params.env,
|
||||
manifestRegistry: params.manifestRegistry,
|
||||
});
|
||||
}
|
||||
238
src/config/plugin-auto-enable.channels.test.ts
Normal file
238
src/config/plugin-auto-enable.channels.test.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { applyPluginAutoEnable } from "./plugin-auto-enable.js";
|
||||
import {
|
||||
makeApnChannelConfig,
|
||||
makeBluebubblesAndImessageChannels,
|
||||
makeIsolatedEnv,
|
||||
makeRegistry,
|
||||
makeTempDir,
|
||||
resetPluginAutoEnableTestState,
|
||||
writePluginManifestFixture,
|
||||
} from "./plugin-auto-enable.test-helpers.js";
|
||||
|
||||
function applyWithApnChannelConfig(extra?: {
|
||||
plugins?: { entries?: Record<string, { enabled: boolean }> };
|
||||
}) {
|
||||
return applyPluginAutoEnable({
|
||||
config: {
|
||||
...makeApnChannelConfig(),
|
||||
...(extra?.plugins ? { plugins: extra.plugins } : {}),
|
||||
},
|
||||
env: makeIsolatedEnv(),
|
||||
manifestRegistry: makeRegistry([{ id: "apn-channel", channels: ["apn"] }]),
|
||||
});
|
||||
}
|
||||
|
||||
function applyWithBluebubblesImessageConfig(extra?: {
|
||||
plugins?: { entries?: Record<string, { enabled: boolean }>; deny?: string[] };
|
||||
}) {
|
||||
return applyPluginAutoEnable({
|
||||
config: {
|
||||
channels: makeBluebubblesAndImessageChannels(),
|
||||
...(extra?.plugins ? { plugins: extra.plugins } : {}),
|
||||
},
|
||||
env: makeIsolatedEnv(),
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
resetPluginAutoEnableTestState();
|
||||
});
|
||||
|
||||
describe("applyPluginAutoEnable channels", () => {
|
||||
it("uses env-scoped catalog metadata for preferOver auto-enable decisions", () => {
|
||||
const stateDir = makeTempDir();
|
||||
const catalogPath = path.join(stateDir, "plugins", "catalog.json");
|
||||
fs.mkdirSync(path.dirname(catalogPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
catalogPath,
|
||||
JSON.stringify({
|
||||
entries: [
|
||||
{
|
||||
name: "@openclaw/env-secondary",
|
||||
openclaw: {
|
||||
channel: {
|
||||
id: "env-secondary",
|
||||
label: "Env Secondary",
|
||||
selectionLabel: "Env Secondary",
|
||||
docsPath: "/channels/env-secondary",
|
||||
blurb: "Env secondary entry",
|
||||
preferOver: ["env-primary"],
|
||||
},
|
||||
install: {
|
||||
npmSpec: "@openclaw/env-secondary",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
channels: {
|
||||
"env-primary": { token: "primary" },
|
||||
"env-secondary": { token: "secondary" },
|
||||
},
|
||||
},
|
||||
env: {
|
||||
...makeIsolatedEnv(),
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
},
|
||||
manifestRegistry: makeRegistry([]),
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.["env-secondary"]?.enabled).toBe(true);
|
||||
expect(result.config.plugins?.entries?.["env-primary"]).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("third-party channel plugins (pluginId ≠ channelId)", () => {
|
||||
it("uses the plugin manifest id, not the channel id, for plugins.entries", () => {
|
||||
const result = applyWithApnChannelConfig();
|
||||
|
||||
expect(result.config.plugins?.entries?.["apn-channel"]?.enabled).toBe(true);
|
||||
expect(result.config.plugins?.entries?.apn).toBeUndefined();
|
||||
expect(result.changes.join("\n")).toContain("apn configured, enabled automatically.");
|
||||
});
|
||||
|
||||
it("does not double-enable when plugin is already enabled under its plugin id", () => {
|
||||
const result = applyWithApnChannelConfig({
|
||||
plugins: { entries: { "apn-channel": { enabled: true } } },
|
||||
});
|
||||
|
||||
expect(result.changes).toEqual([]);
|
||||
});
|
||||
|
||||
it("respects explicit disable of the plugin by its plugin id", () => {
|
||||
const result = applyWithApnChannelConfig({
|
||||
plugins: { entries: { "apn-channel": { enabled: false } } },
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.["apn-channel"]?.enabled).toBe(false);
|
||||
expect(result.changes).toEqual([]);
|
||||
});
|
||||
|
||||
it("falls back to channel key as plugin id when no installed manifest declares the channel", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
channels: { "unknown-chan": { someKey: "value" } },
|
||||
},
|
||||
env: makeIsolatedEnv(),
|
||||
manifestRegistry: makeRegistry([]),
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.["unknown-chan"]?.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("preferOver channel prioritization", () => {
|
||||
it("uses manifest channel config preferOver metadata for plugin channels", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
channels: {
|
||||
primary: { someKey: "value" },
|
||||
secondary: { someKey: "value" },
|
||||
},
|
||||
},
|
||||
env: makeIsolatedEnv(),
|
||||
manifestRegistry: makeRegistry([
|
||||
{
|
||||
id: "primary",
|
||||
channels: ["primary"],
|
||||
channelConfigs: {
|
||||
primary: {
|
||||
schema: { type: "object" },
|
||||
preferOver: ["secondary"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ id: "secondary", channels: ["secondary"] },
|
||||
]),
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.primary?.enabled).toBe(true);
|
||||
expect(result.config.plugins?.entries?.secondary?.enabled).toBeUndefined();
|
||||
expect(result.changes.join("\n")).toContain("primary configured, enabled automatically.");
|
||||
expect(result.changes.join("\n")).not.toContain(
|
||||
"secondary configured, enabled automatically.",
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers bluebubbles: skips imessage auto-configure when both are configured", () => {
|
||||
const result = applyWithBluebubblesImessageConfig();
|
||||
|
||||
expect(result.config.channels?.bluebubbles?.enabled).toBe(true);
|
||||
expect(result.config.plugins?.entries?.imessage?.enabled).toBeUndefined();
|
||||
expect(result.changes.join("\n")).toContain("BlueBubbles configured, enabled automatically.");
|
||||
expect(result.changes.join("\n")).not.toContain(
|
||||
"iMessage configured, enabled automatically.",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps imessage enabled if already explicitly enabled (non-destructive)", () => {
|
||||
const result = applyWithBluebubblesImessageConfig({
|
||||
plugins: { entries: { imessage: { enabled: true } } },
|
||||
});
|
||||
|
||||
expect(result.config.channels?.bluebubbles?.enabled).toBe(true);
|
||||
expect(result.config.plugins?.entries?.imessage?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("allows imessage auto-configure when bluebubbles is explicitly disabled", () => {
|
||||
const result = applyWithBluebubblesImessageConfig({
|
||||
plugins: { entries: { bluebubbles: { enabled: false } } },
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.bluebubbles?.enabled).toBe(false);
|
||||
expect(result.config.channels?.imessage?.enabled).toBe(true);
|
||||
expect(result.changes.join("\n")).toContain("iMessage configured, enabled automatically.");
|
||||
});
|
||||
|
||||
it("allows imessage auto-configure when bluebubbles is in deny list", () => {
|
||||
const result = applyWithBluebubblesImessageConfig({
|
||||
plugins: { deny: ["bluebubbles"] },
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.bluebubbles).toBeUndefined();
|
||||
expect(result.config.channels?.imessage?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("auto-enables imessage when only imessage is configured", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
channels: { imessage: { cliPath: "/usr/local/bin/imsg" } },
|
||||
},
|
||||
env: makeIsolatedEnv(),
|
||||
});
|
||||
|
||||
expect(result.config.channels?.imessage?.enabled).toBe(true);
|
||||
expect(result.changes.join("\n")).toContain("iMessage configured, enabled automatically.");
|
||||
});
|
||||
|
||||
it("uses the provided env when loading installed plugin manifests", () => {
|
||||
const stateDir = makeTempDir();
|
||||
const pluginDir = path.join(stateDir, "extensions", "apn-channel");
|
||||
writePluginManifestFixture({
|
||||
rootDir: pluginDir,
|
||||
id: "apn-channel",
|
||||
channels: ["apn"],
|
||||
});
|
||||
|
||||
const result = applyPluginAutoEnable({
|
||||
config: makeApnChannelConfig(),
|
||||
env: {
|
||||
...makeIsolatedEnv(),
|
||||
OPENCLAW_HOME: undefined,
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.["apn-channel"]?.enabled).toBe(true);
|
||||
expect(result.config.plugins?.entries?.apn).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
405
src/config/plugin-auto-enable.core.test.ts
Normal file
405
src/config/plugin-auto-enable.core.test.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyPluginAutoEnable,
|
||||
detectPluginAutoEnableCandidates,
|
||||
resolvePluginAutoEnableCandidateReason,
|
||||
} from "./plugin-auto-enable.js";
|
||||
import {
|
||||
makeIsolatedEnv,
|
||||
makeRegistry,
|
||||
makeTempDir,
|
||||
resetPluginAutoEnableTestState,
|
||||
writePluginManifestFixture,
|
||||
} from "./plugin-auto-enable.test-helpers.js";
|
||||
import { validateConfigObject } from "./validation.js";
|
||||
|
||||
afterEach(() => {
|
||||
resetPluginAutoEnableTestState();
|
||||
});
|
||||
|
||||
describe("applyPluginAutoEnable core", () => {
|
||||
it("detects typed channel-configured candidates", () => {
|
||||
const candidates = detectPluginAutoEnableCandidates({
|
||||
config: {
|
||||
channels: { slack: { botToken: "x" } },
|
||||
},
|
||||
env: makeIsolatedEnv(),
|
||||
});
|
||||
|
||||
expect(candidates).toEqual([
|
||||
{
|
||||
pluginId: "slack",
|
||||
kind: "channel-configured",
|
||||
channelId: "slack",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("formats typed provider-auth candidates into stable reasons", () => {
|
||||
expect(
|
||||
resolvePluginAutoEnableCandidateReason({
|
||||
pluginId: "google",
|
||||
kind: "provider-auth-configured",
|
||||
providerId: "google",
|
||||
}),
|
||||
).toBe("google auth configured");
|
||||
});
|
||||
|
||||
it("treats an undefined config as empty", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: undefined,
|
||||
env: makeIsolatedEnv(),
|
||||
});
|
||||
|
||||
expect(result.config).toEqual({});
|
||||
expect(result.changes).toEqual([]);
|
||||
expect(result.autoEnabledReasons).toEqual({});
|
||||
});
|
||||
|
||||
it("auto-enables built-in channels without appending to plugins.allow", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
channels: { slack: { botToken: "x" } },
|
||||
plugins: { allow: ["telegram"] },
|
||||
},
|
||||
env: makeIsolatedEnv(),
|
||||
});
|
||||
|
||||
expect(result.config.channels?.slack?.enabled).toBe(true);
|
||||
expect(result.config.plugins?.entries?.slack).toBeUndefined();
|
||||
expect(result.config.plugins?.allow).toEqual(["telegram"]);
|
||||
expect(result.autoEnabledReasons).toEqual({
|
||||
slack: ["slack configured"],
|
||||
});
|
||||
expect(result.changes.join("\n")).toContain("Slack configured, enabled automatically.");
|
||||
});
|
||||
|
||||
it("does not create plugins.allow when allowlist is unset", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
channels: { slack: { botToken: "x" } },
|
||||
},
|
||||
env: makeIsolatedEnv(),
|
||||
});
|
||||
|
||||
expect(result.config.channels?.slack?.enabled).toBe(true);
|
||||
expect(result.config.plugins?.allow).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stores auto-enable reasons in a null-prototype dictionary", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
channels: { slack: { botToken: "x" } },
|
||||
},
|
||||
env: makeIsolatedEnv(),
|
||||
});
|
||||
|
||||
expect(Object.getPrototypeOf(result.autoEnabledReasons)).toBeNull();
|
||||
});
|
||||
|
||||
it("auto-enables browser when browser config exists under a restrictive plugins.allow", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
browser: {
|
||||
defaultProfile: "openclaw",
|
||||
},
|
||||
plugins: {
|
||||
allow: ["telegram"],
|
||||
},
|
||||
},
|
||||
env: makeIsolatedEnv(),
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.allow).toEqual(["telegram", "browser"]);
|
||||
expect(result.config.plugins?.entries?.browser?.enabled).toBe(true);
|
||||
expect(result.changes).toContain("browser configured, enabled automatically.");
|
||||
});
|
||||
|
||||
it("auto-enables browser when tools.alsoAllow references browser", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
tools: {
|
||||
alsoAllow: ["browser"],
|
||||
},
|
||||
plugins: {
|
||||
allow: ["telegram"],
|
||||
},
|
||||
},
|
||||
env: makeIsolatedEnv(),
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.allow).toEqual(["telegram", "browser"]);
|
||||
expect(result.config.plugins?.entries?.browser?.enabled).toBe(true);
|
||||
expect(result.changes).toContain("browser tool referenced, enabled automatically.");
|
||||
});
|
||||
|
||||
it("keeps restrictive plugins.allow unchanged when browser is not referenced", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
plugins: {
|
||||
allow: ["telegram"],
|
||||
},
|
||||
},
|
||||
env: makeIsolatedEnv(),
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.allow).toEqual(["telegram"]);
|
||||
expect(result.config.plugins?.entries?.browser).toBeUndefined();
|
||||
expect(result.changes).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not auto-enable or allowlist non-bundled web fetch providers from config", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
tools: {
|
||||
web: {
|
||||
fetch: {
|
||||
provider: "evilfetch",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
allow: ["telegram"],
|
||||
},
|
||||
},
|
||||
env: makeIsolatedEnv(),
|
||||
manifestRegistry: makeRegistry([
|
||||
{
|
||||
id: "evil-plugin",
|
||||
channels: [],
|
||||
contracts: { webFetchProviders: ["evilfetch"] },
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.["evil-plugin"]).toBeUndefined();
|
||||
expect(result.config.plugins?.allow).toEqual(["telegram"]);
|
||||
expect(result.changes).toEqual([]);
|
||||
});
|
||||
|
||||
it("auto-enables bundled firecrawl when plugin-owned webFetch config exists", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
plugins: {
|
||||
allow: ["telegram"],
|
||||
entries: {
|
||||
firecrawl: {
|
||||
config: {
|
||||
webFetch: {
|
||||
apiKey: "firecrawl-key",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: makeIsolatedEnv(),
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.firecrawl?.enabled).toBe(true);
|
||||
expect(result.config.plugins?.allow).toEqual(["telegram", "firecrawl"]);
|
||||
expect(result.changes).toContain("firecrawl web fetch configured, enabled automatically.");
|
||||
});
|
||||
|
||||
it("skips auto-enable work for configs without channel or plugin-owned surfaces", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
gateway: {
|
||||
auth: {
|
||||
mode: "token",
|
||||
token: "ok",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [{ id: "pi" }],
|
||||
},
|
||||
},
|
||||
env: makeIsolatedEnv(),
|
||||
});
|
||||
|
||||
expect(result.config).toEqual({
|
||||
gateway: {
|
||||
auth: {
|
||||
mode: "token",
|
||||
token: "ok",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [{ id: "pi" }],
|
||||
},
|
||||
});
|
||||
expect(result.changes).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores channels.modelByChannel for plugin auto-enable", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
channels: {
|
||||
modelByChannel: {
|
||||
openai: {
|
||||
whatsapp: "openai/gpt-5.4",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: makeIsolatedEnv(),
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.modelByChannel).toBeUndefined();
|
||||
expect(result.config.plugins?.allow).toBeUndefined();
|
||||
expect(result.changes).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps auto-enabled WhatsApp config schema-valid", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
allowFrom: ["+15555550123"],
|
||||
},
|
||||
},
|
||||
},
|
||||
env: makeIsolatedEnv(),
|
||||
});
|
||||
|
||||
expect(result.config.channels?.whatsapp?.enabled).toBe(true);
|
||||
expect(validateConfigObject(result.config).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("does not append built-in WhatsApp to plugins.allow during auto-enable", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
allowFrom: ["+15555550123"],
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
allow: ["telegram"],
|
||||
},
|
||||
},
|
||||
env: makeIsolatedEnv(),
|
||||
});
|
||||
|
||||
expect(result.config.channels?.whatsapp?.enabled).toBe(true);
|
||||
expect(result.config.plugins?.allow).toEqual(["telegram"]);
|
||||
expect(validateConfigObject(result.config).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("does not re-emit built-in auto-enable changes when rerun with plugins.allow set", () => {
|
||||
const first = applyPluginAutoEnable({
|
||||
config: {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
allowFrom: ["+15555550123"],
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
allow: ["telegram"],
|
||||
},
|
||||
},
|
||||
env: makeIsolatedEnv(),
|
||||
});
|
||||
|
||||
const second = applyPluginAutoEnable({
|
||||
config: first.config,
|
||||
env: makeIsolatedEnv(),
|
||||
});
|
||||
|
||||
expect(first.changes).toHaveLength(1);
|
||||
expect(second.changes).toEqual([]);
|
||||
expect(second.config).toEqual(first.config);
|
||||
});
|
||||
|
||||
it("respects explicit disable", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
channels: { slack: { botToken: "x" } },
|
||||
plugins: { entries: { slack: { enabled: false } } },
|
||||
},
|
||||
env: makeIsolatedEnv(),
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.slack?.enabled).toBe(false);
|
||||
expect(result.changes).toEqual([]);
|
||||
});
|
||||
|
||||
it("respects built-in channel explicit disable via channels.<id>.enabled", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
channels: { slack: { botToken: "x", enabled: false } },
|
||||
},
|
||||
env: makeIsolatedEnv(),
|
||||
});
|
||||
|
||||
expect(result.config.channels?.slack?.enabled).toBe(false);
|
||||
expect(result.config.plugins?.entries?.slack).toBeUndefined();
|
||||
expect(result.changes).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not auto-enable plugin channels when only enabled=false is set", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
channels: { matrix: { enabled: false } },
|
||||
},
|
||||
env: makeIsolatedEnv(),
|
||||
manifestRegistry: makeRegistry([{ id: "matrix", channels: ["matrix"] }]),
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.matrix).toBeUndefined();
|
||||
expect(result.changes).toEqual([]);
|
||||
});
|
||||
|
||||
it("auto-enables irc when configured via env", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {},
|
||||
env: {
|
||||
...makeIsolatedEnv(),
|
||||
IRC_HOST: "irc.libera.chat",
|
||||
IRC_NICK: "openclaw-bot",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.config.channels?.irc?.enabled).toBe(true);
|
||||
expect(result.changes.join("\n")).toContain("IRC configured, enabled automatically.");
|
||||
});
|
||||
|
||||
it("uses the provided env when loading plugin manifests automatically", () => {
|
||||
const stateDir = makeTempDir();
|
||||
const pluginDir = path.join(stateDir, "extensions", "apn-channel");
|
||||
writePluginManifestFixture({
|
||||
rootDir: pluginDir,
|
||||
id: "apn-channel",
|
||||
channels: ["apn"],
|
||||
});
|
||||
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
channels: { apn: { someKey: "value" } },
|
||||
},
|
||||
env: {
|
||||
...makeIsolatedEnv(),
|
||||
OPENCLAW_HOME: undefined,
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.["apn-channel"]?.enabled).toBe(true);
|
||||
expect(result.config.plugins?.entries?.apn).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips when plugins are globally disabled", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
channels: { slack: { botToken: "x" } },
|
||||
plugins: { enabled: false },
|
||||
},
|
||||
env: makeIsolatedEnv(),
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.slack?.enabled).toBeUndefined();
|
||||
expect(result.changes).toEqual([]);
|
||||
});
|
||||
});
|
||||
30
src/config/plugin-auto-enable.detect.ts
Normal file
30
src/config/plugin-auto-enable.detect.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
|
||||
import type { OpenClawConfig } from "./config.js";
|
||||
import {
|
||||
configMayNeedPluginAutoEnable,
|
||||
resolveConfiguredPluginAutoEnableCandidates,
|
||||
resolvePluginAutoEnableManifestRegistry,
|
||||
type PluginAutoEnableCandidate,
|
||||
} from "./plugin-auto-enable.shared.js";
|
||||
|
||||
export function detectPluginAutoEnableCandidates(params: {
|
||||
config?: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
manifestRegistry?: PluginManifestRegistry;
|
||||
}): PluginAutoEnableCandidate[] {
|
||||
const env = params.env ?? process.env;
|
||||
const config = params.config ?? ({} as OpenClawConfig);
|
||||
if (!configMayNeedPluginAutoEnable(config, env)) {
|
||||
return [];
|
||||
}
|
||||
const registry = resolvePluginAutoEnableManifestRegistry({
|
||||
config,
|
||||
env,
|
||||
manifestRegistry: params.manifestRegistry,
|
||||
});
|
||||
return resolveConfiguredPluginAutoEnableCandidates({
|
||||
config,
|
||||
env,
|
||||
registry,
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
143
src/config/plugin-auto-enable.prefer-over.ts
Normal file
143
src/config/plugin-auto-enable.prefer-over.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { getChatChannelMeta, normalizeChatChannelId } from "../channels/registry.js";
|
||||
import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
|
||||
import { isRecord, resolveConfigDir, resolveUserPath } from "../utils.js";
|
||||
import type { OpenClawConfig } from "./config.js";
|
||||
import type { PluginAutoEnableCandidate } from "./plugin-auto-enable.shared.js";
|
||||
|
||||
type ExternalCatalogChannelEntry = {
|
||||
id: string;
|
||||
preferOver: string[];
|
||||
};
|
||||
|
||||
const ENV_CATALOG_PATHS = ["OPENCLAW_PLUGIN_CATALOG_PATHS", "OPENCLAW_MPM_CATALOG_PATHS"];
|
||||
|
||||
function splitEnvPaths(value: string): string[] {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return [];
|
||||
}
|
||||
return trimmed
|
||||
.split(/[;,]/g)
|
||||
.flatMap((chunk) => chunk.split(path.delimiter))
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function resolveExternalCatalogPaths(env: NodeJS.ProcessEnv): string[] {
|
||||
for (const key of ENV_CATALOG_PATHS) {
|
||||
const raw = env[key];
|
||||
if (raw && raw.trim()) {
|
||||
return splitEnvPaths(raw);
|
||||
}
|
||||
}
|
||||
const configDir = resolveConfigDir(env);
|
||||
return [
|
||||
path.join(configDir, "mpm", "plugins.json"),
|
||||
path.join(configDir, "mpm", "catalog.json"),
|
||||
path.join(configDir, "plugins", "catalog.json"),
|
||||
];
|
||||
}
|
||||
|
||||
function parseExternalCatalogChannelEntries(raw: unknown): ExternalCatalogChannelEntry[] {
|
||||
const list = (() => {
|
||||
if (Array.isArray(raw)) {
|
||||
return raw;
|
||||
}
|
||||
if (!isRecord(raw)) {
|
||||
return [];
|
||||
}
|
||||
const entries = raw.entries ?? raw.packages ?? raw.plugins;
|
||||
return Array.isArray(entries) ? entries : [];
|
||||
})();
|
||||
|
||||
const channels: ExternalCatalogChannelEntry[] = [];
|
||||
for (const entry of list) {
|
||||
if (!isRecord(entry) || !isRecord(entry.openclaw) || !isRecord(entry.openclaw.channel)) {
|
||||
continue;
|
||||
}
|
||||
const channel = entry.openclaw.channel;
|
||||
const id = typeof channel.id === "string" ? channel.id.trim() : "";
|
||||
if (!id) {
|
||||
continue;
|
||||
}
|
||||
const preferOver = Array.isArray(channel.preferOver)
|
||||
? channel.preferOver.filter((value): value is string => typeof value === "string")
|
||||
: [];
|
||||
channels.push({ id, preferOver });
|
||||
}
|
||||
return channels;
|
||||
}
|
||||
|
||||
function resolveExternalCatalogPreferOver(channelId: string, env: NodeJS.ProcessEnv): string[] {
|
||||
for (const rawPath of resolveExternalCatalogPaths(env)) {
|
||||
const resolved = resolveUserPath(rawPath, env);
|
||||
if (!fs.existsSync(resolved)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const payload = JSON.parse(fs.readFileSync(resolved, "utf-8")) as unknown;
|
||||
const channel = parseExternalCatalogChannelEntries(payload).find(
|
||||
(entry) => entry.id === channelId,
|
||||
);
|
||||
if (channel) {
|
||||
return channel.preferOver;
|
||||
}
|
||||
} catch {
|
||||
// Ignore invalid catalog files.
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function resolvePreferredOverIds(
|
||||
pluginId: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
registry: PluginManifestRegistry,
|
||||
): string[] {
|
||||
const normalized = normalizeChatChannelId(pluginId);
|
||||
if (normalized) {
|
||||
return [...(getChatChannelMeta(normalized).preferOver ?? [])];
|
||||
}
|
||||
const installedPlugin = registry.plugins.find((record) => record.id === pluginId);
|
||||
const manifestChannelPreferOver = installedPlugin?.channelConfigs?.[pluginId]?.preferOver;
|
||||
if (manifestChannelPreferOver?.length) {
|
||||
return [...manifestChannelPreferOver];
|
||||
}
|
||||
const installedChannelMeta = installedPlugin?.channelCatalogMeta;
|
||||
if (installedChannelMeta?.preferOver?.length) {
|
||||
return [...installedChannelMeta.preferOver];
|
||||
}
|
||||
return resolveExternalCatalogPreferOver(pluginId, env);
|
||||
}
|
||||
|
||||
export function shouldSkipPreferredPluginAutoEnable(params: {
|
||||
config: OpenClawConfig;
|
||||
entry: PluginAutoEnableCandidate;
|
||||
configured: readonly PluginAutoEnableCandidate[];
|
||||
env: NodeJS.ProcessEnv;
|
||||
registry: PluginManifestRegistry;
|
||||
isPluginDenied: (config: OpenClawConfig, pluginId: string) => boolean;
|
||||
isPluginExplicitlyDisabled: (config: OpenClawConfig, pluginId: string) => boolean;
|
||||
}): boolean {
|
||||
for (const other of params.configured) {
|
||||
if (other.pluginId === params.entry.pluginId) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
params.isPluginDenied(params.config, other.pluginId) ||
|
||||
params.isPluginExplicitlyDisabled(params.config, other.pluginId)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
resolvePreferredOverIds(other.pluginId, params.env, params.registry).includes(
|
||||
params.entry.pluginId,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
239
src/config/plugin-auto-enable.providers.test.ts
Normal file
239
src/config/plugin-auto-enable.providers.test.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { applyPluginAutoEnable } from "./plugin-auto-enable.js";
|
||||
import {
|
||||
makeIsolatedEnv,
|
||||
makeRegistry,
|
||||
resetPluginAutoEnableTestState,
|
||||
} from "./plugin-auto-enable.test-helpers.js";
|
||||
|
||||
afterEach(() => {
|
||||
resetPluginAutoEnableTestState();
|
||||
});
|
||||
|
||||
describe("applyPluginAutoEnable providers", () => {
|
||||
it("auto-enables provider auth plugins when profiles exist", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
auth: {
|
||||
profiles: {
|
||||
"google-gemini-cli:default": {
|
||||
provider: "google-gemini-cli",
|
||||
mode: "oauth",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: makeIsolatedEnv(),
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.google?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("auto-enables bundled provider plugins when plugin-owned web search config exists", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
xai: {
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: "xai-plugin-config-key",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: makeIsolatedEnv(),
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.xai?.enabled).toBe(true);
|
||||
expect(result.changes).toContain("xai web search configured, enabled automatically.");
|
||||
});
|
||||
|
||||
it("auto-enables xai when the plugin-owned x_search tool is configured", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
xai: {
|
||||
config: {
|
||||
xSearch: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: makeIsolatedEnv(),
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.xai?.enabled).toBe(true);
|
||||
expect(result.changes).toContain("xai tool configured, enabled automatically.");
|
||||
});
|
||||
|
||||
it("auto-enables xai when the plugin-owned codeExecution config is configured", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
xai: {
|
||||
config: {
|
||||
codeExecution: {
|
||||
enabled: true,
|
||||
model: "grok-4-1-fast",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: makeIsolatedEnv(),
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.xai?.enabled).toBe(true);
|
||||
expect(result.changes).toContain("xai tool configured, enabled automatically.");
|
||||
});
|
||||
|
||||
it("auto-enables minimax when minimax-portal profiles exist", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
auth: {
|
||||
profiles: {
|
||||
"minimax-portal:default": {
|
||||
provider: "minimax-portal",
|
||||
mode: "oauth",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: makeIsolatedEnv(),
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.minimax?.enabled).toBe(true);
|
||||
expect(result.config.plugins?.entries?.["minimax-portal-auth"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("auto-enables minimax when minimax API key auth is configured", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
auth: {
|
||||
profiles: {
|
||||
"minimax:default": {
|
||||
provider: "minimax",
|
||||
mode: "api_key",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: makeIsolatedEnv(),
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.minimax?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("does not auto-enable unrelated provider plugins just because auth profiles exist", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
auth: {
|
||||
profiles: {
|
||||
"openai:default": {
|
||||
provider: "openai",
|
||||
mode: "api_key",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: makeIsolatedEnv(),
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.openai).toBeUndefined();
|
||||
expect(result.changes).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses manifest-owned provider auto-enable metadata for third-party plugins", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
auth: {
|
||||
profiles: {
|
||||
"acme-oauth:default": {
|
||||
provider: "acme-oauth",
|
||||
mode: "oauth",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: makeIsolatedEnv(),
|
||||
manifestRegistry: makeRegistry([
|
||||
{
|
||||
id: "acme",
|
||||
channels: [],
|
||||
autoEnableWhenConfiguredProviders: ["acme-oauth"],
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.acme?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("auto-enables third-party provider plugins when manifest-owned web search config exists", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
acme: {
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: "acme-search-key",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: makeIsolatedEnv(),
|
||||
manifestRegistry: makeRegistry([
|
||||
{
|
||||
id: "acme",
|
||||
channels: [],
|
||||
providers: ["acme-ai"],
|
||||
contracts: {
|
||||
webSearchProviders: ["acme-search"],
|
||||
},
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.acme?.enabled).toBe(true);
|
||||
expect(result.changes).toContain("acme web search configured, enabled automatically.");
|
||||
});
|
||||
|
||||
it("auto-enables acpx plugin when ACP is configured", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
acp: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
env: makeIsolatedEnv(),
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.acpx?.enabled).toBe(true);
|
||||
expect(result.changes.join("\n")).toContain("ACP runtime configured, enabled automatically.");
|
||||
});
|
||||
|
||||
it("does not auto-enable acpx when a different ACP backend is configured", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
acp: {
|
||||
enabled: true,
|
||||
backend: "custom-runtime",
|
||||
},
|
||||
},
|
||||
env: makeIsolatedEnv(),
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.acpx?.enabled).toBeUndefined();
|
||||
});
|
||||
});
|
||||
672
src/config/plugin-auto-enable.shared.ts
Normal file
672
src/config/plugin-auto-enable.shared.ts
Normal file
@@ -0,0 +1,672 @@
|
||||
import { normalizeProviderId } from "../agents/model-selection.js";
|
||||
import {
|
||||
hasPotentialConfiguredChannels,
|
||||
listPotentialConfiguredChannelIds,
|
||||
} from "../channels/config-presence.js";
|
||||
import { getChatChannelMeta, normalizeChatChannelId } from "../channels/registry.js";
|
||||
import {
|
||||
loadPluginManifestRegistry,
|
||||
resolveManifestContractOwnerPluginId,
|
||||
type PluginManifestRegistry,
|
||||
} from "../plugins/manifest-registry.js";
|
||||
import { resolveOwningPluginIdsForModelRef } from "../plugins/providers.js";
|
||||
import { isRecord } from "../utils.js";
|
||||
import { isChannelConfigured } from "./channel-configured.js";
|
||||
import type { OpenClawConfig } from "./config.js";
|
||||
import { shouldSkipPreferredPluginAutoEnable } from "./plugin-auto-enable.prefer-over.js";
|
||||
import { ensurePluginAllowlisted } from "./plugins-allowlist.js";
|
||||
import { isBlockedObjectKey } from "./prototype-keys.js";
|
||||
|
||||
export type PluginAutoEnableCandidate =
|
||||
| {
|
||||
pluginId: string;
|
||||
kind: "channel-configured";
|
||||
channelId: string;
|
||||
}
|
||||
| {
|
||||
pluginId: "browser";
|
||||
kind: "browser-configured";
|
||||
source: "browser-configured" | "browser-plugin-configured" | "browser-tool-referenced";
|
||||
}
|
||||
| {
|
||||
pluginId: string;
|
||||
kind: "provider-auth-configured";
|
||||
providerId: string;
|
||||
}
|
||||
| {
|
||||
pluginId: string;
|
||||
kind: "provider-model-configured";
|
||||
modelRef: string;
|
||||
}
|
||||
| {
|
||||
pluginId: string;
|
||||
kind: "web-fetch-provider-selected";
|
||||
providerId: string;
|
||||
}
|
||||
| {
|
||||
pluginId: string;
|
||||
kind: "plugin-web-search-configured";
|
||||
}
|
||||
| {
|
||||
pluginId: string;
|
||||
kind: "plugin-web-fetch-configured";
|
||||
}
|
||||
| {
|
||||
pluginId: string;
|
||||
kind: "plugin-tool-configured";
|
||||
}
|
||||
| {
|
||||
pluginId: "acpx";
|
||||
kind: "acp-runtime-configured";
|
||||
};
|
||||
|
||||
export type PluginAutoEnableResult = {
|
||||
config: OpenClawConfig;
|
||||
changes: string[];
|
||||
autoEnabledReasons: Record<string, string[]>;
|
||||
};
|
||||
|
||||
const EMPTY_PLUGIN_MANIFEST_REGISTRY: PluginManifestRegistry = {
|
||||
plugins: [],
|
||||
diagnostics: [],
|
||||
};
|
||||
|
||||
function resolveAutoEnableProviderPluginIds(
|
||||
registry: PluginManifestRegistry,
|
||||
): Readonly<Record<string, string>> {
|
||||
const entries = new Map<string, string>();
|
||||
for (const plugin of registry.plugins) {
|
||||
for (const providerId of plugin.autoEnableWhenConfiguredProviders ?? []) {
|
||||
if (!entries.has(providerId)) {
|
||||
entries.set(providerId, plugin.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
function collectModelRefs(cfg: OpenClawConfig): string[] {
|
||||
const refs: string[] = [];
|
||||
const pushModelRef = (value: unknown) => {
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
refs.push(value.trim());
|
||||
}
|
||||
};
|
||||
const collectFromAgent = (agent: Record<string, unknown> | null | undefined) => {
|
||||
if (!agent) {
|
||||
return;
|
||||
}
|
||||
const model = agent.model;
|
||||
if (typeof model === "string") {
|
||||
pushModelRef(model);
|
||||
} else if (isRecord(model)) {
|
||||
pushModelRef(model.primary);
|
||||
const fallbacks = model.fallbacks;
|
||||
if (Array.isArray(fallbacks)) {
|
||||
for (const entry of fallbacks) {
|
||||
pushModelRef(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
const models = agent.models;
|
||||
if (isRecord(models)) {
|
||||
for (const key of Object.keys(models)) {
|
||||
pushModelRef(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
collectFromAgent(cfg.agents?.defaults as Record<string, unknown> | undefined);
|
||||
const list = cfg.agents?.list;
|
||||
if (Array.isArray(list)) {
|
||||
for (const entry of list) {
|
||||
if (isRecord(entry)) {
|
||||
collectFromAgent(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
function extractProviderFromModelRef(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
const slash = trimmed.indexOf("/");
|
||||
if (slash <= 0) {
|
||||
return null;
|
||||
}
|
||||
return normalizeProviderId(trimmed.slice(0, slash));
|
||||
}
|
||||
|
||||
function isProviderConfigured(cfg: OpenClawConfig, providerId: string): boolean {
|
||||
const normalized = normalizeProviderId(providerId);
|
||||
const profiles = cfg.auth?.profiles;
|
||||
if (profiles && typeof profiles === "object") {
|
||||
for (const profile of Object.values(profiles)) {
|
||||
if (!isRecord(profile)) {
|
||||
continue;
|
||||
}
|
||||
const provider = normalizeProviderId(String(profile.provider ?? ""));
|
||||
if (provider === normalized) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const providerConfig = cfg.models?.providers;
|
||||
if (providerConfig && typeof providerConfig === "object") {
|
||||
for (const key of Object.keys(providerConfig)) {
|
||||
if (normalizeProviderId(key) === normalized) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const ref of collectModelRefs(cfg)) {
|
||||
const provider = extractProviderFromModelRef(ref);
|
||||
if (provider && provider === normalized) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasPluginOwnedWebSearchConfig(cfg: OpenClawConfig, pluginId: string): boolean {
|
||||
const pluginConfig = cfg.plugins?.entries?.[pluginId]?.config;
|
||||
return isRecord(pluginConfig) && isRecord(pluginConfig.webSearch);
|
||||
}
|
||||
|
||||
function hasPluginOwnedWebFetchConfig(cfg: OpenClawConfig, pluginId: string): boolean {
|
||||
const pluginConfig = cfg.plugins?.entries?.[pluginId]?.config;
|
||||
return isRecord(pluginConfig) && isRecord(pluginConfig.webFetch);
|
||||
}
|
||||
|
||||
function hasPluginOwnedToolConfig(cfg: OpenClawConfig, pluginId: string): boolean {
|
||||
if (pluginId !== "xai") {
|
||||
return false;
|
||||
}
|
||||
const pluginConfig = cfg.plugins?.entries?.xai?.config;
|
||||
const web = cfg.tools?.web as Record<string, unknown> | undefined;
|
||||
return Boolean(
|
||||
isRecord(web?.x_search) ||
|
||||
(isRecord(pluginConfig) &&
|
||||
(isRecord(pluginConfig.xSearch) || isRecord(pluginConfig.codeExecution))),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveProviderPluginsWithOwnedWebSearch(
|
||||
registry: PluginManifestRegistry,
|
||||
): ReadonlySet<string> {
|
||||
return new Set(
|
||||
registry.plugins
|
||||
.filter((plugin) => plugin.providers.length > 0)
|
||||
.filter((plugin) => (plugin.contracts?.webSearchProviders?.length ?? 0) > 0)
|
||||
.map((plugin) => plugin.id),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveProviderPluginsWithOwnedWebFetch(
|
||||
registry: PluginManifestRegistry,
|
||||
): ReadonlySet<string> {
|
||||
return new Set(
|
||||
registry.plugins
|
||||
.filter((plugin) => (plugin.contracts?.webFetchProviders?.length ?? 0) > 0)
|
||||
.map((plugin) => plugin.id),
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePluginIdForConfiguredWebFetchProvider(
|
||||
providerId: string | undefined,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): string | undefined {
|
||||
return resolveManifestContractOwnerPluginId({
|
||||
contract: "webFetchProviders",
|
||||
value: typeof providerId === "string" ? providerId.trim().toLowerCase() : "",
|
||||
origin: "bundled",
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
||||
function buildChannelToPluginIdMap(registry: PluginManifestRegistry): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
for (const record of registry.plugins) {
|
||||
for (const channelId of record.channels) {
|
||||
if (channelId && !map.has(channelId)) {
|
||||
map.set(channelId, record.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function resolvePluginIdForChannel(
|
||||
channelId: string,
|
||||
channelToPluginId: ReadonlyMap<string, string>,
|
||||
): string {
|
||||
const builtInId = normalizeChatChannelId(channelId);
|
||||
if (builtInId) {
|
||||
return builtInId;
|
||||
}
|
||||
return channelToPluginId.get(channelId) ?? channelId;
|
||||
}
|
||||
|
||||
function collectCandidateChannelIds(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): string[] {
|
||||
return listPotentialConfiguredChannelIds(cfg, env).map(
|
||||
(channelId) => normalizeChatChannelId(channelId) ?? channelId,
|
||||
);
|
||||
}
|
||||
|
||||
function hasConfiguredWebSearchPluginEntry(cfg: OpenClawConfig): boolean {
|
||||
const entries = cfg.plugins?.entries;
|
||||
return (
|
||||
!!entries &&
|
||||
typeof entries === "object" &&
|
||||
Object.values(entries).some(
|
||||
(entry) => isRecord(entry) && isRecord(entry.config) && isRecord(entry.config.webSearch),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function hasConfiguredWebFetchPluginEntry(cfg: OpenClawConfig): boolean {
|
||||
const entries = cfg.plugins?.entries;
|
||||
return (
|
||||
!!entries &&
|
||||
typeof entries === "object" &&
|
||||
Object.values(entries).some(
|
||||
(entry) => isRecord(entry) && isRecord(entry.config) && isRecord(entry.config.webFetch),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function listContainsBrowser(value: unknown): boolean {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.some((entry) => typeof entry === "string" && entry.trim().toLowerCase() === "browser")
|
||||
);
|
||||
}
|
||||
|
||||
function toolPolicyReferencesBrowser(value: unknown): boolean {
|
||||
return (
|
||||
isRecord(value) && (listContainsBrowser(value.allow) || listContainsBrowser(value.alsoAllow))
|
||||
);
|
||||
}
|
||||
|
||||
function hasBrowserToolReference(cfg: OpenClawConfig): boolean {
|
||||
if (toolPolicyReferencesBrowser(cfg.tools)) {
|
||||
return true;
|
||||
}
|
||||
const agentList = cfg.agents?.list;
|
||||
return Array.isArray(agentList)
|
||||
? agentList.some((entry) => isRecord(entry) && toolPolicyReferencesBrowser(entry.tools))
|
||||
: false;
|
||||
}
|
||||
|
||||
function hasExplicitBrowserPluginEntry(cfg: OpenClawConfig): boolean {
|
||||
return Boolean(
|
||||
cfg.plugins?.entries && Object.prototype.hasOwnProperty.call(cfg.plugins.entries, "browser"),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveBrowserAutoEnableSource(
|
||||
cfg: OpenClawConfig,
|
||||
): Extract<PluginAutoEnableCandidate, { kind: "browser-configured" }>["source"] | null {
|
||||
if (cfg.browser?.enabled === false || cfg.plugins?.entries?.browser?.enabled === false) {
|
||||
return null;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(cfg, "browser")) {
|
||||
return "browser-configured";
|
||||
}
|
||||
if (hasExplicitBrowserPluginEntry(cfg)) {
|
||||
return "browser-plugin-configured";
|
||||
}
|
||||
if (hasBrowserToolReference(cfg)) {
|
||||
return "browser-tool-referenced";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function configMayNeedPluginManifestRegistry(cfg: OpenClawConfig): boolean {
|
||||
const pluginEntries = cfg.plugins?.entries;
|
||||
if (
|
||||
pluginEntries &&
|
||||
Object.values(pluginEntries).some((entry) => isRecord(entry) && isRecord(entry.config))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (cfg.auth?.profiles && Object.keys(cfg.auth.profiles).length > 0) {
|
||||
return true;
|
||||
}
|
||||
if (cfg.models?.providers && Object.keys(cfg.models.providers).length > 0) {
|
||||
return true;
|
||||
}
|
||||
if (collectModelRefs(cfg).length > 0) {
|
||||
return true;
|
||||
}
|
||||
const configuredChannels = cfg.channels as Record<string, unknown> | undefined;
|
||||
if (!configuredChannels || typeof configuredChannels !== "object") {
|
||||
return false;
|
||||
}
|
||||
for (const key of Object.keys(configuredChannels)) {
|
||||
if (key === "defaults" || key === "modelByChannel") {
|
||||
continue;
|
||||
}
|
||||
if (!normalizeChatChannelId(key)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function configMayNeedPluginAutoEnable(
|
||||
cfg: OpenClawConfig,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): boolean {
|
||||
if (hasPotentialConfiguredChannels(cfg, env)) {
|
||||
return true;
|
||||
}
|
||||
if (resolveBrowserAutoEnableSource(cfg)) {
|
||||
return true;
|
||||
}
|
||||
if (cfg.acp?.enabled === true || cfg.acp?.dispatch?.enabled === true) {
|
||||
return true;
|
||||
}
|
||||
if (typeof cfg.acp?.backend === "string" && cfg.acp.backend.trim().length > 0) {
|
||||
return true;
|
||||
}
|
||||
if (cfg.auth?.profiles && Object.keys(cfg.auth.profiles).length > 0) {
|
||||
return true;
|
||||
}
|
||||
if (cfg.models?.providers && Object.keys(cfg.models.providers).length > 0) {
|
||||
return true;
|
||||
}
|
||||
if (collectModelRefs(cfg).length > 0) {
|
||||
return true;
|
||||
}
|
||||
const web = cfg.tools?.web as Record<string, unknown> | undefined;
|
||||
return (
|
||||
isRecord(web?.x_search) ||
|
||||
isRecord(cfg.plugins?.entries?.xai?.config) ||
|
||||
hasConfiguredWebSearchPluginEntry(cfg) ||
|
||||
hasConfiguredWebFetchPluginEntry(cfg)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolvePluginAutoEnableCandidateReason(
|
||||
candidate: PluginAutoEnableCandidate,
|
||||
): string {
|
||||
switch (candidate.kind) {
|
||||
case "channel-configured":
|
||||
return `${candidate.channelId} configured`;
|
||||
case "browser-configured":
|
||||
switch (candidate.source) {
|
||||
case "browser-configured":
|
||||
return "browser configured";
|
||||
case "browser-plugin-configured":
|
||||
return "browser plugin configured";
|
||||
case "browser-tool-referenced":
|
||||
return "browser tool referenced";
|
||||
}
|
||||
break;
|
||||
case "provider-auth-configured":
|
||||
return `${candidate.providerId} auth configured`;
|
||||
case "provider-model-configured":
|
||||
return `${candidate.modelRef} model configured`;
|
||||
case "web-fetch-provider-selected":
|
||||
return `${candidate.providerId} web fetch provider selected`;
|
||||
case "plugin-web-search-configured":
|
||||
return `${candidate.pluginId} web search configured`;
|
||||
case "plugin-web-fetch-configured":
|
||||
return `${candidate.pluginId} web fetch configured`;
|
||||
case "plugin-tool-configured":
|
||||
return `${candidate.pluginId} tool configured`;
|
||||
case "acp-runtime-configured":
|
||||
return "ACP runtime configured";
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveConfiguredPluginAutoEnableCandidates(params: {
|
||||
config: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
registry: PluginManifestRegistry;
|
||||
}): PluginAutoEnableCandidate[] {
|
||||
const changes: PluginAutoEnableCandidate[] = [];
|
||||
const channelToPluginId = buildChannelToPluginIdMap(params.registry);
|
||||
for (const channelId of collectCandidateChannelIds(params.config, params.env)) {
|
||||
const pluginId = resolvePluginIdForChannel(channelId, channelToPluginId);
|
||||
if (isChannelConfigured(params.config, channelId, params.env)) {
|
||||
changes.push({ pluginId, kind: "channel-configured", channelId });
|
||||
}
|
||||
}
|
||||
|
||||
const browserSource = resolveBrowserAutoEnableSource(params.config);
|
||||
if (browserSource) {
|
||||
changes.push({ pluginId: "browser", kind: "browser-configured", source: browserSource });
|
||||
}
|
||||
|
||||
for (const [providerId, pluginId] of Object.entries(
|
||||
resolveAutoEnableProviderPluginIds(params.registry),
|
||||
)) {
|
||||
if (isProviderConfigured(params.config, providerId)) {
|
||||
changes.push({ pluginId, kind: "provider-auth-configured", providerId });
|
||||
}
|
||||
}
|
||||
|
||||
for (const modelRef of collectModelRefs(params.config)) {
|
||||
const owningPluginIds = resolveOwningPluginIdsForModelRef({
|
||||
model: modelRef,
|
||||
config: params.config,
|
||||
env: params.env,
|
||||
manifestRegistry: params.registry,
|
||||
});
|
||||
if (owningPluginIds?.length === 1) {
|
||||
changes.push({
|
||||
pluginId: owningPluginIds[0],
|
||||
kind: "provider-model-configured",
|
||||
modelRef,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const webFetchProvider =
|
||||
typeof params.config.tools?.web?.fetch?.provider === "string"
|
||||
? params.config.tools.web.fetch.provider
|
||||
: undefined;
|
||||
const webFetchPluginId = resolvePluginIdForConfiguredWebFetchProvider(
|
||||
webFetchProvider,
|
||||
params.env,
|
||||
);
|
||||
if (webFetchPluginId) {
|
||||
changes.push({
|
||||
pluginId: webFetchPluginId,
|
||||
kind: "web-fetch-provider-selected",
|
||||
providerId: String(webFetchProvider).trim().toLowerCase(),
|
||||
});
|
||||
}
|
||||
|
||||
for (const pluginId of resolveProviderPluginsWithOwnedWebSearch(params.registry)) {
|
||||
if (hasPluginOwnedWebSearchConfig(params.config, pluginId)) {
|
||||
changes.push({ pluginId, kind: "plugin-web-search-configured" });
|
||||
}
|
||||
if (hasPluginOwnedToolConfig(params.config, pluginId)) {
|
||||
changes.push({ pluginId, kind: "plugin-tool-configured" });
|
||||
}
|
||||
}
|
||||
|
||||
for (const pluginId of resolveProviderPluginsWithOwnedWebFetch(params.registry)) {
|
||||
if (hasPluginOwnedWebFetchConfig(params.config, pluginId)) {
|
||||
changes.push({ pluginId, kind: "plugin-web-fetch-configured" });
|
||||
}
|
||||
}
|
||||
|
||||
const backendRaw =
|
||||
typeof params.config.acp?.backend === "string"
|
||||
? params.config.acp.backend.trim().toLowerCase()
|
||||
: "";
|
||||
const acpConfigured =
|
||||
params.config.acp?.enabled === true ||
|
||||
params.config.acp?.dispatch?.enabled === true ||
|
||||
backendRaw === "acpx";
|
||||
if (acpConfigured && (!backendRaw || backendRaw === "acpx")) {
|
||||
changes.push({ pluginId: "acpx", kind: "acp-runtime-configured" });
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
function isPluginExplicitlyDisabled(cfg: OpenClawConfig, pluginId: string): boolean {
|
||||
const builtInChannelId = normalizeChatChannelId(pluginId);
|
||||
if (builtInChannelId) {
|
||||
const channels = cfg.channels as Record<string, unknown> | undefined;
|
||||
const channelConfig = channels?.[builtInChannelId];
|
||||
if (
|
||||
channelConfig &&
|
||||
typeof channelConfig === "object" &&
|
||||
!Array.isArray(channelConfig) &&
|
||||
(channelConfig as { enabled?: unknown }).enabled === false
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return cfg.plugins?.entries?.[pluginId]?.enabled === false;
|
||||
}
|
||||
|
||||
function isPluginDenied(cfg: OpenClawConfig, pluginId: string): boolean {
|
||||
const deny = cfg.plugins?.deny;
|
||||
return Array.isArray(deny) && deny.includes(pluginId);
|
||||
}
|
||||
|
||||
function isBuiltInChannelAlreadyEnabled(cfg: OpenClawConfig, channelId: string): boolean {
|
||||
const channels = cfg.channels as Record<string, unknown> | undefined;
|
||||
const channelConfig = channels?.[channelId];
|
||||
return (
|
||||
!!channelConfig &&
|
||||
typeof channelConfig === "object" &&
|
||||
!Array.isArray(channelConfig) &&
|
||||
(channelConfig as { enabled?: unknown }).enabled === true
|
||||
);
|
||||
}
|
||||
|
||||
function registerPluginEntry(cfg: OpenClawConfig, pluginId: string): OpenClawConfig {
|
||||
const builtInChannelId = normalizeChatChannelId(pluginId);
|
||||
if (builtInChannelId) {
|
||||
const channels = cfg.channels as Record<string, unknown> | undefined;
|
||||
const existing = channels?.[builtInChannelId];
|
||||
const existingRecord =
|
||||
existing && typeof existing === "object" && !Array.isArray(existing)
|
||||
? (existing as Record<string, unknown>)
|
||||
: {};
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
[builtInChannelId]: {
|
||||
...existingRecord,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
plugins: {
|
||||
...cfg.plugins,
|
||||
entries: {
|
||||
...cfg.plugins?.entries,
|
||||
[pluginId]: {
|
||||
...(cfg.plugins?.entries?.[pluginId] as Record<string, unknown> | undefined),
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function formatAutoEnableChange(entry: PluginAutoEnableCandidate): string {
|
||||
let reason = resolvePluginAutoEnableCandidateReason(entry).trim();
|
||||
const channelId = normalizeChatChannelId(entry.pluginId);
|
||||
if (channelId) {
|
||||
const label = getChatChannelMeta(channelId).label;
|
||||
reason = reason.replace(new RegExp(`^${channelId}\\b`, "i"), label);
|
||||
}
|
||||
return `${reason}, enabled automatically.`;
|
||||
}
|
||||
|
||||
export function resolvePluginAutoEnableManifestRegistry(params: {
|
||||
config: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
manifestRegistry?: PluginManifestRegistry;
|
||||
}): PluginManifestRegistry {
|
||||
return (
|
||||
params.manifestRegistry ??
|
||||
(configMayNeedPluginManifestRegistry(params.config)
|
||||
? loadPluginManifestRegistry({ config: params.config, env: params.env })
|
||||
: EMPTY_PLUGIN_MANIFEST_REGISTRY)
|
||||
);
|
||||
}
|
||||
|
||||
export function materializePluginAutoEnableCandidatesInternal(params: {
|
||||
config?: OpenClawConfig;
|
||||
candidates: readonly PluginAutoEnableCandidate[];
|
||||
env: NodeJS.ProcessEnv;
|
||||
manifestRegistry: PluginManifestRegistry;
|
||||
}): PluginAutoEnableResult {
|
||||
let next = params.config ?? {};
|
||||
const changes: string[] = [];
|
||||
const autoEnabledReasons = new Map<string, string[]>();
|
||||
|
||||
if (next.plugins?.enabled === false) {
|
||||
return { config: next, changes, autoEnabledReasons: {} };
|
||||
}
|
||||
|
||||
for (const entry of params.candidates) {
|
||||
const builtInChannelId = normalizeChatChannelId(entry.pluginId);
|
||||
if (isPluginDenied(next, entry.pluginId) || isPluginExplicitlyDisabled(next, entry.pluginId)) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
shouldSkipPreferredPluginAutoEnable({
|
||||
config: next,
|
||||
entry,
|
||||
configured: params.candidates,
|
||||
env: params.env,
|
||||
registry: params.manifestRegistry,
|
||||
isPluginDenied,
|
||||
isPluginExplicitlyDisabled,
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const allow = next.plugins?.allow;
|
||||
const allowMissing =
|
||||
builtInChannelId == null && Array.isArray(allow) && !allow.includes(entry.pluginId);
|
||||
const alreadyEnabled =
|
||||
builtInChannelId != null
|
||||
? isBuiltInChannelAlreadyEnabled(next, builtInChannelId)
|
||||
: next.plugins?.entries?.[entry.pluginId]?.enabled === true;
|
||||
if (alreadyEnabled && !allowMissing) {
|
||||
continue;
|
||||
}
|
||||
|
||||
next = registerPluginEntry(next, entry.pluginId);
|
||||
if (!builtInChannelId) {
|
||||
next = ensurePluginAllowlisted(next, entry.pluginId);
|
||||
}
|
||||
const reason = resolvePluginAutoEnableCandidateReason(entry);
|
||||
autoEnabledReasons.set(entry.pluginId, [
|
||||
...(autoEnabledReasons.get(entry.pluginId) ?? []),
|
||||
reason,
|
||||
]);
|
||||
changes.push(formatAutoEnableChange(entry));
|
||||
}
|
||||
|
||||
const autoEnabledReasonRecord: Record<string, string[]> = Object.create(null);
|
||||
for (const [pluginId, reasons] of autoEnabledReasons) {
|
||||
if (!isBlockedObjectKey(pluginId)) {
|
||||
autoEnabledReasonRecord[pluginId] = [...reasons];
|
||||
}
|
||||
}
|
||||
|
||||
return { config: next, changes, autoEnabledReasons: autoEnabledReasonRecord };
|
||||
}
|
||||
97
src/config/plugin-auto-enable.test-helpers.ts
Normal file
97
src/config/plugin-auto-enable.test-helpers.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { clearPluginDiscoveryCache } from "../plugins/discovery.js";
|
||||
import {
|
||||
clearPluginManifestRegistryCache,
|
||||
type PluginManifestRegistry,
|
||||
} from "../plugins/manifest-registry.js";
|
||||
import {
|
||||
cleanupTrackedTempDirs,
|
||||
makeTrackedTempDir,
|
||||
mkdirSafeDir,
|
||||
} from "../plugins/test-helpers/fs-fixtures.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
export function resetPluginAutoEnableTestState(): void {
|
||||
clearPluginDiscoveryCache();
|
||||
clearPluginManifestRegistryCache();
|
||||
cleanupTrackedTempDirs(tempDirs);
|
||||
}
|
||||
|
||||
export function makeTempDir(): string {
|
||||
return makeTrackedTempDir("openclaw-plugin-auto-enable", tempDirs);
|
||||
}
|
||||
|
||||
export function makeIsolatedEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
|
||||
const rootDir = makeTempDir();
|
||||
return {
|
||||
OPENCLAW_STATE_DIR: path.join(rootDir, "state"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function writePluginManifestFixture(params: {
|
||||
rootDir: string;
|
||||
id: string;
|
||||
channels: string[];
|
||||
}): void {
|
||||
mkdirSafeDir(params.rootDir);
|
||||
fs.writeFileSync(
|
||||
path.join(params.rootDir, "openclaw.plugin.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
id: params.id,
|
||||
channels: params.channels,
|
||||
configSchema: { type: "object" },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(path.join(params.rootDir, "index.ts"), "export default {}", "utf-8");
|
||||
}
|
||||
|
||||
export function makeRegistry(
|
||||
plugins: Array<{
|
||||
id: string;
|
||||
channels: string[];
|
||||
autoEnableWhenConfiguredProviders?: string[];
|
||||
modelSupport?: { modelPrefixes?: string[]; modelPatterns?: string[] };
|
||||
contracts?: { webSearchProviders?: string[]; webFetchProviders?: string[] };
|
||||
providers?: string[];
|
||||
channelConfigs?: Record<string, { schema: Record<string, unknown>; preferOver?: string[] }>;
|
||||
}>,
|
||||
): PluginManifestRegistry {
|
||||
return {
|
||||
plugins: plugins.map((plugin) => ({
|
||||
id: plugin.id,
|
||||
channels: plugin.channels,
|
||||
autoEnableWhenConfiguredProviders: plugin.autoEnableWhenConfiguredProviders,
|
||||
modelSupport: plugin.modelSupport,
|
||||
contracts: plugin.contracts,
|
||||
channelConfigs: plugin.channelConfigs,
|
||||
providers: plugin.providers ?? [],
|
||||
cliBackends: [],
|
||||
skills: [],
|
||||
hooks: [],
|
||||
origin: "config" as const,
|
||||
rootDir: `/fake/${plugin.id}`,
|
||||
source: `/fake/${plugin.id}/index.js`,
|
||||
manifestPath: `/fake/${plugin.id}/openclaw.plugin.json`,
|
||||
})),
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function makeApnChannelConfig() {
|
||||
return { channels: { apn: { someKey: "value" } } };
|
||||
}
|
||||
|
||||
export function makeBluebubblesAndImessageChannels() {
|
||||
return {
|
||||
bluebubbles: { serverUrl: "http://localhost:1234", password: "x" },
|
||||
imessage: { cliPath: "/usr/local/bin/imsg" },
|
||||
};
|
||||
}
|
||||
@@ -1,935 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { clearPluginDiscoveryCache } from "../plugins/discovery.js";
|
||||
import {
|
||||
clearPluginManifestRegistryCache,
|
||||
type PluginManifestRegistry,
|
||||
} from "../plugins/manifest-registry.js";
|
||||
import {
|
||||
cleanupTrackedTempDirs,
|
||||
makeTrackedTempDir,
|
||||
mkdirSafeDir,
|
||||
} from "../plugins/test-helpers/fs-fixtures.js";
|
||||
import {
|
||||
applyPluginAutoEnable,
|
||||
detectPluginAutoEnableCandidates,
|
||||
resolvePluginAutoEnableCandidateReason,
|
||||
} from "./plugin-auto-enable.js";
|
||||
import { validateConfigObject } from "./validation.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function makeTempDir() {
|
||||
return makeTrackedTempDir("openclaw-plugin-auto-enable", tempDirs);
|
||||
}
|
||||
|
||||
function writePluginManifestFixture(params: { rootDir: string; id: string; channels: string[] }) {
|
||||
mkdirSafeDir(params.rootDir);
|
||||
fs.writeFileSync(
|
||||
path.join(params.rootDir, "openclaw.plugin.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
id: params.id,
|
||||
channels: params.channels,
|
||||
configSchema: { type: "object" },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(path.join(params.rootDir, "index.ts"), "export default {}", "utf-8");
|
||||
}
|
||||
|
||||
/** Helper to build a minimal PluginManifestRegistry for testing. */
|
||||
function makeRegistry(
|
||||
plugins: Array<{
|
||||
id: string;
|
||||
channels: string[];
|
||||
autoEnableWhenConfiguredProviders?: string[];
|
||||
modelSupport?: { modelPrefixes?: string[]; modelPatterns?: string[] };
|
||||
contracts?: { webFetchProviders?: string[] };
|
||||
channelConfigs?: Record<string, { schema: Record<string, unknown>; preferOver?: string[] }>;
|
||||
}>,
|
||||
): PluginManifestRegistry {
|
||||
return {
|
||||
plugins: plugins.map((p) => ({
|
||||
id: p.id,
|
||||
channels: p.channels,
|
||||
autoEnableWhenConfiguredProviders: p.autoEnableWhenConfiguredProviders,
|
||||
modelSupport: p.modelSupport,
|
||||
contracts: p.contracts,
|
||||
channelConfigs: p.channelConfigs,
|
||||
providers: [],
|
||||
cliBackends: [],
|
||||
skills: [],
|
||||
hooks: [],
|
||||
origin: "config" as const,
|
||||
rootDir: `/fake/${p.id}`,
|
||||
source: `/fake/${p.id}/index.js`,
|
||||
manifestPath: `/fake/${p.id}/openclaw.plugin.json`,
|
||||
})),
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
function makeApnChannelConfig() {
|
||||
return { channels: { apn: { someKey: "value" } } };
|
||||
}
|
||||
|
||||
function makeBluebubblesAndImessageChannels() {
|
||||
return {
|
||||
bluebubbles: { serverUrl: "http://localhost:1234", password: "x" },
|
||||
imessage: { cliPath: "/usr/local/bin/imsg" },
|
||||
};
|
||||
}
|
||||
|
||||
function applyWithSlackConfig(extra?: { plugins?: { allow?: string[] } }) {
|
||||
return applyPluginAutoEnable({
|
||||
config: {
|
||||
channels: { slack: { botToken: "x" } },
|
||||
...(extra?.plugins ? { plugins: extra.plugins } : {}),
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
}
|
||||
|
||||
function applyWithApnChannelConfig(extra?: {
|
||||
plugins?: { entries?: Record<string, { enabled: boolean }> };
|
||||
}) {
|
||||
return applyPluginAutoEnable({
|
||||
config: {
|
||||
...makeApnChannelConfig(),
|
||||
...(extra?.plugins ? { plugins: extra.plugins } : {}),
|
||||
},
|
||||
env: {},
|
||||
manifestRegistry: makeRegistry([{ id: "apn-channel", channels: ["apn"] }]),
|
||||
});
|
||||
}
|
||||
|
||||
function applyWithBluebubblesImessageConfig(extra?: {
|
||||
plugins?: { entries?: Record<string, { enabled: boolean }>; deny?: string[] };
|
||||
}) {
|
||||
return applyPluginAutoEnable({
|
||||
config: {
|
||||
channels: makeBluebubblesAndImessageChannels(),
|
||||
...(extra?.plugins ? { plugins: extra.plugins } : {}),
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
clearPluginDiscoveryCache();
|
||||
clearPluginManifestRegistryCache();
|
||||
cleanupTrackedTempDirs(tempDirs);
|
||||
});
|
||||
|
||||
describe("applyPluginAutoEnable", () => {
|
||||
it("detects typed channel-configured candidates", () => {
|
||||
const candidates = detectPluginAutoEnableCandidates({
|
||||
config: {
|
||||
channels: { slack: { botToken: "x" } },
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(candidates).toEqual([
|
||||
{
|
||||
pluginId: "slack",
|
||||
kind: "channel-configured",
|
||||
channelId: "slack",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("formats typed provider-auth candidates into stable reasons", () => {
|
||||
expect(
|
||||
resolvePluginAutoEnableCandidateReason({
|
||||
pluginId: "google",
|
||||
kind: "provider-auth-configured",
|
||||
providerId: "google",
|
||||
}),
|
||||
).toBe("google auth configured");
|
||||
});
|
||||
|
||||
it("treats an undefined config as empty", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: undefined,
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(result.config).toEqual({});
|
||||
expect(result.changes).toEqual([]);
|
||||
expect(result.autoEnabledReasons).toEqual({});
|
||||
});
|
||||
|
||||
it("auto-enables built-in channels without appending to plugins.allow", () => {
|
||||
const result = applyWithSlackConfig({ plugins: { allow: ["telegram"] } });
|
||||
|
||||
expect(result.config.channels?.slack?.enabled).toBe(true);
|
||||
expect(result.config.plugins?.entries?.slack).toBeUndefined();
|
||||
expect(result.config.plugins?.allow).toEqual(["telegram"]);
|
||||
expect(result.autoEnabledReasons).toEqual({
|
||||
slack: ["slack configured"],
|
||||
});
|
||||
expect(result.changes.join("\n")).toContain("Slack configured, enabled automatically.");
|
||||
});
|
||||
|
||||
it("does not create plugins.allow when allowlist is unset", () => {
|
||||
const result = applyWithSlackConfig();
|
||||
|
||||
expect(result.config.channels?.slack?.enabled).toBe(true);
|
||||
expect(result.config.plugins?.allow).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stores auto-enable reasons in a null-prototype dictionary", () => {
|
||||
const result = applyWithSlackConfig();
|
||||
|
||||
expect(Object.getPrototypeOf(result.autoEnabledReasons)).toBeNull();
|
||||
});
|
||||
|
||||
it("auto-enables browser when browser config exists under a restrictive plugins.allow", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
browser: {
|
||||
defaultProfile: "openclaw",
|
||||
},
|
||||
plugins: {
|
||||
allow: ["telegram"],
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.allow).toEqual(["telegram", "browser"]);
|
||||
expect(result.config.plugins?.entries?.browser?.enabled).toBe(true);
|
||||
expect(result.autoEnabledReasons).toEqual({
|
||||
browser: ["browser configured"],
|
||||
});
|
||||
expect(result.changes).toContain("browser configured, enabled automatically.");
|
||||
});
|
||||
|
||||
it("auto-enables browser when tools.alsoAllow references browser", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
tools: {
|
||||
alsoAllow: ["browser"],
|
||||
},
|
||||
plugins: {
|
||||
allow: ["telegram"],
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.allow).toEqual(["telegram", "browser"]);
|
||||
expect(result.config.plugins?.entries?.browser?.enabled).toBe(true);
|
||||
expect(result.changes).toContain("browser tool referenced, enabled automatically.");
|
||||
});
|
||||
|
||||
it("keeps restrictive plugins.allow unchanged when browser is not referenced", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
plugins: {
|
||||
allow: ["telegram"],
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.allow).toEqual(["telegram"]);
|
||||
expect(result.config.plugins?.entries?.browser).toBeUndefined();
|
||||
expect(result.changes).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not auto-enable or allowlist non-bundled web fetch providers from config", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
tools: {
|
||||
web: {
|
||||
fetch: {
|
||||
provider: "evilfetch",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
allow: ["telegram"],
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
manifestRegistry: makeRegistry([
|
||||
{
|
||||
id: "evil-plugin",
|
||||
channels: [],
|
||||
contracts: { webFetchProviders: ["evilfetch"] },
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.["evil-plugin"]).toBeUndefined();
|
||||
expect(result.config.plugins?.allow).toEqual(["telegram"]);
|
||||
expect(result.changes).toEqual([]);
|
||||
});
|
||||
|
||||
it("auto-enables bundled firecrawl when plugin-owned webFetch config exists", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
plugins: {
|
||||
allow: ["telegram"],
|
||||
entries: {
|
||||
firecrawl: {
|
||||
config: {
|
||||
webFetch: {
|
||||
apiKey: "firecrawl-key",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.firecrawl?.enabled).toBe(true);
|
||||
expect(result.config.plugins?.allow).toEqual(["telegram", "firecrawl"]);
|
||||
expect(result.changes).toContain("firecrawl web fetch configured, enabled automatically.");
|
||||
});
|
||||
|
||||
it("skips auto-enable work for configs without channel or plugin-owned surfaces", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
gateway: {
|
||||
auth: {
|
||||
mode: "token",
|
||||
token: "ok",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [{ id: "pi" }],
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(result.config).toEqual({
|
||||
gateway: {
|
||||
auth: {
|
||||
mode: "token",
|
||||
token: "ok",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [{ id: "pi" }],
|
||||
},
|
||||
});
|
||||
expect(result.changes).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores channels.modelByChannel for plugin auto-enable", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
channels: {
|
||||
modelByChannel: {
|
||||
openai: {
|
||||
whatsapp: "openai/gpt-5.4",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.modelByChannel).toBeUndefined();
|
||||
expect(result.config.plugins?.allow).toBeUndefined();
|
||||
expect(result.changes).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps auto-enabled WhatsApp config schema-valid", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
allowFrom: ["+15555550123"],
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(result.config.channels?.whatsapp?.enabled).toBe(true);
|
||||
const validated = validateConfigObject(result.config);
|
||||
expect(validated.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("does not append built-in WhatsApp to plugins.allow during auto-enable", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
allowFrom: ["+15555550123"],
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
allow: ["telegram"],
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(result.config.channels?.whatsapp?.enabled).toBe(true);
|
||||
expect(result.config.plugins?.allow).toEqual(["telegram"]);
|
||||
const validated = validateConfigObject(result.config);
|
||||
expect(validated.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("does not re-emit built-in auto-enable changes when rerun with plugins.allow set", () => {
|
||||
const first = applyPluginAutoEnable({
|
||||
config: {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
allowFrom: ["+15555550123"],
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
allow: ["telegram"],
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
const second = applyPluginAutoEnable({
|
||||
config: first.config,
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(first.changes).toHaveLength(1);
|
||||
expect(second.changes).toEqual([]);
|
||||
expect(second.config).toEqual(first.config);
|
||||
});
|
||||
|
||||
it("respects explicit disable", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
channels: { slack: { botToken: "x" } },
|
||||
plugins: { entries: { slack: { enabled: false } } },
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.slack?.enabled).toBe(false);
|
||||
expect(result.changes).toEqual([]);
|
||||
});
|
||||
|
||||
it("respects built-in channel explicit disable via channels.<id>.enabled", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
channels: { slack: { botToken: "x", enabled: false } },
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(result.config.channels?.slack?.enabled).toBe(false);
|
||||
expect(result.config.plugins?.entries?.slack).toBeUndefined();
|
||||
expect(result.changes).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not auto-enable plugin channels when only enabled=false is set", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
channels: { matrix: { enabled: false } },
|
||||
},
|
||||
env: {},
|
||||
manifestRegistry: makeRegistry([{ id: "matrix", channels: ["matrix"] }]),
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.matrix).toBeUndefined();
|
||||
expect(result.changes).toEqual([]);
|
||||
});
|
||||
|
||||
it("auto-enables irc when configured via env", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {},
|
||||
env: {
|
||||
IRC_HOST: "irc.libera.chat",
|
||||
IRC_NICK: "openclaw-bot",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.config.channels?.irc?.enabled).toBe(true);
|
||||
expect(result.changes.join("\n")).toContain("IRC configured, enabled automatically.");
|
||||
});
|
||||
|
||||
it("uses the provided env when loading plugin manifests automatically", () => {
|
||||
const stateDir = makeTempDir();
|
||||
const pluginDir = path.join(stateDir, "extensions", "apn-channel");
|
||||
writePluginManifestFixture({
|
||||
rootDir: pluginDir,
|
||||
id: "apn-channel",
|
||||
channels: ["apn"],
|
||||
});
|
||||
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
channels: { apn: { someKey: "value" } },
|
||||
},
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_HOME: undefined,
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.["apn-channel"]?.enabled).toBe(true);
|
||||
expect(result.config.plugins?.entries?.apn).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses env-scoped catalog metadata for preferOver auto-enable decisions", () => {
|
||||
const stateDir = makeTempDir();
|
||||
const catalogPath = path.join(stateDir, "plugins", "catalog.json");
|
||||
mkdirSafeDir(path.dirname(catalogPath));
|
||||
fs.writeFileSync(
|
||||
catalogPath,
|
||||
JSON.stringify({
|
||||
entries: [
|
||||
{
|
||||
name: "@openclaw/env-secondary",
|
||||
openclaw: {
|
||||
channel: {
|
||||
id: "env-secondary",
|
||||
label: "Env Secondary",
|
||||
selectionLabel: "Env Secondary",
|
||||
docsPath: "/channels/env-secondary",
|
||||
blurb: "Env secondary entry",
|
||||
preferOver: ["env-primary"],
|
||||
},
|
||||
install: {
|
||||
npmSpec: "@openclaw/env-secondary",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
channels: {
|
||||
"env-primary": { token: "primary" },
|
||||
"env-secondary": { token: "secondary" },
|
||||
},
|
||||
},
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
},
|
||||
manifestRegistry: makeRegistry([]),
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.["env-secondary"]?.enabled).toBe(true);
|
||||
expect(result.config.plugins?.entries?.["env-primary"]?.enabled).toBeUndefined();
|
||||
});
|
||||
|
||||
it("auto-enables provider auth plugins when profiles exist", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
auth: {
|
||||
profiles: {
|
||||
"google-gemini-cli:default": {
|
||||
provider: "google-gemini-cli",
|
||||
mode: "oauth",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.google?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("auto-enables bundled provider plugins when plugin-owned web search config exists", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
xai: {
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: "xai-plugin-config-key",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.xai?.enabled).toBe(true);
|
||||
expect(result.changes).toContain("xai web search configured, enabled automatically.");
|
||||
});
|
||||
|
||||
it("auto-enables xai when the plugin-owned x_search tool is configured", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
xai: {
|
||||
config: {
|
||||
xSearch: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.xai?.enabled).toBe(true);
|
||||
expect(result.changes).toContain("xai tool configured, enabled automatically.");
|
||||
});
|
||||
|
||||
it("auto-enables xai when the plugin-owned codeExecution config is configured", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
xai: {
|
||||
config: {
|
||||
codeExecution: {
|
||||
enabled: true,
|
||||
model: "grok-4-1-fast",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.xai?.enabled).toBe(true);
|
||||
expect(result.changes).toContain("xai tool configured, enabled automatically.");
|
||||
});
|
||||
|
||||
it("auto-enables minimax when minimax-portal profiles exist", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
auth: {
|
||||
profiles: {
|
||||
"minimax-portal:default": {
|
||||
provider: "minimax-portal",
|
||||
mode: "oauth",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.minimax?.enabled).toBe(true);
|
||||
expect(result.config.plugins?.entries?.["minimax-portal-auth"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("auto-enables minimax when minimax API key auth is configured", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
auth: {
|
||||
profiles: {
|
||||
"minimax:default": {
|
||||
provider: "minimax",
|
||||
mode: "api_key",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.minimax?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("does not auto-enable unrelated provider plugins just because auth profiles exist", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
auth: {
|
||||
profiles: {
|
||||
"openai:default": {
|
||||
provider: "openai",
|
||||
mode: "api_key",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.openai).toBeUndefined();
|
||||
expect(result.changes).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses manifest-owned provider auto-enable metadata for third-party plugins", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
auth: {
|
||||
profiles: {
|
||||
"acme-oauth:default": {
|
||||
provider: "acme-oauth",
|
||||
mode: "oauth",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
manifestRegistry: makeRegistry([
|
||||
{
|
||||
id: "acme",
|
||||
channels: [],
|
||||
autoEnableWhenConfiguredProviders: ["acme-oauth"],
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.acme?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("auto-enables third-party provider plugins when manifest-owned web search config exists", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
acme: {
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: "acme-search-key",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
manifestRegistry: {
|
||||
plugins: [
|
||||
{
|
||||
id: "acme",
|
||||
channels: [],
|
||||
providers: ["acme-ai"],
|
||||
cliBackends: [],
|
||||
skills: [],
|
||||
hooks: [],
|
||||
origin: "config" as const,
|
||||
rootDir: "/fake/acme",
|
||||
source: "/fake/acme/index.js",
|
||||
manifestPath: "/fake/acme/openclaw.plugin.json",
|
||||
contracts: {
|
||||
webSearchProviders: ["acme-search"],
|
||||
},
|
||||
},
|
||||
],
|
||||
diagnostics: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.acme?.enabled).toBe(true);
|
||||
expect(result.changes).toContain("acme web search configured, enabled automatically.");
|
||||
});
|
||||
|
||||
it("auto-enables acpx plugin when ACP is configured", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
acp: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.acpx?.enabled).toBe(true);
|
||||
expect(result.changes.join("\n")).toContain("ACP runtime configured, enabled automatically.");
|
||||
});
|
||||
|
||||
it("does not auto-enable acpx when a different ACP backend is configured", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
acp: {
|
||||
enabled: true,
|
||||
backend: "custom-runtime",
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.acpx?.enabled).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips when plugins are globally disabled", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
channels: { slack: { botToken: "x" } },
|
||||
plugins: { enabled: false },
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.slack?.enabled).toBeUndefined();
|
||||
expect(result.changes).toEqual([]);
|
||||
});
|
||||
|
||||
describe("third-party channel plugins (pluginId ≠ channelId)", () => {
|
||||
it("uses the plugin manifest id, not the channel id, for plugins.entries", () => {
|
||||
// Reproduces: https://github.com/openclaw/openclaw/issues/25261
|
||||
// Plugin "apn-channel" declares channels: ["apn"]. Doctor must write
|
||||
// plugins.entries["apn-channel"], not plugins.entries["apn"].
|
||||
const result = applyWithApnChannelConfig();
|
||||
|
||||
expect(result.config.plugins?.entries?.["apn-channel"]?.enabled).toBe(true);
|
||||
expect(result.config.plugins?.entries?.["apn"]).toBeUndefined();
|
||||
expect(result.changes.join("\n")).toContain("apn configured, enabled automatically.");
|
||||
});
|
||||
|
||||
it("does not double-enable when plugin is already enabled under its plugin id", () => {
|
||||
const result = applyWithApnChannelConfig({
|
||||
plugins: { entries: { "apn-channel": { enabled: true } } },
|
||||
});
|
||||
|
||||
expect(result.changes).toEqual([]);
|
||||
});
|
||||
|
||||
it("respects explicit disable of the plugin by its plugin id", () => {
|
||||
const result = applyWithApnChannelConfig({
|
||||
plugins: { entries: { "apn-channel": { enabled: false } } },
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.["apn-channel"]?.enabled).toBe(false);
|
||||
expect(result.changes).toEqual([]);
|
||||
});
|
||||
|
||||
it("falls back to channel key as plugin id when no installed manifest declares the channel", () => {
|
||||
// Without a matching manifest entry, behavior is unchanged (backward compat).
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
channels: { "unknown-chan": { someKey: "value" } },
|
||||
},
|
||||
env: {},
|
||||
manifestRegistry: makeRegistry([]),
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.["unknown-chan"]?.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("preferOver channel prioritization", () => {
|
||||
it("uses manifest channel config preferOver metadata for plugin channels", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
channels: {
|
||||
primary: { someKey: "value" },
|
||||
secondary: { someKey: "value" },
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
manifestRegistry: makeRegistry([
|
||||
{
|
||||
id: "primary",
|
||||
channels: ["primary"],
|
||||
channelConfigs: {
|
||||
primary: {
|
||||
schema: { type: "object" },
|
||||
preferOver: ["secondary"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ id: "secondary", channels: ["secondary"] },
|
||||
]),
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.primary?.enabled).toBe(true);
|
||||
expect(result.config.plugins?.entries?.secondary?.enabled).toBeUndefined();
|
||||
expect(result.changes.join("\n")).toContain("primary configured, enabled automatically.");
|
||||
expect(result.changes.join("\n")).not.toContain(
|
||||
"secondary configured, enabled automatically.",
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers bluebubbles: skips imessage auto-configure when both are configured", () => {
|
||||
const result = applyWithBluebubblesImessageConfig();
|
||||
|
||||
expect(result.config.channels?.bluebubbles?.enabled).toBe(true);
|
||||
expect(result.config.plugins?.entries?.imessage?.enabled).toBeUndefined();
|
||||
expect(result.changes.join("\n")).toContain("BlueBubbles configured, enabled automatically.");
|
||||
expect(result.changes.join("\n")).not.toContain(
|
||||
"iMessage configured, enabled automatically.",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps imessage enabled if already explicitly enabled (non-destructive)", () => {
|
||||
const result = applyWithBluebubblesImessageConfig({
|
||||
plugins: { entries: { imessage: { enabled: true } } },
|
||||
});
|
||||
|
||||
expect(result.config.channels?.bluebubbles?.enabled).toBe(true);
|
||||
expect(result.config.plugins?.entries?.imessage?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("allows imessage auto-configure when bluebubbles is explicitly disabled", () => {
|
||||
const result = applyWithBluebubblesImessageConfig({
|
||||
plugins: { entries: { bluebubbles: { enabled: false } } },
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.bluebubbles?.enabled).toBe(false);
|
||||
expect(result.config.channels?.imessage?.enabled).toBe(true);
|
||||
expect(result.changes.join("\n")).toContain("iMessage configured, enabled automatically.");
|
||||
});
|
||||
|
||||
it("allows imessage auto-configure when bluebubbles is in deny list", () => {
|
||||
const result = applyWithBluebubblesImessageConfig({
|
||||
plugins: { deny: ["bluebubbles"] },
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.bluebubbles?.enabled).toBeUndefined();
|
||||
expect(result.config.channels?.imessage?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("auto-enables imessage when only imessage is configured", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
channels: { imessage: { cliPath: "/usr/local/bin/imsg" } },
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(result.config.channels?.imessage?.enabled).toBe(true);
|
||||
expect(result.changes.join("\n")).toContain("iMessage configured, enabled automatically.");
|
||||
});
|
||||
|
||||
it("uses the provided env when loading installed plugin manifests", () => {
|
||||
const stateDir = makeTempDir();
|
||||
const pluginDir = path.join(stateDir, "extensions", "apn-channel");
|
||||
writePluginManifestFixture({
|
||||
rootDir: pluginDir,
|
||||
id: "apn-channel",
|
||||
channels: ["apn"],
|
||||
});
|
||||
|
||||
const result = applyPluginAutoEnable({
|
||||
config: makeApnChannelConfig(),
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_HOME: undefined,
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.["apn-channel"]?.enabled).toBe(true);
|
||||
expect(result.config.plugins?.entries?.apn).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,850 +1,10 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { normalizeProviderId } from "../agents/model-selection.js";
|
||||
import {
|
||||
hasPotentialConfiguredChannels,
|
||||
listPotentialConfiguredChannelIds,
|
||||
} from "../channels/config-presence.js";
|
||||
import { getChatChannelMeta, normalizeChatChannelId } from "../channels/registry.js";
|
||||
import {
|
||||
loadPluginManifestRegistry,
|
||||
resolveManifestContractOwnerPluginId,
|
||||
type PluginManifestRegistry,
|
||||
} from "../plugins/manifest-registry.js";
|
||||
import { resolveOwningPluginIdsForModelRef } from "../plugins/providers.js";
|
||||
import { isRecord, resolveConfigDir, resolveUserPath } from "../utils.js";
|
||||
import { isChannelConfigured } from "./channel-configured.js";
|
||||
import type { OpenClawConfig } from "./config.js";
|
||||
import { ensurePluginAllowlisted } from "./plugins-allowlist.js";
|
||||
import { isBlockedObjectKey } from "./prototype-keys.js";
|
||||
|
||||
export type PluginAutoEnableCandidate =
|
||||
| {
|
||||
pluginId: string;
|
||||
kind: "channel-configured";
|
||||
channelId: string;
|
||||
}
|
||||
| {
|
||||
pluginId: "browser";
|
||||
kind: "browser-configured";
|
||||
source: "browser-configured" | "browser-plugin-configured" | "browser-tool-referenced";
|
||||
}
|
||||
| {
|
||||
pluginId: string;
|
||||
kind: "provider-auth-configured";
|
||||
providerId: string;
|
||||
}
|
||||
| {
|
||||
pluginId: string;
|
||||
kind: "provider-model-configured";
|
||||
modelRef: string;
|
||||
}
|
||||
| {
|
||||
pluginId: string;
|
||||
kind: "web-fetch-provider-selected";
|
||||
providerId: string;
|
||||
}
|
||||
| {
|
||||
pluginId: string;
|
||||
kind: "plugin-web-search-configured";
|
||||
}
|
||||
| {
|
||||
pluginId: string;
|
||||
kind: "plugin-web-fetch-configured";
|
||||
}
|
||||
| {
|
||||
pluginId: string;
|
||||
kind: "plugin-tool-configured";
|
||||
}
|
||||
| {
|
||||
pluginId: "acpx";
|
||||
kind: "acp-runtime-configured";
|
||||
};
|
||||
|
||||
export type PluginAutoEnableResult = {
|
||||
config: OpenClawConfig;
|
||||
changes: string[];
|
||||
autoEnabledReasons: Record<string, string[]>;
|
||||
};
|
||||
|
||||
const EMPTY_PLUGIN_MANIFEST_REGISTRY: PluginManifestRegistry = {
|
||||
plugins: [],
|
||||
diagnostics: [],
|
||||
};
|
||||
|
||||
const ENV_CATALOG_PATHS = ["OPENCLAW_PLUGIN_CATALOG_PATHS", "OPENCLAW_MPM_CATALOG_PATHS"];
|
||||
|
||||
function resolveAutoEnableProviderPluginIds(
|
||||
registry: PluginManifestRegistry,
|
||||
): Readonly<Record<string, string>> {
|
||||
const entries = new Map<string, string>();
|
||||
for (const plugin of registry.plugins) {
|
||||
for (const providerId of plugin.autoEnableWhenConfiguredProviders ?? []) {
|
||||
if (!entries.has(providerId)) {
|
||||
entries.set(providerId, plugin.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
function collectModelRefs(cfg: OpenClawConfig): string[] {
|
||||
const refs: string[] = [];
|
||||
const pushModelRef = (value: unknown) => {
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
refs.push(value.trim());
|
||||
}
|
||||
};
|
||||
const collectFromAgent = (agent: Record<string, unknown> | null | undefined) => {
|
||||
if (!agent) {
|
||||
return;
|
||||
}
|
||||
const model = agent.model;
|
||||
if (typeof model === "string") {
|
||||
pushModelRef(model);
|
||||
} else if (isRecord(model)) {
|
||||
pushModelRef(model.primary);
|
||||
const fallbacks = model.fallbacks;
|
||||
if (Array.isArray(fallbacks)) {
|
||||
for (const entry of fallbacks) {
|
||||
pushModelRef(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
const models = agent.models;
|
||||
if (isRecord(models)) {
|
||||
for (const key of Object.keys(models)) {
|
||||
pushModelRef(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const defaults = cfg.agents?.defaults as Record<string, unknown> | undefined;
|
||||
collectFromAgent(defaults);
|
||||
|
||||
const list = cfg.agents?.list;
|
||||
if (Array.isArray(list)) {
|
||||
for (const entry of list) {
|
||||
if (isRecord(entry)) {
|
||||
collectFromAgent(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
function extractProviderFromModelRef(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
const slash = trimmed.indexOf("/");
|
||||
if (slash <= 0) {
|
||||
return null;
|
||||
}
|
||||
return normalizeProviderId(trimmed.slice(0, slash));
|
||||
}
|
||||
|
||||
function isProviderConfigured(cfg: OpenClawConfig, providerId: string): boolean {
|
||||
const normalized = normalizeProviderId(providerId);
|
||||
|
||||
const profiles = cfg.auth?.profiles;
|
||||
if (profiles && typeof profiles === "object") {
|
||||
for (const profile of Object.values(profiles)) {
|
||||
if (!isRecord(profile)) {
|
||||
continue;
|
||||
}
|
||||
const provider = normalizeProviderId(String(profile.provider ?? ""));
|
||||
if (provider === normalized) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const providerConfig = cfg.models?.providers;
|
||||
if (providerConfig && typeof providerConfig === "object") {
|
||||
for (const key of Object.keys(providerConfig)) {
|
||||
if (normalizeProviderId(key) === normalized) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const modelRefs = collectModelRefs(cfg);
|
||||
for (const ref of modelRefs) {
|
||||
const provider = extractProviderFromModelRef(ref);
|
||||
if (provider && provider === normalized) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasPluginOwnedWebSearchConfig(cfg: OpenClawConfig, pluginId: string): boolean {
|
||||
const pluginConfig = cfg.plugins?.entries?.[pluginId]?.config;
|
||||
if (!isRecord(pluginConfig)) {
|
||||
return false;
|
||||
}
|
||||
return isRecord(pluginConfig.webSearch);
|
||||
}
|
||||
|
||||
function hasPluginOwnedWebFetchConfig(cfg: OpenClawConfig, pluginId: string): boolean {
|
||||
const pluginConfig = cfg.plugins?.entries?.[pluginId]?.config;
|
||||
if (!isRecord(pluginConfig)) {
|
||||
return false;
|
||||
}
|
||||
return isRecord(pluginConfig.webFetch);
|
||||
}
|
||||
|
||||
function hasPluginOwnedToolConfig(cfg: OpenClawConfig, pluginId: string): boolean {
|
||||
if (pluginId === "xai") {
|
||||
const pluginConfig = cfg.plugins?.entries?.xai?.config;
|
||||
const web = cfg.tools?.web as Record<string, unknown> | undefined;
|
||||
return Boolean(
|
||||
isRecord(web?.x_search) ||
|
||||
(isRecord(pluginConfig) &&
|
||||
(isRecord(pluginConfig.xSearch) || isRecord(pluginConfig.codeExecution))),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveProviderPluginsWithOwnedWebSearch(
|
||||
registry: PluginManifestRegistry,
|
||||
): ReadonlySet<string> {
|
||||
const pluginIds = new Set<string>();
|
||||
for (const plugin of registry.plugins) {
|
||||
if (plugin.providers.length > 0 && (plugin.contracts?.webSearchProviders?.length ?? 0) > 0) {
|
||||
pluginIds.add(plugin.id);
|
||||
}
|
||||
}
|
||||
return pluginIds;
|
||||
}
|
||||
|
||||
function resolveProviderPluginsWithOwnedWebFetch(
|
||||
registry: PluginManifestRegistry,
|
||||
): ReadonlySet<string> {
|
||||
return new Set(
|
||||
registry.plugins
|
||||
.filter((plugin) => (plugin.contracts?.webFetchProviders?.length ?? 0) > 0)
|
||||
.map((plugin) => plugin.id),
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePluginIdForConfiguredWebFetchProvider(
|
||||
providerId: string | undefined,
|
||||
): string | undefined {
|
||||
return resolveManifestContractOwnerPluginId({
|
||||
contract: "webFetchProviders",
|
||||
value: typeof providerId === "string" ? providerId.trim().toLowerCase() : "",
|
||||
origin: "bundled",
|
||||
env: process.env,
|
||||
});
|
||||
}
|
||||
|
||||
function buildChannelToPluginIdMap(registry: PluginManifestRegistry): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
for (const record of registry.plugins) {
|
||||
for (const channelId of record.channels) {
|
||||
if (channelId && !map.has(channelId)) {
|
||||
map.set(channelId, record.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
type ExternalCatalogChannelEntry = {
|
||||
id: string;
|
||||
preferOver: string[];
|
||||
};
|
||||
|
||||
function splitEnvPaths(value: string): string[] {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return [];
|
||||
}
|
||||
return trimmed
|
||||
.split(/[;,]/g)
|
||||
.flatMap((chunk) => chunk.split(path.delimiter))
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function resolveExternalCatalogPaths(env: NodeJS.ProcessEnv): string[] {
|
||||
for (const key of ENV_CATALOG_PATHS) {
|
||||
const raw = env[key];
|
||||
if (raw && raw.trim()) {
|
||||
return splitEnvPaths(raw);
|
||||
}
|
||||
}
|
||||
const configDir = resolveConfigDir(env);
|
||||
return [
|
||||
path.join(configDir, "mpm", "plugins.json"),
|
||||
path.join(configDir, "mpm", "catalog.json"),
|
||||
path.join(configDir, "plugins", "catalog.json"),
|
||||
];
|
||||
}
|
||||
|
||||
function parseExternalCatalogChannelEntries(raw: unknown): ExternalCatalogChannelEntry[] {
|
||||
const list = (() => {
|
||||
if (Array.isArray(raw)) {
|
||||
return raw;
|
||||
}
|
||||
if (!isRecord(raw)) {
|
||||
return [];
|
||||
}
|
||||
const entries = raw.entries ?? raw.packages ?? raw.plugins;
|
||||
return Array.isArray(entries) ? entries : [];
|
||||
})();
|
||||
|
||||
const channels: ExternalCatalogChannelEntry[] = [];
|
||||
for (const entry of list) {
|
||||
if (!isRecord(entry) || !isRecord(entry.openclaw) || !isRecord(entry.openclaw.channel)) {
|
||||
continue;
|
||||
}
|
||||
const channel = entry.openclaw.channel;
|
||||
const id = typeof channel.id === "string" ? channel.id.trim() : "";
|
||||
if (!id) {
|
||||
continue;
|
||||
}
|
||||
const preferOver = Array.isArray(channel.preferOver)
|
||||
? channel.preferOver.filter((value): value is string => typeof value === "string")
|
||||
: [];
|
||||
channels.push({ id, preferOver });
|
||||
}
|
||||
return channels;
|
||||
}
|
||||
|
||||
function resolveExternalCatalogPreferOver(channelId: string, env: NodeJS.ProcessEnv): string[] {
|
||||
for (const rawPath of resolveExternalCatalogPaths(env)) {
|
||||
const resolved = resolveUserPath(rawPath, env);
|
||||
if (!fs.existsSync(resolved)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const payload = JSON.parse(fs.readFileSync(resolved, "utf-8")) as unknown;
|
||||
const channel = parseExternalCatalogChannelEntries(payload).find(
|
||||
(entry) => entry.id === channelId,
|
||||
);
|
||||
if (channel) {
|
||||
return channel.preferOver;
|
||||
}
|
||||
} catch {
|
||||
// Ignore invalid catalog files.
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function resolvePluginIdForChannel(
|
||||
channelId: string,
|
||||
channelToPluginId: ReadonlyMap<string, string>,
|
||||
): string {
|
||||
// Third-party plugins can expose a channel id that differs from their
|
||||
// manifest id; plugins.entries must always be keyed by manifest id.
|
||||
const builtInId = normalizeChatChannelId(channelId);
|
||||
if (builtInId) {
|
||||
return builtInId;
|
||||
}
|
||||
return channelToPluginId.get(channelId) ?? channelId;
|
||||
}
|
||||
|
||||
function collectCandidateChannelIds(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): string[] {
|
||||
return listPotentialConfiguredChannelIds(cfg, env).map(
|
||||
(channelId) => normalizeChatChannelId(channelId) ?? channelId,
|
||||
);
|
||||
}
|
||||
|
||||
function hasConfiguredWebSearchPluginEntry(cfg: OpenClawConfig): boolean {
|
||||
const entries = cfg.plugins?.entries;
|
||||
if (!entries || typeof entries !== "object") {
|
||||
return false;
|
||||
}
|
||||
return Object.values(entries).some(
|
||||
(entry) => isRecord(entry) && isRecord(entry.config) && isRecord(entry.config.webSearch),
|
||||
);
|
||||
}
|
||||
|
||||
function hasConfiguredWebFetchPluginEntry(cfg: OpenClawConfig): boolean {
|
||||
const entries = cfg.plugins?.entries;
|
||||
if (!entries || typeof entries !== "object") {
|
||||
return false;
|
||||
}
|
||||
return Object.values(entries).some(
|
||||
(entry) => isRecord(entry) && isRecord(entry.config) && isRecord(entry.config.webFetch),
|
||||
);
|
||||
}
|
||||
|
||||
function configMayNeedPluginManifestRegistry(cfg: OpenClawConfig): boolean {
|
||||
const pluginEntries = cfg.plugins?.entries;
|
||||
if (
|
||||
pluginEntries &&
|
||||
Object.values(pluginEntries).some((entry) => isRecord(entry) && isRecord(entry.config))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (cfg.auth?.profiles && Object.keys(cfg.auth.profiles).length > 0) {
|
||||
return true;
|
||||
}
|
||||
if (cfg.models?.providers && Object.keys(cfg.models.providers).length > 0) {
|
||||
return true;
|
||||
}
|
||||
if (collectModelRefs(cfg).length > 0) {
|
||||
return true;
|
||||
}
|
||||
const configuredChannels = cfg.channels as Record<string, unknown> | undefined;
|
||||
if (!configuredChannels || typeof configuredChannels !== "object") {
|
||||
return false;
|
||||
}
|
||||
for (const key of Object.keys(configuredChannels)) {
|
||||
if (key === "defaults" || key === "modelByChannel") {
|
||||
continue;
|
||||
}
|
||||
if (!normalizeChatChannelId(key)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function configMayNeedPluginAutoEnable(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean {
|
||||
if (hasPotentialConfiguredChannels(cfg, env)) {
|
||||
return true;
|
||||
}
|
||||
if (resolveBrowserAutoEnableSource(cfg)) {
|
||||
return true;
|
||||
}
|
||||
if (cfg.acp?.enabled === true || cfg.acp?.dispatch?.enabled === true) {
|
||||
return true;
|
||||
}
|
||||
if (typeof cfg.acp?.backend === "string" && cfg.acp.backend.trim().length > 0) {
|
||||
return true;
|
||||
}
|
||||
if (cfg.auth?.profiles && Object.keys(cfg.auth.profiles).length > 0) {
|
||||
return true;
|
||||
}
|
||||
if (cfg.models?.providers && Object.keys(cfg.models.providers).length > 0) {
|
||||
return true;
|
||||
}
|
||||
if (collectModelRefs(cfg).length > 0) {
|
||||
return true;
|
||||
}
|
||||
const web = cfg.tools?.web as Record<string, unknown> | undefined;
|
||||
if (isRecord(web?.x_search)) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
isRecord(cfg.plugins?.entries?.xai?.config) ||
|
||||
hasConfiguredWebSearchPluginEntry(cfg) ||
|
||||
hasConfiguredWebFetchPluginEntry(cfg)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function listContainsBrowser(value: unknown): boolean {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.some((entry) => typeof entry === "string" && entry.trim().toLowerCase() === "browser")
|
||||
);
|
||||
}
|
||||
|
||||
function toolPolicyReferencesBrowser(value: unknown): boolean {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
return listContainsBrowser(value.allow) || listContainsBrowser(value.alsoAllow);
|
||||
}
|
||||
|
||||
function hasBrowserToolReference(cfg: OpenClawConfig): boolean {
|
||||
if (toolPolicyReferencesBrowser(cfg.tools)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const agentList = cfg.agents?.list;
|
||||
if (!Array.isArray(agentList)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return agentList.some((entry) => isRecord(entry) && toolPolicyReferencesBrowser(entry.tools));
|
||||
}
|
||||
|
||||
function hasExplicitBrowserPluginEntry(cfg: OpenClawConfig): boolean {
|
||||
return Boolean(
|
||||
cfg.plugins?.entries && Object.prototype.hasOwnProperty.call(cfg.plugins.entries, "browser"),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveBrowserAutoEnableSource(
|
||||
cfg: OpenClawConfig,
|
||||
): Extract<PluginAutoEnableCandidate, { kind: "browser-configured" }>["source"] | null {
|
||||
if (cfg.browser?.enabled === false || cfg.plugins?.entries?.browser?.enabled === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(cfg, "browser")) {
|
||||
return "browser-configured";
|
||||
}
|
||||
|
||||
if (hasExplicitBrowserPluginEntry(cfg)) {
|
||||
return "browser-plugin-configured";
|
||||
}
|
||||
|
||||
if (hasBrowserToolReference(cfg)) {
|
||||
return "browser-tool-referenced";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolvePluginAutoEnableCandidateReason(
|
||||
candidate: PluginAutoEnableCandidate,
|
||||
): string {
|
||||
switch (candidate.kind) {
|
||||
case "channel-configured":
|
||||
return `${candidate.channelId} configured`;
|
||||
case "browser-configured":
|
||||
switch (candidate.source) {
|
||||
case "browser-configured":
|
||||
return "browser configured";
|
||||
case "browser-plugin-configured":
|
||||
return "browser plugin configured";
|
||||
case "browser-tool-referenced":
|
||||
return "browser tool referenced";
|
||||
}
|
||||
break;
|
||||
case "provider-auth-configured":
|
||||
return `${candidate.providerId} auth configured`;
|
||||
case "provider-model-configured":
|
||||
return `${candidate.modelRef} model configured`;
|
||||
case "web-fetch-provider-selected":
|
||||
return `${candidate.providerId} web fetch provider selected`;
|
||||
case "plugin-web-search-configured":
|
||||
return `${candidate.pluginId} web search configured`;
|
||||
case "plugin-web-fetch-configured":
|
||||
return `${candidate.pluginId} web fetch configured`;
|
||||
case "plugin-tool-configured":
|
||||
return `${candidate.pluginId} tool configured`;
|
||||
case "acp-runtime-configured":
|
||||
return "ACP runtime configured";
|
||||
}
|
||||
}
|
||||
|
||||
function resolveConfiguredPlugins(
|
||||
cfg: OpenClawConfig,
|
||||
env: NodeJS.ProcessEnv,
|
||||
registry: PluginManifestRegistry,
|
||||
): PluginAutoEnableCandidate[] {
|
||||
const changes: PluginAutoEnableCandidate[] = [];
|
||||
// Build reverse map: channel ID → plugin ID from installed plugin manifests.
|
||||
const channelToPluginId = buildChannelToPluginIdMap(registry);
|
||||
for (const channelId of collectCandidateChannelIds(cfg, env)) {
|
||||
const pluginId = resolvePluginIdForChannel(channelId, channelToPluginId);
|
||||
if (isChannelConfigured(cfg, channelId, env)) {
|
||||
changes.push({ pluginId, kind: "channel-configured", channelId });
|
||||
}
|
||||
}
|
||||
|
||||
const browserSource = resolveBrowserAutoEnableSource(cfg);
|
||||
if (browserSource) {
|
||||
changes.push({ pluginId: "browser", kind: "browser-configured", source: browserSource });
|
||||
}
|
||||
|
||||
for (const [providerId, pluginId] of Object.entries(
|
||||
resolveAutoEnableProviderPluginIds(registry),
|
||||
)) {
|
||||
if (isProviderConfigured(cfg, providerId)) {
|
||||
changes.push({
|
||||
pluginId,
|
||||
kind: "provider-auth-configured",
|
||||
providerId,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const modelRef of collectModelRefs(cfg)) {
|
||||
const owningPluginIds = resolveOwningPluginIdsForModelRef({
|
||||
model: modelRef,
|
||||
config: cfg,
|
||||
env,
|
||||
manifestRegistry: registry,
|
||||
});
|
||||
if (owningPluginIds?.length !== 1) {
|
||||
continue;
|
||||
}
|
||||
changes.push({
|
||||
pluginId: owningPluginIds[0],
|
||||
kind: "provider-model-configured",
|
||||
modelRef,
|
||||
});
|
||||
}
|
||||
const webFetchProvider =
|
||||
typeof cfg.tools?.web?.fetch?.provider === "string" ? cfg.tools.web.fetch.provider : undefined;
|
||||
const webFetchPluginId = resolvePluginIdForConfiguredWebFetchProvider(webFetchProvider);
|
||||
if (webFetchPluginId) {
|
||||
changes.push({
|
||||
pluginId: webFetchPluginId,
|
||||
kind: "web-fetch-provider-selected",
|
||||
providerId: String(webFetchProvider).trim().toLowerCase(),
|
||||
});
|
||||
}
|
||||
for (const pluginId of resolveProviderPluginsWithOwnedWebSearch(registry)) {
|
||||
if (hasPluginOwnedWebSearchConfig(cfg, pluginId)) {
|
||||
changes.push({
|
||||
pluginId,
|
||||
kind: "plugin-web-search-configured",
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const pluginId of resolveProviderPluginsWithOwnedWebFetch(registry)) {
|
||||
if (hasPluginOwnedWebFetchConfig(cfg, pluginId)) {
|
||||
changes.push({
|
||||
pluginId,
|
||||
kind: "plugin-web-fetch-configured",
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const pluginId of resolveProviderPluginsWithOwnedWebSearch(registry)) {
|
||||
if (hasPluginOwnedToolConfig(cfg, pluginId)) {
|
||||
changes.push({
|
||||
pluginId,
|
||||
kind: "plugin-tool-configured",
|
||||
});
|
||||
}
|
||||
}
|
||||
const backendRaw =
|
||||
typeof cfg.acp?.backend === "string" ? cfg.acp.backend.trim().toLowerCase() : "";
|
||||
const acpConfigured =
|
||||
cfg.acp?.enabled === true || cfg.acp?.dispatch?.enabled === true || backendRaw === "acpx";
|
||||
if (acpConfigured && (!backendRaw || backendRaw === "acpx")) {
|
||||
changes.push({
|
||||
pluginId: "acpx",
|
||||
kind: "acp-runtime-configured",
|
||||
});
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
function isPluginExplicitlyDisabled(cfg: OpenClawConfig, pluginId: string): boolean {
|
||||
const builtInChannelId = normalizeChatChannelId(pluginId);
|
||||
if (builtInChannelId) {
|
||||
const channels = cfg.channels as Record<string, unknown> | undefined;
|
||||
const channelConfig = channels?.[builtInChannelId];
|
||||
if (
|
||||
channelConfig &&
|
||||
typeof channelConfig === "object" &&
|
||||
!Array.isArray(channelConfig) &&
|
||||
(channelConfig as { enabled?: unknown }).enabled === false
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const entry = cfg.plugins?.entries?.[pluginId];
|
||||
return entry?.enabled === false;
|
||||
}
|
||||
|
||||
function isPluginDenied(cfg: OpenClawConfig, pluginId: string): boolean {
|
||||
const deny = cfg.plugins?.deny;
|
||||
return Array.isArray(deny) && deny.includes(pluginId);
|
||||
}
|
||||
|
||||
function resolvePreferredOverIds(
|
||||
pluginId: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
registry: PluginManifestRegistry,
|
||||
): string[] {
|
||||
const normalized = normalizeChatChannelId(pluginId);
|
||||
if (normalized) {
|
||||
return [...(getChatChannelMeta(normalized).preferOver ?? [])];
|
||||
}
|
||||
const installedPlugin = registry.plugins.find((record) => record.id === pluginId);
|
||||
const manifestChannelPreferOver = installedPlugin?.channelConfigs?.[pluginId]?.preferOver;
|
||||
if (manifestChannelPreferOver?.length) {
|
||||
return [...manifestChannelPreferOver];
|
||||
}
|
||||
const installedChannelMeta = installedPlugin?.channelCatalogMeta;
|
||||
if (installedChannelMeta?.preferOver?.length) {
|
||||
return [...installedChannelMeta.preferOver];
|
||||
}
|
||||
return resolveExternalCatalogPreferOver(pluginId, env);
|
||||
}
|
||||
|
||||
function shouldSkipPreferredPluginAutoEnable(
|
||||
cfg: OpenClawConfig,
|
||||
entry: PluginAutoEnableCandidate,
|
||||
configured: PluginAutoEnableCandidate[],
|
||||
env: NodeJS.ProcessEnv,
|
||||
registry: PluginManifestRegistry,
|
||||
): boolean {
|
||||
for (const other of configured) {
|
||||
if (other.pluginId === entry.pluginId) {
|
||||
continue;
|
||||
}
|
||||
if (isPluginDenied(cfg, other.pluginId)) {
|
||||
continue;
|
||||
}
|
||||
if (isPluginExplicitlyDisabled(cfg, other.pluginId)) {
|
||||
continue;
|
||||
}
|
||||
const preferOver = resolvePreferredOverIds(other.pluginId, env, registry);
|
||||
if (preferOver.includes(entry.pluginId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function registerPluginEntry(cfg: OpenClawConfig, pluginId: string): OpenClawConfig {
|
||||
const builtInChannelId = normalizeChatChannelId(pluginId);
|
||||
if (builtInChannelId) {
|
||||
const channels = cfg.channels as Record<string, unknown> | undefined;
|
||||
const existing = channels?.[builtInChannelId];
|
||||
const existingRecord =
|
||||
existing && typeof existing === "object" && !Array.isArray(existing)
|
||||
? (existing as Record<string, unknown>)
|
||||
: {};
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
[builtInChannelId]: {
|
||||
...existingRecord,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
const entries = {
|
||||
...cfg.plugins?.entries,
|
||||
[pluginId]: {
|
||||
...(cfg.plugins?.entries?.[pluginId] as Record<string, unknown> | undefined),
|
||||
enabled: true,
|
||||
},
|
||||
};
|
||||
return {
|
||||
...cfg,
|
||||
plugins: {
|
||||
...cfg.plugins,
|
||||
entries,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function formatAutoEnableChange(entry: PluginAutoEnableCandidate): string {
|
||||
let reason = resolvePluginAutoEnableCandidateReason(entry).trim();
|
||||
const channelId = normalizeChatChannelId(entry.pluginId);
|
||||
if (channelId) {
|
||||
const label = getChatChannelMeta(channelId).label;
|
||||
reason = reason.replace(new RegExp(`^${channelId}\\b`, "i"), label);
|
||||
}
|
||||
return `${reason}, enabled automatically.`;
|
||||
}
|
||||
|
||||
export function detectPluginAutoEnableCandidates(params: {
|
||||
config?: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
manifestRegistry?: PluginManifestRegistry;
|
||||
}): PluginAutoEnableCandidate[] {
|
||||
const env = params.env ?? process.env;
|
||||
const config = params.config ?? ({} as OpenClawConfig);
|
||||
if (!configMayNeedPluginAutoEnable(config, env)) {
|
||||
return [];
|
||||
}
|
||||
const registry =
|
||||
params.manifestRegistry ??
|
||||
(configMayNeedPluginManifestRegistry(config)
|
||||
? loadPluginManifestRegistry({ config, env })
|
||||
: EMPTY_PLUGIN_MANIFEST_REGISTRY);
|
||||
return resolveConfiguredPlugins(config, env, registry);
|
||||
}
|
||||
|
||||
export function materializePluginAutoEnableCandidates(params: {
|
||||
config?: OpenClawConfig;
|
||||
candidates: readonly PluginAutoEnableCandidate[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
manifestRegistry?: PluginManifestRegistry;
|
||||
}): PluginAutoEnableResult {
|
||||
const env = params.env ?? process.env;
|
||||
let next = params.config ?? {};
|
||||
const changes: string[] = [];
|
||||
const autoEnabledReasons = new Map<string, string[]>();
|
||||
const registry =
|
||||
params.manifestRegistry ??
|
||||
(configMayNeedPluginManifestRegistry(next)
|
||||
? loadPluginManifestRegistry({ config: next, env })
|
||||
: EMPTY_PLUGIN_MANIFEST_REGISTRY);
|
||||
|
||||
if (next.plugins?.enabled === false) {
|
||||
return { config: next, changes, autoEnabledReasons: {} };
|
||||
}
|
||||
|
||||
for (const entry of params.candidates) {
|
||||
const builtInChannelId = normalizeChatChannelId(entry.pluginId);
|
||||
if (isPluginDenied(next, entry.pluginId)) {
|
||||
continue;
|
||||
}
|
||||
if (isPluginExplicitlyDisabled(next, entry.pluginId)) {
|
||||
continue;
|
||||
}
|
||||
if (shouldSkipPreferredPluginAutoEnable(next, entry, [...params.candidates], env, registry)) {
|
||||
continue;
|
||||
}
|
||||
const allow = next.plugins?.allow;
|
||||
const allowMissing =
|
||||
builtInChannelId == null && Array.isArray(allow) && !allow.includes(entry.pluginId);
|
||||
const alreadyEnabled =
|
||||
builtInChannelId != null
|
||||
? (() => {
|
||||
const channels = next.channels as Record<string, unknown> | undefined;
|
||||
const channelConfig = channels?.[builtInChannelId];
|
||||
if (
|
||||
!channelConfig ||
|
||||
typeof channelConfig !== "object" ||
|
||||
Array.isArray(channelConfig)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return (channelConfig as { enabled?: unknown }).enabled === true;
|
||||
})()
|
||||
: next.plugins?.entries?.[entry.pluginId]?.enabled === true;
|
||||
if (alreadyEnabled && !allowMissing) {
|
||||
continue;
|
||||
}
|
||||
next = registerPluginEntry(next, entry.pluginId);
|
||||
if (!builtInChannelId) {
|
||||
next = ensurePluginAllowlisted(next, entry.pluginId);
|
||||
}
|
||||
const reason = resolvePluginAutoEnableCandidateReason(entry);
|
||||
autoEnabledReasons.set(entry.pluginId, [
|
||||
...(autoEnabledReasons.get(entry.pluginId) ?? []),
|
||||
reason,
|
||||
]);
|
||||
changes.push(formatAutoEnableChange(entry));
|
||||
}
|
||||
|
||||
const autoEnabledReasonRecord: Record<string, string[]> = Object.create(null);
|
||||
for (const [pluginId, reasons] of autoEnabledReasons) {
|
||||
if (isBlockedObjectKey(pluginId)) {
|
||||
continue;
|
||||
}
|
||||
autoEnabledReasonRecord[pluginId] = [...reasons];
|
||||
}
|
||||
|
||||
return { config: next, changes, autoEnabledReasons: autoEnabledReasonRecord };
|
||||
}
|
||||
|
||||
export function applyPluginAutoEnable(params: {
|
||||
config?: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
/** Pre-loaded manifest registry. When omitted, the registry is loaded from
|
||||
* the installed plugins on disk. Pass an explicit registry in tests to
|
||||
* avoid filesystem access and control what plugins are "installed". */
|
||||
manifestRegistry?: PluginManifestRegistry;
|
||||
}): PluginAutoEnableResult {
|
||||
const candidates = detectPluginAutoEnableCandidates(params);
|
||||
return materializePluginAutoEnableCandidates({
|
||||
config: params.config,
|
||||
candidates,
|
||||
env: params.env,
|
||||
manifestRegistry: params.manifestRegistry,
|
||||
});
|
||||
}
|
||||
export {
|
||||
applyPluginAutoEnable,
|
||||
materializePluginAutoEnableCandidates,
|
||||
} from "./plugin-auto-enable.apply.js";
|
||||
export { detectPluginAutoEnableCandidates } from "./plugin-auto-enable.detect.js";
|
||||
export type {
|
||||
PluginAutoEnableCandidate,
|
||||
PluginAutoEnableResult,
|
||||
} from "./plugin-auto-enable.shared.js";
|
||||
export { resolvePluginAutoEnableCandidateReason } from "./plugin-auto-enable.shared.js";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { PluginManifestRecord } from "./manifest-registry.js";
|
||||
import { resolvePluginWebSearchProviders } from "./web-search-providers.runtime.js";
|
||||
|
||||
function hasConfiguredCredentialValue(value: unknown): boolean {
|
||||
@@ -8,10 +9,12 @@ function hasConfiguredCredentialValue(value: unknown): boolean {
|
||||
return value !== undefined && value !== null;
|
||||
}
|
||||
|
||||
export function hasBundledWebSearchCredential(params: {
|
||||
export function hasConfiguredWebSearchCredential(params: {
|
||||
config: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
searchConfig?: Record<string, unknown>;
|
||||
origin?: PluginManifestRecord["origin"];
|
||||
bundledAllowlistCompat?: boolean;
|
||||
}): boolean {
|
||||
const searchConfig =
|
||||
params.searchConfig ??
|
||||
@@ -19,8 +22,8 @@ export function hasBundledWebSearchCredential(params: {
|
||||
return resolvePluginWebSearchProviders({
|
||||
config: params.config,
|
||||
env: params.env,
|
||||
bundledAllowlistCompat: true,
|
||||
origin: "bundled",
|
||||
bundledAllowlistCompat: params.bundledAllowlistCompat ?? false,
|
||||
origin: params.origin,
|
||||
}).some((provider) => {
|
||||
const configuredCredential =
|
||||
provider.getConfiguredCredentialValue?.(params.config) ??
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user