Files
openclaw/src/config/plugin-auto-enable.ts

533 lines
15 KiB
TypeScript
Raw Normal View History

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";
import {
loadPluginManifestRegistry,
type PluginManifestRegistry,
} from "../plugins/manifest-registry.js";
import { isRecord } from "../utils.js";
import { hasAnyWhatsAppAuth } from "../web/accounts.js";
import type { OpenClawConfig } from "./config.js";
import { ensurePluginAllowlisted } from "./plugins-allowlist.js";
type PluginEnableChange = {
pluginId: string;
reason: string;
};
export type PluginAutoEnableResult = {
2026-01-30 03:15:10 +01:00
config: OpenClawConfig;
changes: string[];
};
const CHANNEL_PLUGIN_IDS = Array.from(
new Set([
...listChatChannels().map((meta) => meta.id),
...listChannelPluginCatalogEntries().map((entry) => entry.id),
]),
);
const PROVIDER_PLUGIN_IDS: Array<{ pluginId: string; providerId: string }> = [
{ pluginId: "google-gemini-cli-auth", providerId: "google-gemini-cli" },
{ pluginId: "qwen-portal-auth", providerId: "qwen-portal" },
{ pluginId: "copilot-proxy", providerId: "copilot-proxy" },
{ pluginId: "minimax-portal-auth", providerId: "minimax-portal" },
];
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;
}
function accountsHaveKeys(value: unknown, keys: readonly string[]): boolean {
if (!isRecord(value)) {
return false;
}
for (const account of Object.values(value)) {
if (!isRecord(account)) {
continue;
}
for (const key of keys) {
if (hasNonEmptyString(account[key])) {
return true;
}
}
}
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 {
const channels = cfg.channels as Record<string, unknown> | undefined;
const entry = channels?.[channelId];
return isRecord(entry) ? entry : null;
}
type StructuredChannelConfigSpec = {
envAny?: readonly string[];
envAll?: readonly string[];
stringKeys?: readonly string[];
numberKeys?: readonly string[];
accountStringKeys?: readonly string[];
};
const STRUCTURED_CHANNEL_CONFIG_SPECS: Record<string, StructuredChannelConfigSpec> = {
telegram: {
envAny: ["TELEGRAM_BOT_TOKEN"],
stringKeys: ["botToken", "tokenFile"],
accountStringKeys: ["botToken", "tokenFile"],
},
discord: {
envAny: ["DISCORD_BOT_TOKEN"],
stringKeys: ["token"],
accountStringKeys: ["token"],
},
irc: {
envAll: ["IRC_HOST", "IRC_NICK"],
stringKeys: ["host", "nick"],
accountStringKeys: ["host", "nick"],
},
slack: {
envAny: ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_USER_TOKEN"],
stringKeys: ["botToken", "appToken", "userToken"],
accountStringKeys: ["botToken", "appToken", "userToken"],
},
signal: {
stringKeys: ["account", "httpUrl", "httpHost", "cliPath"],
numberKeys: ["httpPort"],
accountStringKeys: ["account", "httpUrl", "httpHost", "cliPath"],
},
imessage: {
stringKeys: ["cliPath"],
},
};
function envHasAnyKeys(env: NodeJS.ProcessEnv, keys: readonly string[]): boolean {
for (const key of keys) {
if (hasNonEmptyString(env[key])) {
return true;
}
}
return false;
}
function envHasAllKeys(env: NodeJS.ProcessEnv, keys: readonly string[]): boolean {
for (const key of keys) {
if (!hasNonEmptyString(env[key])) {
return false;
}
}
return keys.length > 0;
}
function hasAnyNumberKeys(entry: Record<string, unknown>, keys: readonly string[]): boolean {
for (const key of keys) {
if (typeof entry[key] === "number") {
return true;
}
}
return false;
}
function isStructuredChannelConfigured(
cfg: OpenClawConfig,
channelId: string,
env: NodeJS.ProcessEnv,
spec: StructuredChannelConfigSpec,
): boolean {
if (spec.envAny && envHasAnyKeys(env, spec.envAny)) {
return true;
}
if (spec.envAll && envHasAllKeys(env, spec.envAll)) {
return true;
}
const entry = resolveChannelConfig(cfg, channelId);
if (!entry) {
return false;
}
if (spec.stringKeys && spec.stringKeys.some((key) => hasNonEmptyString(entry[key]))) {
return true;
}
if (spec.numberKeys && hasAnyNumberKeys(entry, spec.numberKeys)) {
return true;
}
if (spec.accountStringKeys && accountsHaveKeys(entry.accounts, spec.accountStringKeys)) {
return true;
}
return recordHasKeys(entry);
}
2026-01-30 03:15:10 +01:00
function isWhatsAppConfigured(cfg: OpenClawConfig): boolean {
if (hasAnyWhatsAppAuth(cfg)) {
return true;
}
const entry = resolveChannelConfig(cfg, "whatsapp");
if (!entry) {
return false;
}
return recordHasKeys(entry);
}
2026-01-30 03:15:10 +01:00
function isGenericChannelConfigured(cfg: OpenClawConfig, channelId: string): boolean {
const entry = resolveChannelConfig(cfg, channelId);
return recordHasKeys(entry);
}
export function isChannelConfigured(
2026-01-30 03:15:10 +01:00
cfg: OpenClawConfig,
channelId: string,
env: NodeJS.ProcessEnv = process.env,
): boolean {
if (channelId === "whatsapp") {
return isWhatsAppConfigured(cfg);
}
const spec = STRUCTURED_CHANNEL_CONFIG_SPECS[channelId];
if (spec) {
return isStructuredChannelConfigured(cfg, channelId, env, spec);
}
return isGenericChannelConfigured(cfg, channelId);
}
2026-01-30 03:15:10 +01:00
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));
}
2026-01-30 03:15:10 +01:00
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 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 {
// 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): string[] {
const channelIds = new Set<string>(CHANNEL_PLUGIN_IDS);
const configuredChannels = cfg.channels as Record<string, unknown> | undefined;
if (!configuredChannels || typeof configuredChannels !== "object") {
return Array.from(channelIds);
}
for (const key of Object.keys(configuredChannels)) {
if (key === "defaults" || key === "modelByChannel") {
continue;
}
const normalizedBuiltIn = normalizeChatChannelId(key);
channelIds.add(normalizedBuiltIn ?? key);
}
return Array.from(channelIds);
}
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,
registry: PluginManifestRegistry,
2026-01-18 18:43:31 +00:00
): PluginEnableChange[] {
const changes: PluginEnableChange[] = [];
// Build reverse map: channel ID → plugin ID from installed plugin manifests.
const channelToPluginId = buildChannelToPluginIdMap(registry);
for (const channelId of collectCandidateChannelIds(cfg)) {
const pluginId = resolvePluginIdForChannel(channelId, channelToPluginId);
if (isChannelConfigured(cfg, channelId, env)) {
changes.push({ pluginId, 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`,
});
}
}
feat: ACP thread-bound agents (#23580) * docs: add ACP thread-bound agents plan doc * docs: expand ACP implementation specification * feat(acp): route ACP sessions through core dispatch and lifecycle cleanup * feat(acp): add /acp commands and Discord spawn gate * ACP: add acpx runtime plugin backend * fix(subagents): defer transient lifecycle errors before announce * Agents: harden ACP sessions_spawn and tighten spawn guidance * Agents: require explicit ACP target for runtime spawns * docs: expand ACP control-plane implementation plan * ACP: harden metadata seeding and spawn guidance * ACP: centralize runtime control-plane manager and fail-closed dispatch * ACP: harden runtime manager and unify spawn helpers * Commands: route ACP sessions through ACP runtime in agent command * ACP: require persisted metadata for runtime spawns * Sessions: preserve ACP metadata when updating entries * Plugins: harden ACP backend registry across loaders * ACPX: make availability probe compatible with adapters * E2E: add manual Discord ACP plain-language smoke script * ACPX: preserve streamed spacing across Discord delivery * Docs: add ACP Discord streaming strategy * ACP: harden Discord stream buffering for thread replies * ACP: reuse shared block reply pipeline for projector * ACP: unify streaming config and adopt coalesceIdleMs * Docs: add temporary ACP production hardening plan * Docs: trim temporary ACP hardening plan goals * Docs: gate ACP thread controls by backend capabilities * ACP: add capability-gated runtime controls and /acp operator commands * Docs: remove temporary ACP hardening plan * ACP: fix spawn target validation and close cache cleanup * ACP: harden runtime dispatch and recovery paths * ACP: split ACP command/runtime internals and centralize policy * ACP: harden runtime lifecycle, validation, and observability * ACP: surface runtime and backend session IDs in thread bindings * docs: add temp plan for binding-service migration * ACP: migrate thread binding flows to SessionBindingService * ACP: address review feedback and preserve prompt wording * ACPX plugin: pin runtime dependency and prefer bundled CLI * Discord: complete binding-service migration cleanup and restore ACP plan * Docs: add standalone ACP agents guide * ACP: route harness intents to thread-bound ACP sessions * ACP: fix spawn thread routing and queue-owner stall * ACP: harden startup reconciliation and command bypass handling * ACP: fix dispatch bypass type narrowing * ACP: align runtime metadata to agentSessionId * ACP: normalize session identifier handling and labels * ACP: mark thread banner session ids provisional until first reply * ACP: stabilize session identity mapping and startup reconciliation * ACP: add resolved session-id notices and cwd in thread intros * Discord: prefix thread meta notices consistently * Discord: unify ACP/thread meta notices with gear prefix * Discord: split thread persona naming from meta formatting * Extensions: bump acpx plugin dependency to 0.1.9 * Agents: gate ACP prompt guidance behind acp.enabled * Docs: remove temp experiment plan docs * Docs: scope streaming plan to holy grail refactor * Docs: refactor ACP agents guide for human-first flow * Docs/Skill: add ACP feature-flag guidance and direct acpx telephone-game flow * Docs/Skill: add OpenCode and Pi to ACP harness lists * Docs/Skill: align ACP harness list with current acpx registry * Dev/Test: move ACP plain-language smoke script and mark as keep * Docs/Skill: reorder ACP harness lists with Pi first * ACP: split control-plane manager into core/types/utils modules * Docs: refresh ACP thread-bound agents plan * ACP: extract dispatch lane and split manager domains * ACP: centralize binding context and remove reverse deps * Infra: unify system message formatting * ACP: centralize error boundaries and session id rendering * ACP: enforce init concurrency cap and strict meta clear * Tests: fix ACP dispatch binding mock typing * Tests: fix Discord thread-binding mock drift and ACP request id * ACP: gate slash bypass and persist cleared overrides * ACPX: await pre-abort cancel before runTurn return * Extension: pin acpx runtime dependency to 0.1.11 * Docs: add pinned acpx install strategy for ACP extension * Extensions/acpx: enforce strict local pinned startup * Extensions/acpx: tighten acp-router install guidance * ACPX: retry runtime test temp-dir cleanup * Extensions/acpx: require proactive ACPX repair for thread spawns * Extensions/acpx: require restart offer after acpx reinstall * extensions/acpx: remove workspace protocol devDependency * extensions/acpx: bump pinned acpx to 0.1.13 * extensions/acpx: sync lockfile after dependency bump * ACPX: make runtime spawn Windows-safe * fix: align doctor-config-flow repair tests with default-account migration (#23580) (thanks @osolmaz)
2026-02-26 11:00:09 +01:00
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",
reason: "ACP runtime configured",
});
}
return changes;
}
2026-01-30 03:15:10 +01:00
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;
}
2026-01-30 03:15:10 +01:00
function isPluginDenied(cfg: OpenClawConfig, pluginId: string): boolean {
const deny = cfg.plugins?.deny;
return Array.isArray(deny) && deny.includes(pluginId);
}
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,
entry: PluginEnableChange,
2026-01-19 20:16:14 -08:00
configured: PluginEnableChange[],
): 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);
if (preferOver.includes(entry.pluginId)) {
return true;
}
}
return false;
2026-01-19 20:16:14 -08:00
}
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 {
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,
},
};
}
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}, enabled automatically.`;
2026-01-20 15:58:26 +00:00
}
export function applyPluginAutoEnable(params: {
2026-01-30 03:15:10 +01:00
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 env = params.env ?? process.env;
const registry = params.manifestRegistry ?? loadPluginManifestRegistry({ config: params.config });
const configured = resolveConfiguredPlugins(params.config, env, registry);
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) {
const builtInChannelId = normalizeChatChannelId(entry.pluginId);
if (isPluginDenied(next, entry.pluginId)) {
continue;
}
if (isPluginExplicitlyDisabled(next, entry.pluginId)) {
continue;
}
if (shouldSkipPreferredPluginAutoEnable(next, entry, configured)) {
continue;
}
const allow = next.plugins?.allow;
const allowMissing = 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;
}
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);
if (allowMissing || !builtInChannelId) {
next = ensurePluginAllowlisted(next, entry.pluginId);
}
2026-01-20 15:58:26 +00:00
changes.push(formatAutoEnableChange(entry));
}
return { config: next, changes };
}