2026-01-30 03:15:10 +01:00
|
|
|
import type { OpenClawConfig } from "./config.js";
|
2026-02-01 10:03:47 +09:00
|
|
|
import { normalizeProviderId } from "../agents/model-selection.js";
|
|
|
|
|
import {
|
|
|
|
|
getChannelPluginCatalogEntry,
|
|
|
|
|
listChannelPluginCatalogEntries,
|
|
|
|
|
} from "../channels/plugins/catalog.js";
|
2026-01-20 13:52:59 +00:00
|
|
|
import {
|
|
|
|
|
getChatChannelMeta,
|
|
|
|
|
listChatChannels,
|
|
|
|
|
normalizeChatChannelId,
|
|
|
|
|
} from "../channels/registry.js";
|
2026-01-20 11:11:42 +00:00
|
|
|
import { hasAnyWhatsAppAuth } from "../web/accounts.js";
|
2026-01-18 16:22:50 +00:00
|
|
|
|
|
|
|
|
type PluginEnableChange = {
|
|
|
|
|
pluginId: string;
|
|
|
|
|
reason: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type PluginAutoEnableResult = {
|
2026-01-30 03:15:10 +01:00
|
|
|
config: OpenClawConfig;
|
2026-01-18 16:22:50 +00:00
|
|
|
changes: string[];
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-20 11:49:31 +00:00
|
|
|
const CHANNEL_PLUGIN_IDS = Array.from(
|
|
|
|
|
new Set([
|
|
|
|
|
...listChatChannels().map((meta) => meta.id),
|
|
|
|
|
...listChannelPluginCatalogEntries().map((entry) => entry.id),
|
|
|
|
|
]),
|
|
|
|
|
);
|
|
|
|
|
|
2026-01-18 16:22:50 +00:00
|
|
|
const PROVIDER_PLUGIN_IDS: Array<{ pluginId: string; providerId: string }> = [
|
|
|
|
|
{ pluginId: "google-antigravity-auth", providerId: "google-antigravity" },
|
|
|
|
|
{ pluginId: "google-gemini-cli-auth", providerId: "google-gemini-cli" },
|
|
|
|
|
{ pluginId: "qwen-portal-auth", providerId: "qwen-portal" },
|
|
|
|
|
{ pluginId: "copilot-proxy", providerId: "copilot-proxy" },
|
2026-01-31 12:42:45 +01:00
|
|
|
{ pluginId: "minimax-portal-auth", providerId: "minimax-portal" },
|
2026-01-18 16:22:50 +00:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
|
|
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function hasNonEmptyString(value: unknown): boolean {
|
|
|
|
|
return typeof value === "string" && value.trim().length > 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function recordHasKeys(value: unknown): boolean {
|
|
|
|
|
return isRecord(value) && Object.keys(value).length > 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-18 18:43:31 +00:00
|
|
|
function accountsHaveKeys(value: unknown, keys: string[]): boolean {
|
2026-01-31 16:19:20 +09:00
|
|
|
if (!isRecord(value)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2026-01-18 16:22:50 +00:00
|
|
|
for (const account of Object.values(value)) {
|
2026-01-31 16:19:20 +09:00
|
|
|
if (!isRecord(account)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-01-18 16:22:50 +00:00
|
|
|
for (const key of keys) {
|
2026-01-31 16:19:20 +09:00
|
|
|
if (hasNonEmptyString(account[key])) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2026-01-18 16:22:50 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-18 18:43:31 +00:00
|
|
|
function resolveChannelConfig(
|
2026-01-30 03:15:10 +01:00
|
|
|
cfg: OpenClawConfig,
|
2026-01-18 18:43:31 +00:00
|
|
|
channelId: string,
|
|
|
|
|
): Record<string, unknown> | null {
|
2026-01-18 16:22:50 +00:00
|
|
|
const channels = cfg.channels as Record<string, unknown> | undefined;
|
|
|
|
|
const entry = channels?.[channelId];
|
|
|
|
|
return isRecord(entry) ? entry : null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-30 03:15:10 +01:00
|
|
|
function isTelegramConfigured(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean {
|
2026-01-31 16:19:20 +09:00
|
|
|
if (hasNonEmptyString(env.TELEGRAM_BOT_TOKEN)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2026-01-18 16:22:50 +00:00
|
|
|
const entry = resolveChannelConfig(cfg, "telegram");
|
2026-01-31 16:19:20 +09:00
|
|
|
if (!entry) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if (hasNonEmptyString(entry.botToken) || hasNonEmptyString(entry.tokenFile)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
if (accountsHaveKeys(entry.accounts, ["botToken", "tokenFile"])) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2026-01-18 16:22:50 +00:00
|
|
|
return recordHasKeys(entry);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-30 03:15:10 +01:00
|
|
|
function isDiscordConfigured(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean {
|
2026-01-31 16:19:20 +09:00
|
|
|
if (hasNonEmptyString(env.DISCORD_BOT_TOKEN)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2026-01-18 16:22:50 +00:00
|
|
|
const entry = resolveChannelConfig(cfg, "discord");
|
2026-01-31 16:19:20 +09:00
|
|
|
if (!entry) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if (hasNonEmptyString(entry.token)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
if (accountsHaveKeys(entry.accounts, ["token"])) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2026-01-18 16:22:50 +00:00
|
|
|
return recordHasKeys(entry);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-30 03:15:10 +01:00
|
|
|
function isSlackConfigured(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean {
|
2026-01-18 16:22:50 +00:00
|
|
|
if (
|
|
|
|
|
hasNonEmptyString(env.SLACK_BOT_TOKEN) ||
|
|
|
|
|
hasNonEmptyString(env.SLACK_APP_TOKEN) ||
|
|
|
|
|
hasNonEmptyString(env.SLACK_USER_TOKEN)
|
|
|
|
|
) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
const entry = resolveChannelConfig(cfg, "slack");
|
2026-01-31 16:19:20 +09:00
|
|
|
if (!entry) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2026-01-18 16:22:50 +00:00
|
|
|
if (
|
|
|
|
|
hasNonEmptyString(entry.botToken) ||
|
|
|
|
|
hasNonEmptyString(entry.appToken) ||
|
|
|
|
|
hasNonEmptyString(entry.userToken)
|
|
|
|
|
) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2026-01-31 16:19:20 +09:00
|
|
|
if (accountsHaveKeys(entry.accounts, ["botToken", "appToken", "userToken"])) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2026-01-18 16:22:50 +00:00
|
|
|
return recordHasKeys(entry);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-30 03:15:10 +01:00
|
|
|
function isSignalConfigured(cfg: OpenClawConfig): boolean {
|
2026-01-18 16:22:50 +00:00
|
|
|
const entry = resolveChannelConfig(cfg, "signal");
|
2026-01-31 16:19:20 +09:00
|
|
|
if (!entry) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2026-01-18 16:22:50 +00:00
|
|
|
if (
|
|
|
|
|
hasNonEmptyString(entry.account) ||
|
|
|
|
|
hasNonEmptyString(entry.httpUrl) ||
|
|
|
|
|
hasNonEmptyString(entry.httpHost) ||
|
|
|
|
|
typeof entry.httpPort === "number" ||
|
|
|
|
|
hasNonEmptyString(entry.cliPath)
|
|
|
|
|
) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2026-01-31 16:19:20 +09:00
|
|
|
if (accountsHaveKeys(entry.accounts, ["account", "httpUrl", "httpHost", "cliPath"])) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2026-01-18 16:22:50 +00:00
|
|
|
return recordHasKeys(entry);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-30 03:15:10 +01:00
|
|
|
function isIMessageConfigured(cfg: OpenClawConfig): boolean {
|
2026-01-18 16:22:50 +00:00
|
|
|
const entry = resolveChannelConfig(cfg, "imessage");
|
2026-01-31 16:19:20 +09:00
|
|
|
if (!entry) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if (hasNonEmptyString(entry.cliPath)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2026-01-18 16:22:50 +00:00
|
|
|
return recordHasKeys(entry);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-30 03:15:10 +01:00
|
|
|
function isWhatsAppConfigured(cfg: OpenClawConfig): boolean {
|
2026-01-31 16:19:20 +09:00
|
|
|
if (hasAnyWhatsAppAuth(cfg)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2026-01-18 16:22:50 +00:00
|
|
|
const entry = resolveChannelConfig(cfg, "whatsapp");
|
2026-01-31 16:19:20 +09:00
|
|
|
if (!entry) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2026-01-18 16:22:50 +00:00
|
|
|
return recordHasKeys(entry);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-30 03:15:10 +01:00
|
|
|
function isGenericChannelConfigured(cfg: OpenClawConfig, channelId: string): boolean {
|
2026-01-18 16:22:50 +00:00
|
|
|
const entry = resolveChannelConfig(cfg, channelId);
|
|
|
|
|
return recordHasKeys(entry);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function isChannelConfigured(
|
2026-01-30 03:15:10 +01:00
|
|
|
cfg: OpenClawConfig,
|
2026-01-18 16:22:50 +00:00
|
|
|
channelId: string,
|
|
|
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
|
|
|
): boolean {
|
|
|
|
|
switch (channelId) {
|
|
|
|
|
case "whatsapp":
|
|
|
|
|
return isWhatsAppConfigured(cfg);
|
|
|
|
|
case "telegram":
|
|
|
|
|
return isTelegramConfigured(cfg, env);
|
|
|
|
|
case "discord":
|
|
|
|
|
return isDiscordConfigured(cfg, env);
|
|
|
|
|
case "slack":
|
|
|
|
|
return isSlackConfigured(cfg, env);
|
|
|
|
|
case "signal":
|
|
|
|
|
return isSignalConfigured(cfg);
|
|
|
|
|
case "imessage":
|
|
|
|
|
return isIMessageConfigured(cfg);
|
|
|
|
|
default:
|
|
|
|
|
return isGenericChannelConfigured(cfg, channelId);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-30 03:15:10 +01:00
|
|
|
function collectModelRefs(cfg: OpenClawConfig): string[] {
|
2026-01-18 16:22:50 +00:00
|
|
|
const refs: string[] = [];
|
|
|
|
|
const pushModelRef = (value: unknown) => {
|
2026-01-31 16:19:20 +09:00
|
|
|
if (typeof value === "string" && value.trim()) {
|
|
|
|
|
refs.push(value.trim());
|
|
|
|
|
}
|
2026-01-18 16:22:50 +00:00
|
|
|
};
|
|
|
|
|
const collectFromAgent = (agent: Record<string, unknown> | null | undefined) => {
|
2026-01-31 16:19:20 +09:00
|
|
|
if (!agent) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-01-18 16:22:50 +00:00
|
|
|
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)) {
|
2026-01-31 16:19:20 +09:00
|
|
|
for (const entry of fallbacks) {
|
|
|
|
|
pushModelRef(entry);
|
|
|
|
|
}
|
2026-01-18 16:22:50 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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) {
|
2026-01-31 16:19:20 +09:00
|
|
|
if (isRecord(entry)) {
|
|
|
|
|
collectFromAgent(entry);
|
|
|
|
|
}
|
2026-01-18 16:22:50 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return refs;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function extractProviderFromModelRef(value: string): string | null {
|
|
|
|
|
const trimmed = value.trim();
|
|
|
|
|
const slash = trimmed.indexOf("/");
|
2026-01-31 16:19:20 +09:00
|
|
|
if (slash <= 0) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
2026-01-18 16:22:50 +00:00
|
|
|
return normalizeProviderId(trimmed.slice(0, slash));
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-30 03:15:10 +01:00
|
|
|
function isProviderConfigured(cfg: OpenClawConfig, providerId: string): boolean {
|
2026-01-18 16:22:50 +00:00
|
|
|
const normalized = normalizeProviderId(providerId);
|
|
|
|
|
|
|
|
|
|
const profiles = cfg.auth?.profiles;
|
|
|
|
|
if (profiles && typeof profiles === "object") {
|
|
|
|
|
for (const profile of Object.values(profiles)) {
|
2026-01-31 16:19:20 +09:00
|
|
|
if (!isRecord(profile)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-01-18 16:22:50 +00:00
|
|
|
const provider = normalizeProviderId(String(profile.provider ?? ""));
|
2026-01-31 16:19:20 +09:00
|
|
|
if (provider === normalized) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2026-01-18 16:22:50 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const providerConfig = cfg.models?.providers;
|
|
|
|
|
if (providerConfig && typeof providerConfig === "object") {
|
|
|
|
|
for (const key of Object.keys(providerConfig)) {
|
2026-01-31 16:19:20 +09:00
|
|
|
if (normalizeProviderId(key) === normalized) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2026-01-18 16:22:50 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const modelRefs = collectModelRefs(cfg);
|
|
|
|
|
for (const ref of modelRefs) {
|
|
|
|
|
const provider = extractProviderFromModelRef(ref);
|
2026-01-31 16:19:20 +09:00
|
|
|
if (provider && provider === normalized) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2026-01-18 16:22:50 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-18 18:43:31 +00:00
|
|
|
function resolveConfiguredPlugins(
|
2026-01-30 03:15:10 +01:00
|
|
|
cfg: OpenClawConfig,
|
2026-01-18 18:43:31 +00:00
|
|
|
env: NodeJS.ProcessEnv,
|
|
|
|
|
): PluginEnableChange[] {
|
2026-01-18 16:22:50 +00:00
|
|
|
const changes: PluginEnableChange[] = [];
|
2026-01-20 11:49:31 +00:00
|
|
|
const channelIds = new Set(CHANNEL_PLUGIN_IDS);
|
2026-01-20 11:11:42 +00:00
|
|
|
const configuredChannels = cfg.channels as Record<string, unknown> | undefined;
|
|
|
|
|
if (configuredChannels && typeof configuredChannels === "object") {
|
|
|
|
|
for (const key of Object.keys(configuredChannels)) {
|
2026-01-31 16:19:20 +09:00
|
|
|
if (key === "defaults") {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-01-20 11:11:42 +00:00
|
|
|
channelIds.add(key);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for (const channelId of channelIds) {
|
2026-01-31 16:19:20 +09:00
|
|
|
if (!channelId) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-01-18 16:22:50 +00:00
|
|
|
if (isChannelConfigured(cfg, channelId, env)) {
|
|
|
|
|
changes.push({
|
|
|
|
|
pluginId: channelId,
|
|
|
|
|
reason: `${channelId} configured`,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for (const mapping of PROVIDER_PLUGIN_IDS) {
|
|
|
|
|
if (isProviderConfigured(cfg, mapping.providerId)) {
|
|
|
|
|
changes.push({
|
|
|
|
|
pluginId: mapping.pluginId,
|
|
|
|
|
reason: `${mapping.providerId} auth configured`,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return changes;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-30 03:15:10 +01:00
|
|
|
function isPluginExplicitlyDisabled(cfg: OpenClawConfig, pluginId: string): boolean {
|
2026-01-18 16:22:50 +00:00
|
|
|
const entry = cfg.plugins?.entries?.[pluginId];
|
|
|
|
|
return entry?.enabled === false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-30 03:15:10 +01:00
|
|
|
function isPluginDenied(cfg: OpenClawConfig, pluginId: string): boolean {
|
2026-01-18 16:22:50 +00:00
|
|
|
const deny = cfg.plugins?.deny;
|
|
|
|
|
return Array.isArray(deny) && deny.includes(pluginId);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 11:49:31 +00:00
|
|
|
function resolvePreferredOverIds(pluginId: string): string[] {
|
|
|
|
|
const normalized = normalizeChatChannelId(pluginId);
|
|
|
|
|
if (normalized) {
|
|
|
|
|
return getChatChannelMeta(normalized).preferOver ?? [];
|
|
|
|
|
}
|
|
|
|
|
const catalogEntry = getChannelPluginCatalogEntry(pluginId);
|
|
|
|
|
return catalogEntry?.meta.preferOver ?? [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function shouldSkipPreferredPluginAutoEnable(
|
2026-01-30 03:15:10 +01:00
|
|
|
cfg: OpenClawConfig,
|
2026-01-20 11:49:31 +00:00
|
|
|
entry: PluginEnableChange,
|
2026-01-19 20:16:14 -08:00
|
|
|
configured: PluginEnableChange[],
|
|
|
|
|
): boolean {
|
2026-01-20 11:49:31 +00:00
|
|
|
for (const other of configured) {
|
2026-01-31 16:19:20 +09:00
|
|
|
if (other.pluginId === entry.pluginId) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (isPluginDenied(cfg, other.pluginId)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (isPluginExplicitlyDisabled(cfg, other.pluginId)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-01-20 11:49:31 +00:00
|
|
|
const preferOver = resolvePreferredOverIds(other.pluginId);
|
|
|
|
|
if (preferOver.includes(entry.pluginId)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false;
|
2026-01-19 20:16:14 -08:00
|
|
|
}
|
|
|
|
|
|
2026-01-30 03:15:10 +01:00
|
|
|
function ensureAllowlisted(cfg: OpenClawConfig, pluginId: string): OpenClawConfig {
|
2026-01-18 16:22:50 +00:00
|
|
|
const allow = cfg.plugins?.allow;
|
2026-01-31 16:19:20 +09:00
|
|
|
if (!Array.isArray(allow) || allow.includes(pluginId)) {
|
|
|
|
|
return cfg;
|
|
|
|
|
}
|
2026-01-18 16:22:50 +00:00
|
|
|
return {
|
|
|
|
|
...cfg,
|
|
|
|
|
plugins: {
|
|
|
|
|
...cfg.plugins,
|
|
|
|
|
allow: [...allow, pluginId],
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
fix: comprehensive BlueBubbles and channel cleanup (#11093)
* feat(bluebubbles): auto-strip markdown from outbound messages (#7402)
* fix(security): add timeout to webhook body reading (#6762)
Adds 30-second timeout to readBody() in voice-call, bluebubbles, and nostr
webhook handlers. Prevents Slow-Loris DoS (CWE-400, CVSS 7.5).
Merged with existing maxBytes protection in voice-call.
* fix(security): unify Error objects and lint fixes in webhook timeouts (#6762)
* fix: prevent plugins from auto-enabling without user consent (#3961)
Changes default plugin enabled state from true to false in enablePluginEntry().
Preserves existing enabled:true values. Fixes #3932.
* fix: apply hierarchical mediaMaxMb config to all channels (#8749)
Generalizes resolveAttachmentMaxBytes() to use account → channel → global
config resolution for all channels, not just BlueBubbles. Fixes #7847.
* fix(bluebubbles): sanitize attachment filenames against header injection (#10333)
Strip ", \r, \n, and \\ from filenames after path.basename() to prevent
multipart Content-Disposition header injection (CWE-93, CVSS 5.4).
Also adds sanitization to setGroupIconBlueBubbles which had zero filename
sanitization.
* fix(lint): exclude extensions/ from Oxlint preflight check (#9313)
Extensions use PluginRuntime|null patterns that trigger
no-redundant-type-constituents because PluginRuntime resolves to any.
Excluding extensions/ from Oxlint unblocks user upgrades.
Re-applies the approach from closed PR #10087.
* fix(bluebubbles): add tempGuid to createNewChatWithMessage payload (#7745)
Non-Private-API mode (AppleScript) requires tempGuid in send payloads.
The main sendMessageBlueBubbles already had it, but createNewChatWithMessage
was missing it, causing 400 errors for new chat creation without Private API.
* fix: send stop-typing signal when run ends with NO_REPLY (#8785)
Adds onCleanup callback to the typing controller that fires when the
controller is cleaned up while typing was active (e.g., after NO_REPLY).
Channels using createTypingCallbacks automatically get stop-typing on
cleanup. This prevents the typing indicator from lingering in group chats
when the agent decides not to reply.
* fix(telegram): deduplicate skill commands in multi-agent setup (#5717)
Two fixes:
1. Skip duplicate workspace dirs when listing skill commands across agents.
Multiple agents sharing the same workspace would produce duplicate commands
with _2, _3 suffixes.
2. Clear stale commands via deleteMyCommands before registering new ones.
Commands from deleted skills now get cleaned up on restart.
* fix: add size limits to unbounded in-memory caches (#4948)
Adds max-size caps with oldest-entry eviction to prevent OOM in
long-running deployments:
- BlueBubbles serverInfoCache: 64 entries (already has TTL)
- Google Chat authCache: 32 entries
- Matrix directRoomCache: 1024 entries
- Discord presenceCache: 5000 entries per account
* fix: address review concerns (#11093)
- Chain deleteMyCommands → setMyCommands to prevent race condition (#5717)
- Rename enablePluginEntry to registerPluginEntry (now sets enabled: false)
- Add Slow-Loris timeout test for readJsonBody (#6023)
2026-02-07 05:00:55 -08:00
|
|
|
function registerPluginEntry(cfg: OpenClawConfig, pluginId: string): OpenClawConfig {
|
2026-01-18 16:22:50 +00:00
|
|
|
const entries = {
|
|
|
|
|
...cfg.plugins?.entries,
|
|
|
|
|
[pluginId]: {
|
|
|
|
|
...(cfg.plugins?.entries?.[pluginId] as Record<string, unknown> | undefined),
|
fix: comprehensive BlueBubbles and channel cleanup (#11093)
* feat(bluebubbles): auto-strip markdown from outbound messages (#7402)
* fix(security): add timeout to webhook body reading (#6762)
Adds 30-second timeout to readBody() in voice-call, bluebubbles, and nostr
webhook handlers. Prevents Slow-Loris DoS (CWE-400, CVSS 7.5).
Merged with existing maxBytes protection in voice-call.
* fix(security): unify Error objects and lint fixes in webhook timeouts (#6762)
* fix: prevent plugins from auto-enabling without user consent (#3961)
Changes default plugin enabled state from true to false in enablePluginEntry().
Preserves existing enabled:true values. Fixes #3932.
* fix: apply hierarchical mediaMaxMb config to all channels (#8749)
Generalizes resolveAttachmentMaxBytes() to use account → channel → global
config resolution for all channels, not just BlueBubbles. Fixes #7847.
* fix(bluebubbles): sanitize attachment filenames against header injection (#10333)
Strip ", \r, \n, and \\ from filenames after path.basename() to prevent
multipart Content-Disposition header injection (CWE-93, CVSS 5.4).
Also adds sanitization to setGroupIconBlueBubbles which had zero filename
sanitization.
* fix(lint): exclude extensions/ from Oxlint preflight check (#9313)
Extensions use PluginRuntime|null patterns that trigger
no-redundant-type-constituents because PluginRuntime resolves to any.
Excluding extensions/ from Oxlint unblocks user upgrades.
Re-applies the approach from closed PR #10087.
* fix(bluebubbles): add tempGuid to createNewChatWithMessage payload (#7745)
Non-Private-API mode (AppleScript) requires tempGuid in send payloads.
The main sendMessageBlueBubbles already had it, but createNewChatWithMessage
was missing it, causing 400 errors for new chat creation without Private API.
* fix: send stop-typing signal when run ends with NO_REPLY (#8785)
Adds onCleanup callback to the typing controller that fires when the
controller is cleaned up while typing was active (e.g., after NO_REPLY).
Channels using createTypingCallbacks automatically get stop-typing on
cleanup. This prevents the typing indicator from lingering in group chats
when the agent decides not to reply.
* fix(telegram): deduplicate skill commands in multi-agent setup (#5717)
Two fixes:
1. Skip duplicate workspace dirs when listing skill commands across agents.
Multiple agents sharing the same workspace would produce duplicate commands
with _2, _3 suffixes.
2. Clear stale commands via deleteMyCommands before registering new ones.
Commands from deleted skills now get cleaned up on restart.
* fix: add size limits to unbounded in-memory caches (#4948)
Adds max-size caps with oldest-entry eviction to prevent OOM in
long-running deployments:
- BlueBubbles serverInfoCache: 64 entries (already has TTL)
- Google Chat authCache: 32 entries
- Matrix directRoomCache: 1024 entries
- Discord presenceCache: 5000 entries per account
* fix: address review concerns (#11093)
- Chain deleteMyCommands → setMyCommands to prevent race condition (#5717)
- Rename enablePluginEntry to registerPluginEntry (now sets enabled: false)
- Add Slow-Loris timeout test for readJsonBody (#6023)
2026-02-07 05:00:55 -08:00
|
|
|
enabled: false,
|
2026-01-18 16:22:50 +00:00
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
return {
|
|
|
|
|
...cfg,
|
|
|
|
|
plugins: {
|
|
|
|
|
...cfg.plugins,
|
|
|
|
|
entries,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 15:58:26 +00:00
|
|
|
function formatAutoEnableChange(entry: PluginEnableChange): string {
|
|
|
|
|
let reason = entry.reason.trim();
|
|
|
|
|
const channelId = normalizeChatChannelId(entry.pluginId);
|
|
|
|
|
if (channelId) {
|
|
|
|
|
const label = getChatChannelMeta(channelId).label;
|
|
|
|
|
reason = reason.replace(new RegExp(`^${channelId}\\b`, "i"), label);
|
|
|
|
|
}
|
|
|
|
|
return `${reason}, not enabled yet.`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-18 16:22:50 +00:00
|
|
|
export function applyPluginAutoEnable(params: {
|
2026-01-30 03:15:10 +01:00
|
|
|
config: OpenClawConfig;
|
2026-01-18 16:22:50 +00:00
|
|
|
env?: NodeJS.ProcessEnv;
|
|
|
|
|
}): PluginAutoEnableResult {
|
|
|
|
|
const env = params.env ?? process.env;
|
|
|
|
|
const configured = resolveConfiguredPlugins(params.config, env);
|
|
|
|
|
if (configured.length === 0) {
|
|
|
|
|
return { config: params.config, changes: [] };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let next = params.config;
|
|
|
|
|
const changes: string[] = [];
|
|
|
|
|
|
|
|
|
|
if (next.plugins?.enabled === false) {
|
|
|
|
|
return { config: next, changes };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const entry of configured) {
|
2026-01-31 16:19:20 +09:00
|
|
|
if (isPluginDenied(next, entry.pluginId)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (isPluginExplicitlyDisabled(next, entry.pluginId)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (shouldSkipPreferredPluginAutoEnable(next, entry, configured)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-01-18 16:22:50 +00:00
|
|
|
const allow = next.plugins?.allow;
|
|
|
|
|
const allowMissing = Array.isArray(allow) && !allow.includes(entry.pluginId);
|
|
|
|
|
const alreadyEnabled = next.plugins?.entries?.[entry.pluginId]?.enabled === true;
|
2026-01-31 16:19:20 +09:00
|
|
|
if (alreadyEnabled && !allowMissing) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
fix: comprehensive BlueBubbles and channel cleanup (#11093)
* feat(bluebubbles): auto-strip markdown from outbound messages (#7402)
* fix(security): add timeout to webhook body reading (#6762)
Adds 30-second timeout to readBody() in voice-call, bluebubbles, and nostr
webhook handlers. Prevents Slow-Loris DoS (CWE-400, CVSS 7.5).
Merged with existing maxBytes protection in voice-call.
* fix(security): unify Error objects and lint fixes in webhook timeouts (#6762)
* fix: prevent plugins from auto-enabling without user consent (#3961)
Changes default plugin enabled state from true to false in enablePluginEntry().
Preserves existing enabled:true values. Fixes #3932.
* fix: apply hierarchical mediaMaxMb config to all channels (#8749)
Generalizes resolveAttachmentMaxBytes() to use account → channel → global
config resolution for all channels, not just BlueBubbles. Fixes #7847.
* fix(bluebubbles): sanitize attachment filenames against header injection (#10333)
Strip ", \r, \n, and \\ from filenames after path.basename() to prevent
multipart Content-Disposition header injection (CWE-93, CVSS 5.4).
Also adds sanitization to setGroupIconBlueBubbles which had zero filename
sanitization.
* fix(lint): exclude extensions/ from Oxlint preflight check (#9313)
Extensions use PluginRuntime|null patterns that trigger
no-redundant-type-constituents because PluginRuntime resolves to any.
Excluding extensions/ from Oxlint unblocks user upgrades.
Re-applies the approach from closed PR #10087.
* fix(bluebubbles): add tempGuid to createNewChatWithMessage payload (#7745)
Non-Private-API mode (AppleScript) requires tempGuid in send payloads.
The main sendMessageBlueBubbles already had it, but createNewChatWithMessage
was missing it, causing 400 errors for new chat creation without Private API.
* fix: send stop-typing signal when run ends with NO_REPLY (#8785)
Adds onCleanup callback to the typing controller that fires when the
controller is cleaned up while typing was active (e.g., after NO_REPLY).
Channels using createTypingCallbacks automatically get stop-typing on
cleanup. This prevents the typing indicator from lingering in group chats
when the agent decides not to reply.
* fix(telegram): deduplicate skill commands in multi-agent setup (#5717)
Two fixes:
1. Skip duplicate workspace dirs when listing skill commands across agents.
Multiple agents sharing the same workspace would produce duplicate commands
with _2, _3 suffixes.
2. Clear stale commands via deleteMyCommands before registering new ones.
Commands from deleted skills now get cleaned up on restart.
* fix: add size limits to unbounded in-memory caches (#4948)
Adds max-size caps with oldest-entry eviction to prevent OOM in
long-running deployments:
- BlueBubbles serverInfoCache: 64 entries (already has TTL)
- Google Chat authCache: 32 entries
- Matrix directRoomCache: 1024 entries
- Discord presenceCache: 5000 entries per account
* fix: address review concerns (#11093)
- Chain deleteMyCommands → setMyCommands to prevent race condition (#5717)
- Rename enablePluginEntry to registerPluginEntry (now sets enabled: false)
- Add Slow-Loris timeout test for readJsonBody (#6023)
2026-02-07 05:00:55 -08:00
|
|
|
next = registerPluginEntry(next, entry.pluginId);
|
2026-01-18 16:22:50 +00:00
|
|
|
next = ensureAllowlisted(next, entry.pluginId);
|
2026-01-20 15:58:26 +00:00
|
|
|
changes.push(formatAutoEnableChange(entry));
|
2026-01-18 16:22:50 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { config: next, changes };
|
|
|
|
|
}
|