fix(outbound): restore generic delivery and security seams

This commit is contained in:
Peter Steinberger
2026-04-03 17:55:27 +01:00
parent ab96520bba
commit 856592cf00
57 changed files with 1930 additions and 1517 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -12,6 +12,7 @@ export * from "./src/probe.js";
export * from "./src/session-key-normalization.js";
export * from "./src/status-issues.js";
export * from "./src/targets.js";
export * from "./src/security-audit.js";
export { resolveDiscordRuntimeGroupPolicy } from "./src/runtime-group-policy.js";
export {
DISCORD_DEFAULT_INBOUND_WORKER_TIMEOUT_MS,

View File

@@ -0,0 +1,235 @@
import {
isDangerousNameMatchingEnabled,
resolveNativeCommandsEnabled,
resolveNativeSkillsEnabled,
} from "openclaw/plugin-sdk/config-runtime";
import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runtime";
import type { ResolvedDiscordAccount } from "./accounts.js";
import type { OpenClawConfig } from "./runtime-api.js";
function normalizeAllowFromList(list: Array<string | number> | undefined | null): string[] {
if (!Array.isArray(list)) {
return [];
}
return list.map((value) => String(value).trim()).filter(Boolean);
}
function coerceNativeSetting(value: unknown): boolean | "auto" | undefined {
if (value === true || value === false || value === "auto") {
return value;
}
return undefined;
}
function isDiscordMutableAllowEntry(raw: string): boolean {
const text = raw.trim();
if (!text || text === "*") {
return false;
}
const maybeMentionId = text.replace(/^<@!?/, "").replace(/>$/, "");
if (/^\d+$/.test(maybeMentionId)) {
return false;
}
for (const prefix of ["discord:", "user:", "pk:"]) {
if (!text.startsWith(prefix)) {
continue;
}
return text.slice(prefix.length).trim().length === 0;
}
return true;
}
function addDiscordNameBasedEntries(params: {
target: Set<string>;
values: unknown;
source: string;
}) {
if (!Array.isArray(params.values)) {
return;
}
for (const value of params.values) {
if (!isDiscordMutableAllowEntry(String(value))) {
continue;
}
const text = String(value).trim();
if (!text) {
continue;
}
params.target.add(`${params.source}:${text}`);
}
}
export async function collectDiscordSecurityAuditFindings(params: {
cfg: OpenClawConfig;
accountId?: string | null;
account: ResolvedDiscordAccount;
orderedAccountIds: string[];
hasExplicitAccountPath: boolean;
}) {
const findings: Array<{
checkId: string;
severity: "info" | "warn" | "critical";
title: string;
detail: string;
remediation?: string;
}> = [];
const discordCfg = params.account.config ?? {};
const accountId = params.accountId?.trim() || params.account.accountId || "default";
const dangerousNameMatchingEnabled = isDangerousNameMatchingEnabled(discordCfg);
const storeAllowFrom = await readChannelAllowFromStore("discord", process.env, accountId).catch(
() => [],
);
const discordNameBasedAllowEntries = new Set<string>();
const discordPathPrefix =
params.orderedAccountIds.length > 1 || params.hasExplicitAccountPath
? `channels.discord.accounts.${accountId}`
: "channels.discord";
addDiscordNameBasedEntries({
target: discordNameBasedAllowEntries,
values: discordCfg.allowFrom,
source: `${discordPathPrefix}.allowFrom`,
});
addDiscordNameBasedEntries({
target: discordNameBasedAllowEntries,
values: (discordCfg.dm as { allowFrom?: unknown } | undefined)?.allowFrom,
source: `${discordPathPrefix}.dm.allowFrom`,
});
addDiscordNameBasedEntries({
target: discordNameBasedAllowEntries,
values: storeAllowFrom,
source: "~/.openclaw/credentials/discord-allowFrom.json",
});
const guildEntries = (discordCfg.guilds as Record<string, unknown> | undefined) ?? {};
for (const [guildKey, guildValue] of Object.entries(guildEntries)) {
if (!guildValue || typeof guildValue !== "object") {
continue;
}
const guild = guildValue as Record<string, unknown>;
addDiscordNameBasedEntries({
target: discordNameBasedAllowEntries,
values: guild.users,
source: `${discordPathPrefix}.guilds.${guildKey}.users`,
});
const channels = guild.channels;
if (!channels || typeof channels !== "object") {
continue;
}
for (const [channelKey, channelValue] of Object.entries(channels as Record<string, unknown>)) {
if (!channelValue || typeof channelValue !== "object") {
continue;
}
const channel = channelValue as Record<string, unknown>;
addDiscordNameBasedEntries({
target: discordNameBasedAllowEntries,
values: channel.users,
source: `${discordPathPrefix}.guilds.${guildKey}.channels.${channelKey}.users`,
});
}
}
if (discordNameBasedAllowEntries.size > 0) {
const examples = Array.from(discordNameBasedAllowEntries).slice(0, 5);
const more =
discordNameBasedAllowEntries.size > examples.length
? ` (+${discordNameBasedAllowEntries.size - examples.length} more)`
: "";
findings.push({
checkId: "channels.discord.allowFrom.name_based_entries",
severity: dangerousNameMatchingEnabled ? "info" : "warn",
title: dangerousNameMatchingEnabled
? "Discord allowlist uses break-glass name/tag matching"
: "Discord allowlist contains name or tag entries",
detail: dangerousNameMatchingEnabled
? "Discord name/tag allowlist matching is explicitly enabled via dangerouslyAllowNameMatching. This mutable-identity mode is operator-selected break-glass behavior and out-of-scope for vulnerability reports by itself. " +
`Found: ${examples.join(", ")}${more}.`
: "Discord name/tag allowlist matching uses normalized slugs and can collide across users. " +
`Found: ${examples.join(", ")}${more}.`,
remediation: dangerousNameMatchingEnabled
? "Prefer stable Discord IDs (or <@id>/user:<id>/pk:<id>), then disable dangerouslyAllowNameMatching."
: "Prefer stable Discord IDs (or <@id>/user:<id>/pk:<id>) in channels.discord.allowFrom and channels.discord.guilds.*.users, or explicitly opt in with dangerouslyAllowNameMatching=true if you accept the risk.",
});
}
const nativeEnabled = resolveNativeCommandsEnabled({
providerId: "discord",
providerSetting: coerceNativeSetting(
(discordCfg.commands as { native?: unknown } | undefined)?.native,
),
globalSetting: params.cfg.commands?.native,
});
const nativeSkillsEnabled = resolveNativeSkillsEnabled({
providerId: "discord",
providerSetting: coerceNativeSetting(
(discordCfg.commands as { nativeSkills?: unknown } | undefined)?.nativeSkills,
),
globalSetting: params.cfg.commands?.nativeSkills,
});
if (!nativeEnabled && !nativeSkillsEnabled) {
return findings;
}
const defaultGroupPolicy = params.cfg.channels?.defaults?.groupPolicy;
const groupPolicy =
(discordCfg.groupPolicy as string | undefined) ?? defaultGroupPolicy ?? "allowlist";
const guildsConfigured = Object.keys(guildEntries).length > 0;
const hasAnyUserAllowlist = Object.values(guildEntries).some((guild) => {
if (!guild || typeof guild !== "object") {
return false;
}
const record = guild as Record<string, unknown>;
if (Array.isArray(record.users) && record.users.length > 0) {
return true;
}
const channels = record.channels;
if (!channels || typeof channels !== "object") {
return false;
}
return Object.values(channels as Record<string, unknown>).some((channel) => {
if (!channel || typeof channel !== "object") {
return false;
}
const channelRecord = channel as Record<string, unknown>;
return Array.isArray(channelRecord.users) && channelRecord.users.length > 0;
});
});
const dmAllowFromRaw = (discordCfg.dm as { allowFrom?: unknown } | undefined)?.allowFrom;
const dmAllowFrom = Array.isArray(dmAllowFromRaw) ? dmAllowFromRaw : [];
const ownerAllowFromConfigured =
normalizeAllowFromList([...dmAllowFrom, ...storeAllowFrom]).length > 0;
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
if (!useAccessGroups && groupPolicy !== "disabled" && guildsConfigured && !hasAnyUserAllowlist) {
findings.push({
checkId: "channels.discord.commands.native.unrestricted",
severity: "critical",
title: "Discord slash commands are unrestricted",
detail:
"commands.useAccessGroups=false disables sender allowlists for Discord slash commands unless a per-guild/channel users allowlist is configured; with no users allowlist, any user in allowed guild channels can invoke /… commands.",
remediation:
"Set commands.useAccessGroups=true (recommended), or configure channels.discord.guilds.<id>.users (or channels.discord.guilds.<id>.channels.<channel>.users).",
});
} else if (
useAccessGroups &&
groupPolicy !== "disabled" &&
guildsConfigured &&
!ownerAllowFromConfigured &&
!hasAnyUserAllowlist
) {
findings.push({
checkId: "channels.discord.commands.native.no_allowlists",
severity: "warn",
title: "Discord slash commands have no allowlists",
detail:
"Discord slash commands are enabled, but neither an owner allowFrom list nor any per-guild/channel users allowlist is configured; /… commands will be rejected for everyone.",
remediation:
"Add your user id to channels.discord.allowFrom (or approve yourself via pairing), or configure channels.discord.guilds.<id>.users.",
});
}
return findings;
}

View File

@@ -8,6 +8,7 @@ import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
import { createScopedChannelMediaMaxBytesResolver } from "openclaw/plugin-sdk/media-runtime";
import {
resolveOutboundSendDep,
sanitizeForPlainText,
type OutboundSendDeps,
} from "openclaw/plugin-sdk/outbound-runtime";
import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime";
@@ -34,6 +35,7 @@ export const signalOutbound: ChannelOutboundAdapter = {
chunker: (text, _limit) => text.split(/\n{2,}/).flatMap((chunk) => (chunk ? [chunk] : [])),
chunkerMode: "text",
textChunkLimit: 4000,
sanitizeText: ({ text }) => sanitizeForPlainText(text),
sendFormattedText: async ({ cfg, to, text, accountId, deps, abortSignal }) => {
const send = resolveSignalSender(deps);
const maxBytes = resolveSignalMaxBytes({

View File

@@ -13,6 +13,7 @@ export * from "./src/message-actions.js";
export * from "./src/group-policy.js";
export * from "./src/monitor/allow-list.js";
export * from "./src/probe.js";
export * from "./src/security-audit.js";
export * from "./src/sent-thread-cache.js";
export * from "./src/targets.js";
export * from "./src/threading-tool-context.js";

View File

@@ -0,0 +1,105 @@
import {
resolveNativeCommandsEnabled,
resolveNativeSkillsEnabled,
} from "openclaw/plugin-sdk/config-runtime";
import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runtime";
import type { ResolvedSlackAccount } from "./accounts.js";
import type { OpenClawConfig } from "./runtime-api.js";
function normalizeAllowFromList(list: Array<string | number> | undefined | null): string[] {
if (!Array.isArray(list)) {
return [];
}
return list.map((value) => String(value).trim()).filter(Boolean);
}
function coerceNativeSetting(value: unknown): boolean | "auto" | undefined {
if (value === true || value === false || value === "auto") {
return value;
}
return undefined;
}
export async function collectSlackSecurityAuditFindings(params: {
cfg: OpenClawConfig;
accountId?: string | null;
account: ResolvedSlackAccount;
}) {
const findings: Array<{
checkId: string;
severity: "info" | "warn" | "critical";
title: string;
detail: string;
remediation?: string;
}> = [];
const slackCfg = params.account.config ?? {};
const accountId = params.accountId?.trim() || params.account.accountId || "default";
const nativeEnabled = resolveNativeCommandsEnabled({
providerId: "slack",
providerSetting: coerceNativeSetting(
(slackCfg.commands as { native?: unknown } | undefined)?.native,
),
globalSetting: params.cfg.commands?.native,
});
const nativeSkillsEnabled = resolveNativeSkillsEnabled({
providerId: "slack",
providerSetting: coerceNativeSetting(
(slackCfg.commands as { nativeSkills?: unknown } | undefined)?.nativeSkills,
),
globalSetting: params.cfg.commands?.nativeSkills,
});
const slashCommandEnabled =
nativeEnabled ||
nativeSkillsEnabled ||
(slackCfg.slashCommand as { enabled?: unknown } | undefined)?.enabled === true;
if (!slashCommandEnabled) {
return findings;
}
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
if (!useAccessGroups) {
findings.push({
checkId: "channels.slack.commands.slash.useAccessGroups_off",
severity: "critical",
title: "Slack slash commands bypass access groups",
detail:
"Slack slash/native commands are enabled while commands.useAccessGroups=false; this can allow unrestricted /… command execution from channels/users you didn't explicitly authorize.",
remediation: "Set commands.useAccessGroups=true (recommended).",
});
return findings;
}
const allowFromRaw = slackCfg.allowFrom;
const legacyAllowFromRaw = (params.account as { dm?: { allowFrom?: unknown } }).dm?.allowFrom;
const allowFrom = Array.isArray(allowFromRaw)
? allowFromRaw
: Array.isArray(legacyAllowFromRaw)
? legacyAllowFromRaw
: [];
const storeAllowFrom = await readChannelAllowFromStore("slack", process.env, accountId).catch(
() => [],
);
const ownerAllowFromConfigured =
normalizeAllowFromList([...allowFrom, ...storeAllowFrom]).length > 0;
const channels = (slackCfg.channels as Record<string, unknown> | undefined) ?? {};
const hasAnyChannelUsersAllowlist = Object.values(channels).some((value) => {
if (!value || typeof value !== "object") {
return false;
}
const channel = value as Record<string, unknown>;
return Array.isArray(channel.users) && channel.users.length > 0;
});
if (!ownerAllowFromConfigured && !hasAnyChannelUsersAllowlist) {
findings.push({
checkId: "channels.slack.commands.slash.no_allowlists",
severity: "warn",
title: "Slack slash commands have no allowlists",
detail:
"Slack slash/native commands are enabled, but neither an owner allowFrom list nor any channels.<id>.users allowlist is configured; /… commands will be rejected for everyone.",
remediation:
"Approve yourself via pairing (recommended), or set channels.slack.allowFrom and/or channels.slack.channels.<id>.users.",
});
}
return findings;
}

View File

@@ -0,0 +1 @@
export * from "./src/security-audit.js";

View File

@@ -0,0 +1,28 @@
import type { ResolvedSynologyChatAccount } from "./types.js";
export function collectSynologyChatSecurityAuditFindings(params: {
accountId?: string | null;
account: ResolvedSynologyChatAccount;
orderedAccountIds: string[];
hasExplicitAccountPath: boolean;
}) {
if (!params.account.dangerouslyAllowNameMatching) {
return [];
}
const accountId = params.accountId?.trim() || params.account.accountId || "default";
const accountNote =
params.orderedAccountIds.length > 1 || params.hasExplicitAccountPath
? ` (account: ${accountId})`
: "";
return [
{
checkId: "channels.synology-chat.reply.dangerous_name_matching_enabled",
severity: "info" as const,
title: `Synology Chat dangerous name matching is enabled${accountNote}`,
detail:
"dangerouslyAllowNameMatching=true re-enables mutable username/nickname matching for reply delivery. This is a break-glass compatibility mode, not a hardened default.",
remediation:
"Prefer stable numeric Synology Chat user IDs for reply delivery, then disable dangerouslyAllowNameMatching.",
},
];
}

View File

@@ -15,6 +15,7 @@ export * from "./src/outbound-adapter.js";
export * from "./src/outbound-params.js";
export * from "./src/probe.js";
export * from "./src/reaction-level.js";
export * from "./src/security-audit.js";
export * from "./src/sticker-cache.js";
export * from "./src/status-issues.js";
export * from "./src/targets.js";

View File

@@ -0,0 +1,163 @@
import { resolveNativeSkillsEnabled } from "openclaw/plugin-sdk/config-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runtime";
import type { ResolvedTelegramAccount } from "./accounts.js";
import { isNumericTelegramUserId, normalizeTelegramAllowFromEntry } from "./allow-from.js";
function collectInvalidTelegramAllowFromEntries(params: { entries: unknown; target: Set<string> }) {
if (!Array.isArray(params.entries)) {
return;
}
for (const entry of params.entries) {
const normalized = normalizeTelegramAllowFromEntry(entry);
if (!normalized || normalized === "*") {
continue;
}
if (!isNumericTelegramUserId(normalized)) {
params.target.add(normalized);
}
}
}
export async function collectTelegramSecurityAuditFindings(params: {
cfg: OpenClawConfig;
accountId?: string | null;
account: ResolvedTelegramAccount;
}) {
const findings: Array<{
checkId: string;
severity: "info" | "warn" | "critical";
title: string;
detail: string;
remediation?: string;
}> = [];
if (params.cfg.commands?.text === false) {
return findings;
}
const telegramCfg = params.account.config ?? {};
const accountId = params.accountId?.trim() || params.account.accountId || "default";
const defaultGroupPolicy = params.cfg.channels?.defaults?.groupPolicy;
const groupPolicy =
(telegramCfg.groupPolicy as string | undefined) ?? defaultGroupPolicy ?? "allowlist";
const groups = telegramCfg.groups as Record<string, unknown> | undefined;
const groupsConfigured = Boolean(groups) && Object.keys(groups ?? {}).length > 0;
const groupAccessPossible =
groupPolicy === "open" || (groupPolicy === "allowlist" && groupsConfigured);
if (!groupAccessPossible) {
return findings;
}
const storeAllowFrom = await readChannelAllowFromStore("telegram", process.env, accountId).catch(
() => [],
);
const storeHasWildcard = storeAllowFrom.some((value) => String(value).trim() === "*");
const invalidTelegramAllowFromEntries = new Set<string>();
collectInvalidTelegramAllowFromEntries({
entries: storeAllowFrom,
target: invalidTelegramAllowFromEntries,
});
const groupAllowFrom = Array.isArray(telegramCfg.groupAllowFrom)
? telegramCfg.groupAllowFrom
: [];
const groupAllowFromHasWildcard = groupAllowFrom.some((value) => String(value).trim() === "*");
collectInvalidTelegramAllowFromEntries({
entries: groupAllowFrom,
target: invalidTelegramAllowFromEntries,
});
collectInvalidTelegramAllowFromEntries({
entries: Array.isArray(telegramCfg.allowFrom) ? telegramCfg.allowFrom : [],
target: invalidTelegramAllowFromEntries,
});
let anyGroupOverride = false;
if (groups) {
for (const value of Object.values(groups)) {
if (!value || typeof value !== "object") {
continue;
}
const group = value as Record<string, unknown>;
const allowFrom = Array.isArray(group.allowFrom) ? group.allowFrom : [];
if (allowFrom.length > 0) {
anyGroupOverride = true;
collectInvalidTelegramAllowFromEntries({
entries: allowFrom,
target: invalidTelegramAllowFromEntries,
});
}
const topics = group.topics;
if (!topics || typeof topics !== "object") {
continue;
}
for (const topicValue of Object.values(topics as Record<string, unknown>)) {
if (!topicValue || typeof topicValue !== "object") {
continue;
}
const topic = topicValue as Record<string, unknown>;
const topicAllow = Array.isArray(topic.allowFrom) ? topic.allowFrom : [];
if (topicAllow.length > 0) {
anyGroupOverride = true;
}
collectInvalidTelegramAllowFromEntries({
entries: topicAllow,
target: invalidTelegramAllowFromEntries,
});
}
}
}
const hasAnySenderAllowlist =
storeAllowFrom.length > 0 || groupAllowFrom.length > 0 || anyGroupOverride;
if (invalidTelegramAllowFromEntries.size > 0) {
const examples = Array.from(invalidTelegramAllowFromEntries).slice(0, 5);
const more =
invalidTelegramAllowFromEntries.size > examples.length
? ` (+${invalidTelegramAllowFromEntries.size - examples.length} more)`
: "";
findings.push({
checkId: "channels.telegram.allowFrom.invalid_entries",
severity: "warn",
title: "Telegram allowlist contains non-numeric entries",
detail:
"Telegram sender authorization requires numeric Telegram user IDs. " +
`Found non-numeric allowFrom entries: ${examples.join(", ")}${more}.`,
remediation:
"Replace @username entries with numeric Telegram user IDs (use setup to resolve), then re-run the audit.",
});
}
if (storeHasWildcard || groupAllowFromHasWildcard) {
findings.push({
checkId: "channels.telegram.groups.allowFrom.wildcard",
severity: "critical",
title: "Telegram group allowlist contains wildcard",
detail:
'Telegram group sender allowlist contains "*", which allows any group member to run /… commands and control directives.',
remediation:
'Remove "*" from channels.telegram.groupAllowFrom and pairing store; prefer explicit numeric Telegram user IDs.',
});
return findings;
}
if (!hasAnySenderAllowlist) {
const skillsEnabled = resolveNativeSkillsEnabled({
providerId: "telegram",
providerSetting: (telegramCfg.commands as { nativeSkills?: unknown } | undefined)
?.nativeSkills as boolean | "auto" | undefined,
globalSetting: params.cfg.commands?.nativeSkills,
});
findings.push({
checkId: "channels.telegram.groups.allowFrom.missing",
severity: "critical",
title: "Telegram group commands have no sender allowlist",
detail:
`Telegram group access is enabled but no sender allowlist is configured; this allows any group member to invoke /… commands` +
(skillsEnabled ? " (including skill commands)." : "."),
remediation:
"Approve yourself via pairing (recommended), or set channels.telegram.groupAllowFrom (or per-group groups.<id>.allowFrom).",
});
}
return findings;
}

View File

@@ -1,2 +1,3 @@
export * from "./src/setup-core.js";
export * from "./src/setup-surface.js";
export * from "./src/security-audit.js";

View File

@@ -0,0 +1,71 @@
import { isDangerousNameMatchingEnabled } from "../runtime-api.js";
import type { ResolvedZalouserAccount } from "./accounts.js";
function isZalouserMutableGroupEntry(raw: string): boolean {
const text = raw.trim();
if (!text || text === "*") {
return false;
}
const normalized = text
.replace(/^(zalouser|zlu):/i, "")
.replace(/^group:/i, "")
.trim();
if (!normalized) {
return false;
}
if (/^\d+$/.test(normalized)) {
return false;
}
return !/^g-\S+$/i.test(normalized);
}
export function collectZalouserSecurityAuditFindings(params: {
accountId?: string | null;
account: ResolvedZalouserAccount;
orderedAccountIds: string[];
hasExplicitAccountPath: boolean;
}) {
const zalouserCfg = params.account.config ?? {};
const accountId = params.accountId?.trim() || params.account.accountId || "default";
const dangerousNameMatchingEnabled = isDangerousNameMatchingEnabled(zalouserCfg);
const zalouserPathPrefix =
params.orderedAccountIds.length > 1 || params.hasExplicitAccountPath
? `channels.zalouser.accounts.${accountId}`
: "channels.zalouser";
const mutableGroupEntries = new Set<string>();
const groups = zalouserCfg.groups;
if (groups && typeof groups === "object" && !Array.isArray(groups)) {
for (const key of Object.keys(groups as Record<string, unknown>)) {
if (!isZalouserMutableGroupEntry(key)) {
continue;
}
mutableGroupEntries.add(`${zalouserPathPrefix}.groups:${key}`);
}
}
if (mutableGroupEntries.size === 0) {
return [];
}
const examples = Array.from(mutableGroupEntries).slice(0, 5);
const more =
mutableGroupEntries.size > examples.length
? ` (+${mutableGroupEntries.size - examples.length} more)`
: "";
const severity: "info" | "warn" = dangerousNameMatchingEnabled ? "info" : "warn";
return [
{
checkId: "channels.zalouser.groups.mutable_entries",
severity,
title: dangerousNameMatchingEnabled
? "Zalouser group routing uses break-glass name matching"
: "Zalouser group routing contains mutable group entries",
detail: dangerousNameMatchingEnabled
? "Zalouser group-name routing is explicitly enabled via dangerouslyAllowNameMatching. This mutable-identity mode is operator-selected break-glass behavior and out-of-scope for vulnerability reports by itself. " +
`Found: ${examples.join(", ")}${more}.`
: "Zalouser group auth is ID-only by default, so unresolved group-name or slug entries are ignored for auth and can drift from the intended trusted group. " +
`Found: ${examples.join(", ")}${more}.`,
remediation: dangerousNameMatchingEnabled
? "Prefer stable Zalo group IDs (for example group:<id> or provider-native g- ids), then disable dangerouslyAllowNameMatching."
: "Prefer stable Zalo group IDs in channels.zalouser.groups, or explicitly opt in with dangerouslyAllowNameMatching=true if you accept mutable group-name matching.",
},
];
}

View File

@@ -4,9 +4,13 @@ const { callGatewayMock } = vi.hoisted(() => ({
callGatewayMock: vi.fn(),
}));
vi.mock("../agent-scope.js", () => ({
resolveSessionAgentId: () => "agent-123",
}));
vi.mock("../agent-scope.js", async () => {
const actual = await vi.importActual<typeof import("../agent-scope.js")>("../agent-scope.js");
return {
...actual,
resolveSessionAgentId: () => "agent-123",
};
});
import { createCronTool } from "./cron-tool.js";

View File

@@ -1,3 +1,4 @@
import { sanitizeForPlainText } from "openclaw/plugin-sdk/outbound-runtime";
import {
resolvePayloadMediaUrls,
sendPayloadMediaSequence,
@@ -112,6 +113,7 @@ export function createDirectTextMediaOutbound<
chunker: chunkText,
chunkerMode: "text",
textChunkLimit: 4000,
sanitizeText: ({ text }) => sanitizeForPlainText(text),
sendPayload: async (ctx) =>
await sendTextMediaPayload({ channel: params.channel, ctx, adapter: outbound }),
sendText: async ({ cfg, to, text, accountId, deps, replyToId }) => {

View File

@@ -178,7 +178,10 @@ export type ChannelOutboundAdapter = {
chunker?: ((text: string, limit: number) => string[]) | null;
chunkerMode?: "text" | "markdown";
textChunkLimit?: number;
sanitizeText?: (params: { text: string; payload: ReplyPayload }) => string;
pollMaxOptions?: number;
supportsPollDurationSeconds?: boolean;
supportsAnonymousPolls?: boolean;
normalizePayload?: (params: { payload: ReplyPayload }) => ReplyPayload | null;
shouldSkipPlainTextSanitization?: (params: { payload: ReplyPayload }) => boolean;
resolveEffectiveTextChunkLimit?: (params: {
@@ -198,6 +201,15 @@ export type ChannelOutboundAdapter = {
payload: ReplyPayload;
hint?: ChannelOutboundPayloadHint;
}) => Promise<void> | void;
shouldTreatRoutedTextAsVisible?: (params: {
kind: "tool" | "block" | "final";
text?: string;
}) => boolean;
targetsMatchForReplySuppression?: (params: {
originTarget: string;
targetKey: string;
targetThreadId?: string;
}) => boolean;
resolveTarget?: (params: {
cfg?: OpenClawConfig;
to?: string;
@@ -217,6 +229,7 @@ export type ChannelOutboundAdapter = {
export type ChannelStatusAdapter<ResolvedAccount, Probe = unknown, Audit = unknown> = {
defaultRuntime?: ChannelAccountSnapshot;
skipStaleSocketHealthCheck?: boolean;
buildChannelSummary?: (params: {
account: ResolvedAccount;
cfg: OpenClawConfig;
@@ -497,6 +510,84 @@ export type ChannelElevatedAdapter = {
export type ChannelCommandAdapter = {
enforceOwnerForCommands?: boolean;
skipWhenConfigEmpty?: boolean;
nativeCommandsAutoEnabled?: boolean;
nativeSkillsAutoEnabled?: boolean;
preferSenderE164ForCommands?: boolean;
resolveNativeCommandName?: (params: {
commandKey: string;
defaultName: string;
}) => string | undefined;
buildCommandsListChannelData?: (params: {
currentPage: number;
totalPages: number;
agentId?: string;
}) => ReplyPayload["channelData"] | null;
buildModelsProviderChannelData?: (params: {
providers: Array<{ id: string; count: number }>;
}) => ReplyPayload["channelData"] | null;
buildModelsListChannelData?: (params: {
provider: string;
models: readonly string[];
currentModel?: string;
currentPage: number;
totalPages: number;
pageSize?: number;
modelNames?: ReadonlyMap<string, string>;
}) => ReplyPayload["channelData"] | null;
buildModelBrowseChannelData?: () => ReplyPayload["channelData"] | null;
};
export type ChannelDoctorConfigMutation = {
config: OpenClawConfig;
changes: string[];
warnings?: string[];
};
export type ChannelDoctorSequenceResult = {
changeNotes: string[];
warningNotes: string[];
};
export type ChannelDoctorEmptyAllowlistAccountContext = {
account: Record<string, unknown>;
channelName: string;
dmPolicy?: string;
effectiveAllowFrom?: Array<string | number>;
parent?: Record<string, unknown>;
prefix: string;
};
export type ChannelDoctorAdapter = {
dmAllowFromMode?: "topOnly" | "topOrNested" | "nestedOnly";
groupModel?: "sender" | "route" | "hybrid";
groupAllowFromFallbackToAllowFrom?: boolean;
warnOnEmptyGroupSenderAllowlist?: boolean;
normalizeCompatibilityConfig?: (params: { cfg: OpenClawConfig }) => ChannelDoctorConfigMutation;
collectPreviewWarnings?: (params: {
cfg: OpenClawConfig;
doctorFixCommand: string;
}) => string[] | Promise<string[]>;
collectMutableAllowlistWarnings?: (params: {
cfg: OpenClawConfig;
}) => string[] | Promise<string[]>;
repairConfig?: (params: {
cfg: OpenClawConfig;
doctorFixCommand: string;
}) => ChannelDoctorConfigMutation | Promise<ChannelDoctorConfigMutation>;
runConfigSequence?: (params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
shouldRepair: boolean;
}) => ChannelDoctorSequenceResult | Promise<ChannelDoctorSequenceResult>;
cleanStaleConfig?: (params: {
cfg: OpenClawConfig;
}) => ChannelDoctorConfigMutation | Promise<ChannelDoctorConfigMutation>;
collectEmptyAllowlistExtraWarnings?: (
params: ChannelDoctorEmptyAllowlistAccountContext,
) => string[];
shouldSkipDefaultEmptyGroupAllowlistWarning?: (
params: ChannelDoctorEmptyAllowlistAccountContext,
) => boolean;
};
export type ChannelLifecycleAdapter = {
@@ -511,6 +602,16 @@ export type ChannelLifecycleAdapter = {
accountId: string;
runtime: RuntimeEnv;
}) => Promise<void> | void;
runStartupMaintenance?: (params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
log: {
info?: (message: string) => void;
warn?: (message: string) => void;
};
trigger?: string;
logPrefix?: string;
}) => Promise<void> | void;
};
export type ChannelApprovalDeliveryAdapter = {
@@ -694,6 +795,7 @@ export type ChannelCommandConversationContext = {
};
export type ChannelConfiguredBindingProvider = {
selfParentConversationByDefault?: boolean;
compileConfiguredBinding: (params: {
binding: ConfiguredBindingRule;
conversationId: string;
@@ -711,6 +813,45 @@ export type ChannelConfiguredBindingProvider = {
export type ChannelConversationBindingSupport = {
supportsCurrentConversationBinding?: boolean;
/**
* Preferred placement when a command is started from a top-level conversation
* without an existing native thread id.
*
* - `current`: bind/spawn in the current conversation
* - `child`: create a child thread/conversation first
*/
defaultTopLevelPlacement?: "current" | "child";
resolveConversationRef?: (params: {
accountId?: string | null;
conversationId: string;
parentConversationId?: string;
threadId?: string | number | null;
}) => {
conversationId: string;
parentConversationId?: string;
} | null;
buildBoundReplyChannelData?: (params: {
operation: "acp-spawn";
placement: "current" | "child";
conversation: {
channel: string;
accountId?: string | null;
conversationId: string;
parentConversationId?: string;
};
}) => ReplyPayload["channelData"] | null | Promise<ReplyPayload["channelData"] | null>;
shouldStripThreadFromAnnounceOrigin?: (params: {
requester: {
channel?: string;
to?: string;
threadId?: string | number;
};
entry: {
channel?: string;
to?: string;
threadId?: string | number;
};
}) => boolean;
setIdleTimeoutBySessionKey?: (params: {
targetSessionKey: string;
accountId?: string | null;
@@ -745,4 +886,27 @@ export type ChannelSecurityAdapter<ResolvedAccount = unknown> = {
ctx: ChannelSecurityContext<ResolvedAccount>,
) => ChannelSecurityDmPolicy | null;
collectWarnings?: (ctx: ChannelSecurityContext<ResolvedAccount>) => Promise<string[]> | string[];
collectAuditFindings?: (
ctx: ChannelSecurityContext<ResolvedAccount> & {
sourceConfig: OpenClawConfig;
orderedAccountIds: string[];
hasExplicitAccountPath: boolean;
},
) =>
| Promise<
Array<{
checkId: string;
severity: "info" | "warn" | "critical";
title: string;
detail: string;
remediation?: string;
}>
>
| Array<{
checkId: string;
severity: "info" | "warn" | "critical";
title: string;
detail: string;
remediation?: string;
}>;
};

View File

@@ -401,6 +401,20 @@ export type ChannelMessagingAdapter = {
sessionKey: string;
ctx: MsgContext;
}) => string | undefined;
resolveInboundConversation?: (params: {
from?: string;
to?: string;
conversationId?: string;
threadId?: string | number;
isGroup: boolean;
}) => {
conversationId?: string;
parentConversationId?: string;
} | null;
resolveDeliveryTarget?: (params: { conversationId: string; parentConversationId?: string }) => {
to?: string;
threadId?: string;
} | null;
/**
* Canonical plugin-owned session conversation grammar.
* Use this when the provider encodes thread or scoped-conversation semantics
@@ -500,6 +514,12 @@ export type ChannelAgentPromptAdapter = {
cfg: OpenClawConfig;
accountId?: string | null;
}) => string[] | undefined;
inboundFormattingHints?: (params: { accountId?: string | null }) =>
| {
text_markup: string;
rules: string[];
}
| undefined;
reactionGuidance?: (params: {
cfg: OpenClawConfig;
accountId?: string | null;
@@ -567,6 +587,13 @@ export type ChannelMessageActionAdapter = {
params: ChannelMessageActionDiscoveryContext,
) => ChannelMessageToolDiscovery | null | undefined;
supportsAction?: (params: { action: ChannelMessageActionName }) => boolean;
resolveCliActionRequest?: (params: {
action: ChannelMessageActionName;
args: Record<string, unknown>;
}) => {
action: ChannelMessageActionName;
args: Record<string, unknown>;
};
requiresTrustedRequesterSender?: (params: {
action: ChannelMessageActionName;
toolContext?: ChannelThreadingToolContext;

View File

@@ -7,6 +7,7 @@ import type {
ChannelCommandAdapter,
ChannelConfigAdapter,
ChannelConversationBindingSupport,
ChannelDoctorAdapter,
ChannelDirectoryAdapter,
ChannelResolverAdapter,
ChannelElevatedAdapter,
@@ -107,6 +108,7 @@ export type ChannelPlugin<ResolvedAccount = any, Probe = unknown, Audit = unknow
lifecycle?: ChannelLifecycleAdapter;
approvals?: ChannelApprovalAdapter;
allowlist?: ChannelAllowlistAdapter;
doctor?: ChannelDoctorAdapter;
bindings?: ChannelConfiguredBindingProvider;
conversationBindings?: ChannelConversationBindingSupport;
streaming?: ChannelStreamingAdapter;

View File

@@ -1,3 +1,4 @@
import { normalizeAnyChannelId } from "../channels/registry.js";
import type { OutboundSendDeps } from "../infra/outbound/deliver.js";
/**
@@ -6,23 +7,39 @@ import type { OutboundSendDeps } from "../infra/outbound/deliver.js";
*/
export type CliOutboundSendSource = { [channelId: string]: unknown };
const LEGACY_SOURCE_TO_CHANNEL = {
sendMessageWhatsApp: "whatsapp",
sendMessageTelegram: "telegram",
sendMessageDiscord: "discord",
sendMessageSlack: "slack",
sendMessageSignal: "signal",
sendMessageIMessage: "imessage",
} as const;
function normalizeLegacyChannelStem(raw: string): string {
return raw
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
.replace(/_/g, "-")
.trim()
.toLowerCase()
.replace(/-/g, "");
}
const CHANNEL_TO_LEGACY_DEP_KEY = {
whatsapp: "sendWhatsApp",
telegram: "sendTelegram",
discord: "sendDiscord",
slack: "sendSlack",
signal: "sendSignal",
imessage: "sendIMessage",
} as const;
function resolveChannelIdFromLegacySourceKey(key: string): string | undefined {
const match = key.match(/^sendMessage(.+)$/);
if (!match) {
return undefined;
}
const normalizedStem = normalizeLegacyChannelStem(match[1] ?? "");
return normalizeAnyChannelId(normalizedStem) ?? (normalizedStem || undefined);
}
function resolveLegacyDepKeysForChannel(channelId: string): string[] {
const compact = channelId.replace(/[^a-z0-9]+/gi, "");
if (!compact) {
return [];
}
const pascal = compact.charAt(0).toUpperCase() + compact.slice(1);
const keys = new Set<string>([`send${pascal}`]);
if (pascal.startsWith("I") && pascal.length > 1) {
keys.add(`sendI${pascal.slice(1)}`);
}
if (pascal.startsWith("Ms") && pascal.length > 2) {
keys.add(`sendMS${pascal.slice(2)}`);
}
return [...keys];
}
/**
* Pass CLI send sources through as-is — both CliOutboundSendSource and
@@ -31,17 +48,26 @@ const CHANNEL_TO_LEGACY_DEP_KEY = {
export function createOutboundSendDepsFromCliSource(deps: CliOutboundSendSource): OutboundSendDeps {
const outbound: OutboundSendDeps = { ...deps };
for (const [legacySourceKey, channelId] of Object.entries(LEGACY_SOURCE_TO_CHANNEL)) {
for (const legacySourceKey of Object.keys(deps)) {
const channelId = resolveChannelIdFromLegacySourceKey(legacySourceKey);
if (!channelId) {
continue;
}
const sourceValue = deps[legacySourceKey];
if (sourceValue !== undefined && outbound[channelId] === undefined) {
outbound[channelId] = sourceValue;
}
}
for (const [channelId, legacyDepKey] of Object.entries(CHANNEL_TO_LEGACY_DEP_KEY)) {
for (const channelId of Object.keys(outbound)) {
const sourceValue = outbound[channelId];
if (sourceValue !== undefined && outbound[legacyDepKey] === undefined) {
outbound[legacyDepKey] = sourceValue;
if (sourceValue === undefined) {
continue;
}
for (const legacyDepKey of resolveLegacyDepKeysForChannel(channelId)) {
if (outbound[legacyDepKey] === undefined) {
outbound[legacyDepKey] = sourceValue;
}
}
}

View File

@@ -1,7 +1,6 @@
import { createPluginBoundaryRuntimeSend } from "./plugin-boundary-send.js";
import { createChannelOutboundRuntimeSend } from "./channel-outbound-send.js";
export const runtimeSend = createPluginBoundaryRuntimeSend({
pluginId: "discord",
exportName: "sendMessageDiscord",
missingLabel: "Discord plugin runtime",
export const runtimeSend = createChannelOutboundRuntimeSend({
channelId: "discord",
unavailableMessage: "Discord outbound adapter is unavailable.",
});

View File

@@ -1,37 +0,0 @@
import { createCachedPluginBoundaryModuleLoader } from "../../plugins/runtime/runtime-plugin-boundary.js";
type RuntimeSendModule = Record<string, unknown>;
export type RuntimeSend = {
sendMessage: (...args: unknown[]) => Promise<unknown>;
};
function resolveRuntimeExport(
module: RuntimeSendModule | null,
pluginId: string,
exportName: string,
): (...args: unknown[]) => Promise<unknown> {
const candidate = module?.[exportName];
if (typeof candidate !== "function") {
throw new Error(`${pluginId} plugin runtime is unavailable: missing export '${exportName}'`);
}
return candidate as (...args: unknown[]) => Promise<unknown>;
}
export function createPluginBoundaryRuntimeSend(params: {
pluginId: string;
exportName: string;
missingLabel: string;
}): RuntimeSend {
const loadRuntimeModuleSync = createCachedPluginBoundaryModuleLoader<RuntimeSendModule>({
pluginId: params.pluginId,
entryBaseName: "runtime-api",
required: true,
missingLabel: params.missingLabel,
});
return {
sendMessage: (...args) =>
resolveRuntimeExport(loadRuntimeModuleSync(), params.pluginId, params.exportName)(...args),
};
}

View File

@@ -1,7 +1,6 @@
import { createPluginBoundaryRuntimeSend } from "./plugin-boundary-send.js";
import { createChannelOutboundRuntimeSend } from "./channel-outbound-send.js";
export const runtimeSend = createPluginBoundaryRuntimeSend({
pluginId: "signal",
exportName: "sendMessageSignal",
missingLabel: "Signal plugin runtime",
export const runtimeSend = createChannelOutboundRuntimeSend({
channelId: "signal",
unavailableMessage: "Signal outbound adapter is unavailable.",
});

View File

@@ -1,7 +1,6 @@
import { createPluginBoundaryRuntimeSend } from "./plugin-boundary-send.js";
import { createChannelOutboundRuntimeSend } from "./channel-outbound-send.js";
export const runtimeSend = createPluginBoundaryRuntimeSend({
pluginId: "slack",
exportName: "sendMessageSlack",
missingLabel: "Slack plugin runtime",
export const runtimeSend = createChannelOutboundRuntimeSend({
channelId: "slack",
unavailableMessage: "Slack outbound adapter is unavailable.",
});

View File

@@ -1,39 +1,6 @@
import { loadChannelOutboundAdapter } from "../../channels/plugins/outbound/load.js";
import { loadConfig } from "../../config/config.js";
import { createChannelOutboundRuntimeSend } from "./channel-outbound-send.js";
type TelegramRuntimeSendOpts = {
cfg?: ReturnType<typeof loadConfig>;
mediaUrl?: string;
mediaLocalRoots?: readonly string[];
accountId?: string;
messageThreadId?: string | number;
replyToMessageId?: string | number;
silent?: boolean;
forceDocument?: boolean;
gatewayClientScopes?: readonly string[];
};
export const runtimeSend = {
sendMessage: async (to: string, text: string, opts: TelegramRuntimeSendOpts = {}) => {
const outbound = await loadChannelOutboundAdapter("telegram");
if (!outbound?.sendText) {
throw new Error("Telegram outbound adapter is unavailable.");
}
return await outbound.sendText({
cfg: opts.cfg ?? loadConfig(),
to,
text,
mediaUrl: opts.mediaUrl,
mediaLocalRoots: opts.mediaLocalRoots,
accountId: opts.accountId,
threadId: opts.messageThreadId,
replyToId:
opts.replyToMessageId == null
? undefined
: String(opts.replyToMessageId).trim() || undefined,
silent: opts.silent,
forceDocument: opts.forceDocument,
gatewayClientScopes: opts.gatewayClientScopes,
});
},
};
export const runtimeSend = createChannelOutboundRuntimeSend({
channelId: "telegram",
unavailableMessage: "Telegram outbound adapter is unavailable.",
});

View File

@@ -1,7 +1,6 @@
import { createPluginBoundaryRuntimeSend } from "./plugin-boundary-send.js";
import { createChannelOutboundRuntimeSend } from "./channel-outbound-send.js";
export const runtimeSend = createPluginBoundaryRuntimeSend({
pluginId: "whatsapp",
exportName: "sendMessageWhatsApp",
missingLabel: "WhatsApp plugin runtime",
export const runtimeSend = createChannelOutboundRuntimeSend({
channelId: "whatsapp",
unavailableMessage: "WhatsApp outbound adapter is unavailable.",
});

View File

@@ -76,7 +76,7 @@ describe("generic current-conversation bindings", () => {
).toBeNull();
});
it("keeps Slack current-conversation binding support when the runtime registry is empty", () => {
it("requires an active channel plugin registration", () => {
setActivePluginRegistry(createTestRegistry([]));
expect(
@@ -84,12 +84,7 @@ describe("generic current-conversation bindings", () => {
channel: "slack",
accountId: "default",
}),
).toEqual({
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current"],
});
).toBeNull();
});
it("reloads persisted bindings after the in-memory cache is cleared", async () => {

View File

@@ -22,7 +22,6 @@ type PersistedCurrentConversationBindingsFile = {
const CURRENT_BINDINGS_FILE_VERSION = 1;
const CURRENT_BINDINGS_ID_PREFIX = "generic:";
const FALLBACK_CURRENT_CONVERSATION_BINDING_CHANNELS = new Set(["slack"]);
let bindingsLoaded = false;
let persistPromise: Promise<void> = Promise.resolve();
@@ -136,10 +135,7 @@ function resolveChannelSupportsCurrentConversationBinding(channel: string): bool
if (plugin?.conversationBindings?.supportsCurrentConversationBinding === true) {
return true;
}
// Slack live/gateway tests intentionally skip channel startup, so there is no
// active runtime plugin snapshot even though the generic current-conversation
// path is still expected to work.
return FALLBACK_CURRENT_CONVERSATION_BINDING_CHANNELS.has(normalized);
return false;
}
export function getGenericCurrentConversationBindingCapabilities(params: {

View File

@@ -1,6 +1,9 @@
import { vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import {
releasePinnedPluginChannelRegistry,
setActivePluginRegistry,
} from "../../plugins/runtime.js";
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js";
import { createInternalHookEventPayload } from "../../test-utils/internal-hook-event-payload.js";
@@ -174,6 +177,7 @@ export const defaultRegistry = createTestRegistry([
export const emptyRegistry = createTestRegistry([]);
export function resetDeliverTestState() {
releasePinnedPluginChannelRegistry();
setActivePluginRegistry(defaultRegistry);
deliverMocks.hooks.runner.hasHooks = () => false;
deliverMocks.hooks.runner.runMessageSent = async () => {};
@@ -190,6 +194,7 @@ export function resetDeliverTestState() {
}
export function clearDeliverTestRegistry() {
releasePinnedPluginChannelRegistry();
setActivePluginRegistry(emptyRegistry);
}

View File

@@ -4,7 +4,10 @@ import type { OpenClawConfig } from "../../config/config.js";
import { createHookRunner } from "../../plugins/hooks.js";
import { addTestHook } from "../../plugins/hooks.test-helpers.js";
import { createEmptyPluginRegistry } from "../../plugins/registry.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import {
releasePinnedPluginChannelRegistry,
setActivePluginRegistry,
} from "../../plugins/runtime.js";
import type { PluginHookRegistration } from "../../plugins/types.js";
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js";
@@ -189,6 +192,7 @@ describe("deliverOutboundPayloads", () => {
});
beforeEach(() => {
releasePinnedPluginChannelRegistry();
setActivePluginRegistry(defaultRegistry);
mocks.appendAssistantMessageToSessionTranscript.mockClear();
hookMocks.runner.hasHooks.mockClear();
@@ -210,6 +214,7 @@ describe("deliverOutboundPayloads", () => {
});
afterEach(() => {
releasePinnedPluginChannelRegistry();
setActivePluginRegistry(emptyRegistry);
});
it("chunks direct adapter text and preserves delivery overrides across sends", async () => {

View File

@@ -35,7 +35,6 @@ import type { OutboundIdentity } from "./identity.js";
import type { DeliveryMirror } from "./mirror.js";
import type { NormalizedOutboundPayload } from "./payloads.js";
import { normalizeReplyPayloadsForDelivery } from "./payloads.js";
import { isPlainTextSurface, sanitizeForPlainText } from "./sanitize-text.js";
import { resolveOutboundSendDep, type OutboundSendDeps } from "./send-deps.js";
import type { OutboundSessionContext } from "./session-context.js";
import type { OutboundChannel } from "./targets.js";
@@ -84,6 +83,7 @@ type ChannelHandler = {
chunkerMode?: "text" | "markdown";
textChunkLimit?: number;
supportsMedia: boolean;
sanitizeText?: (payload: ReplyPayload) => string;
normalizePayload?: (payload: ReplyPayload) => ReplyPayload | null;
shouldSkipPlainTextSanitization?: (payload: ReplyPayload) => boolean;
resolveEffectiveTextChunkLimit?: (fallbackLimit?: number) => number | undefined;
@@ -192,6 +192,9 @@ function createPluginHandler(
chunkerMode,
textChunkLimit: outbound.textChunkLimit,
supportsMedia: Boolean(sendMedia),
sanitizeText: outbound.sanitizeText
? (payload) => outbound.sanitizeText!({ text: payload.text ?? "", payload })
: undefined,
normalizePayload: outbound.normalizePayload
? (payload) => outbound.normalizePayload!({ payload })
: undefined,
@@ -335,20 +338,16 @@ function normalizeEmptyPayloadForDelivery(payload: ReplyPayload): ReplyPayload |
function normalizePayloadsForChannelDelivery(
payloads: ReplyPayload[],
channel: Exclude<OutboundChannel, "none">,
handler: ChannelHandler,
): ReplyPayload[] {
const normalizedPayloads: ReplyPayload[] = [];
for (const payload of normalizeReplyPayloadsForDelivery(payloads)) {
let sanitizedPayload = payload;
// Strip HTML tags for plain-text surfaces (WhatsApp, Signal, etc.)
// Models occasionally produce <br>, <b>, etc. that render as literal text.
// See https://github.com/openclaw/openclaw/issues/31884
if (isPlainTextSurface(channel) && sanitizedPayload.text) {
if (handler.sanitizeText && sanitizedPayload.text) {
if (!handler.shouldSkipPlainTextSanitization?.(sanitizedPayload)) {
sanitizedPayload = {
...sanitizedPayload,
text: sanitizeForPlainText(sanitizedPayload.text),
text: handler.sanitizeText(sanitizedPayload),
};
}
}
@@ -650,7 +649,7 @@ async function deliverOutboundPayloadsCore(
results.push(await handler.sendText(chunk, overrides));
}
};
const normalizedPayloads = normalizePayloadsForChannelDelivery(payloads, channel, handler);
const normalizedPayloads = normalizePayloadsForChannelDelivery(payloads, handler);
const hookRunner = getGlobalHookRunner();
const sessionKeyForInternalHooks = params.mirror?.sessionKey ?? params.session?.key;
const mirrorIsGroup = params.mirror?.isGroup;

View File

@@ -49,7 +49,7 @@ const PERMANENT_ERROR_PATTERNS: readonly RegExp[] = [
/chat_id is empty/i,
/recipient is not a valid/i,
/outbound not configured for channel/i,
/ambiguous discord recipient/i,
/ambiguous .* recipient/i,
/User .* not in room/i,
];

View File

@@ -369,7 +369,7 @@ async function handleBroadcastAction(
}
return {
kind: "broadcast",
channel: targetChannels[0] ?? "discord",
channel: targetChannels[0] ?? channelHint?.trim().toLowerCase() ?? "unknown",
action: "broadcast",
handledBy: input.dryRun ? "dry-run" : "core",
payload: { results },

View File

@@ -1,27 +1,5 @@
import { describe, expect, it } from "vitest";
import { isPlainTextSurface, sanitizeForPlainText } from "./sanitize-text.js";
// ---------------------------------------------------------------------------
// isPlainTextSurface
// ---------------------------------------------------------------------------
describe("isPlainTextSurface", () => {
it.each(["whatsapp", "signal", "sms", "irc", "telegram", "imessage", "googlechat"])(
"returns true for %s",
(channel) => {
expect(isPlainTextSurface(channel)).toBe(true);
},
);
it.each(["discord", "slack", "web", "matrix"])("returns false for %s", (channel) => {
expect(isPlainTextSurface(channel)).toBe(false);
});
it("is case-insensitive", () => {
expect(isPlainTextSurface("WhatsApp")).toBe(true);
expect(isPlainTextSurface("SIGNAL")).toBe(true);
});
});
import { sanitizeForPlainText } from "./sanitize-text.js";
// ---------------------------------------------------------------------------
// sanitizeForPlainText

View File

@@ -11,22 +11,6 @@
* @see https://github.com/openclaw/openclaw/issues/18558
*/
/** Channels where HTML tags should be converted/stripped. */
const PLAIN_TEXT_SURFACES = new Set([
"whatsapp",
"signal",
"sms",
"irc",
"telegram",
"imessage",
"googlechat",
]);
/** Returns `true` when the channel cannot render raw HTML. */
export function isPlainTextSurface(channelId: string): boolean {
return PLAIN_TEXT_SURFACES.has(channelId.toLowerCase());
}
/**
* Convert common HTML tags to their plain-text/lightweight-markup equivalents
* and strip anything that remains.

View File

@@ -1,41 +1,42 @@
type LegacyOutboundSendDeps = {
sendWhatsApp?: unknown;
sendTelegram?: unknown;
sendDiscord?: unknown;
sendSlack?: unknown;
sendSignal?: unknown;
sendIMessage?: unknown;
sendMatrix?: unknown;
sendMSTeams?: unknown;
};
/**
* Dynamic bag of per-channel send functions, keyed by channel ID.
* Each outbound adapter resolves its own function from this record and
* falls back to a direct import when the key is absent.
*/
export type OutboundSendDeps = LegacyOutboundSendDeps & { [channelId: string]: unknown };
export type OutboundSendDeps = { [channelId: string]: unknown };
const LEGACY_SEND_DEP_KEYS = {
whatsapp: "sendWhatsApp",
telegram: "sendTelegram",
discord: "sendDiscord",
slack: "sendSlack",
signal: "sendSignal",
imessage: "sendIMessage",
matrix: "sendMatrix",
msteams: "sendMSTeams",
} as const satisfies Record<string, keyof LegacyOutboundSendDeps>;
function resolveLegacyDepKeysForChannel(channelId: string): string[] {
const compact = channelId.replace(/[^a-z0-9]+/gi, "");
if (!compact) {
return [];
}
const pascal = compact.charAt(0).toUpperCase() + compact.slice(1);
const keys = new Set<string>([`send${pascal}`]);
if (compact === "whatsapp") {
keys.add("sendWhatsApp");
}
if (pascal.startsWith("I") && pascal.length > 1) {
keys.add(`sendI${pascal.slice(1)}`);
}
if (pascal.startsWith("Ms") && pascal.length > 2) {
keys.add(`sendMS${pascal.slice(2)}`);
}
return [...keys];
}
export function resolveOutboundSendDep<T>(
deps: OutboundSendDeps | null | undefined,
channelId: keyof typeof LEGACY_SEND_DEP_KEYS,
channelId: string,
): T | undefined {
const dynamic = deps?.[channelId];
if (dynamic !== undefined) {
return dynamic as T;
}
const legacyKey = LEGACY_SEND_DEP_KEYS[channelId];
const legacy = deps?.[legacyKey];
return legacy as T | undefined;
for (const legacyKey of resolveLegacyDepKeysForChannel(channelId)) {
const legacy = deps?.[legacyKey];
if (legacy !== undefined) {
return legacy as T;
}
}
return undefined;
}

View File

@@ -14,9 +14,16 @@ export type {
ChannelMessageActionName,
ChannelMessageToolDiscovery,
ChannelMessageToolSchemaContribution,
ChannelStructuredComponents,
ChannelStatusIssue,
ChannelThreadingContext,
ChannelThreadingToolContext,
} from "../channels/plugins/types.js";
export type { ChannelDirectoryAdapter } from "../channels/plugins/types.adapters.js";
export type {
ChannelDirectoryAdapter,
ChannelDoctorAdapter,
ChannelDoctorConfigMutation,
ChannelDoctorEmptyAllowlistAccountContext,
ChannelDoctorSequenceResult,
} from "../channels/plugins/types.adapters.js";

View File

@@ -71,7 +71,6 @@ export {
resolveSkillCommandInvocation,
} from "../auto-reply/skill-commands.js";
export type { SkillCommandSpec } from "../agents/skills.js";
export { buildCommandsPaginationKeyboard } from "../auto-reply/reply/commands-info.js";
export {
buildModelsProviderData,
formatModelsAvailableHeader,

View File

@@ -41,6 +41,8 @@ export {
resolveTelegramCustomCommands,
} from "../config/telegram-custom-commands.js";
export {
formatSlackStreamingBooleanMigrationMessage,
formatSlackStreamModeMigrationMessage,
mapStreamingModeToSlackLegacyDraftStreamMode,
resolveDiscordPreviewStreamMode,
resolveSlackNativeStreaming,

View File

@@ -42,8 +42,6 @@ export {
resolveThreadBindingThreadName,
} from "../channels/thread-bindings-messages.js";
export {
DISCORD_THREAD_BINDING_CHANNEL,
MATRIX_THREAD_BINDING_CHANNEL,
formatThreadBindingDisabledError,
resolveThreadBindingEffectiveExpiresAt,
resolveThreadBindingIdleTimeoutMs,

View File

@@ -34,7 +34,6 @@ export type {
OpenClawPluginService,
OpenClawPluginServiceContext,
PluginCommandContext,
PluginInteractiveTelegramHandlerContext,
PluginLogger,
ProviderAuthContext,
ProviderAuthDoctorHintContext,
@@ -247,6 +246,8 @@ type CreateChannelPluginBaseOptions<TResolvedAccount> = {
meta?: Partial<NonNullable<ChannelPlugin<TResolvedAccount>["meta"]>>;
setupWizard?: NonNullable<ChannelPlugin<TResolvedAccount>["setupWizard"]>;
capabilities?: ChannelPlugin<TResolvedAccount>["capabilities"];
commands?: ChannelPlugin<TResolvedAccount>["commands"];
doctor?: ChannelPlugin<TResolvedAccount>["doctor"];
agentPrompt?: ChannelPlugin<TResolvedAccount>["agentPrompt"];
streaming?: ChannelPlugin<TResolvedAccount>["streaming"];
reload?: ChannelPlugin<TResolvedAccount>["reload"];
@@ -267,6 +268,8 @@ type CreatedChannelPluginBase<TResolvedAccount> = Pick<
ChannelPlugin<TResolvedAccount>,
| "setupWizard"
| "capabilities"
| "commands"
| "doctor"
| "agentPrompt"
| "streaming"
| "reload"
@@ -357,6 +360,7 @@ type ChatChannelSecurityOptions<TResolvedAccount extends { accountId?: string |
normalizeEntry?: (raw: string) => string;
};
collectWarnings?: ChannelSecurityAdapter<TResolvedAccount>["collectWarnings"];
collectAuditFindings?: ChannelSecurityAdapter<TResolvedAccount>["collectAuditFindings"];
};
type ChatChannelPairingOptions = {
@@ -464,6 +468,9 @@ function resolveChatChannelSecurity<TResolvedAccount extends { accountId?: strin
normalizeEntry: security.dm.normalizeEntry,
}),
...(security.collectWarnings ? { collectWarnings: security.collectWarnings } : {}),
...(security.collectAuditFindings
? { collectAuditFindings: security.collectAuditFindings }
: {}),
};
}
@@ -559,6 +566,8 @@ export function createChannelPluginBase<TResolvedAccount>(
},
...(params.setupWizard ? { setupWizard: params.setupWizard } : {}),
...(params.capabilities ? { capabilities: params.capabilities } : {}),
...(params.commands ? { commands: params.commands } : {}),
...(params.doctor ? { doctor: params.doctor } : {}),
...(params.agentPrompt ? { agentPrompt: params.agentPrompt } : {}),
...(params.streaming ? { streaming: params.streaming } : {}),
...(params.reload ? { reload: params.reload } : {}),

View File

@@ -33,6 +33,7 @@ export * from "../infra/net/proxy-fetch.js";
export * from "../infra/net/undici-global-dispatcher.js";
export * from "../infra/net/ssrf.js";
export * from "../infra/outbound/identity.js";
export * from "../infra/outbound/sanitize-text.js";
export * from "../infra/parse-finite-number.js";
export * from "../infra/outbound/send-deps.js";
export * from "../infra/retry.js";

View File

@@ -3,5 +3,17 @@
export { ensureConfiguredAcpBindingReady } from "../acp/persistent-bindings.lifecycle.js";
export { resolveConfiguredAcpBindingRecord } from "../acp/persistent-bindings.resolve.js";
export { maybeCreateMatrixMigrationSnapshot } from "../infra/matrix-migration-snapshot.js";
export {
autoPrepareLegacyMatrixCrypto,
detectLegacyMatrixCrypto,
} from "../infra/matrix-legacy-crypto.js";
export {
autoMigrateLegacyMatrixState,
detectLegacyMatrixState,
} from "../infra/matrix-legacy-state.js";
export {
hasActionableMatrixMigration,
hasPendingMatrixMigration,
maybeCreateMatrixMigrationSnapshot,
} from "../infra/matrix-migration-snapshot.js";
export { dispatchReplyFromConfigWithSettledDispatcher } from "./inbound-reply-dispatch.js";

View File

@@ -1,3 +1,4 @@
export { createRuntimeOutboundDelegates } from "../channels/plugins/runtime-forwarders.js";
export { resolveOutboundSendDep, type OutboundSendDeps } from "../infra/outbound/send-deps.js";
export { resolveAgentOutboundIdentity, type OutboundIdentity } from "../infra/outbound/identity.js";
export { sanitizeForPlainText } from "../infra/outbound/sanitize-text.js";

View File

@@ -11,7 +11,6 @@ import type {
OpenClawPluginServiceContext,
OpenClawPluginToolContext,
OpenClawPluginToolFactory,
PluginInteractiveTelegramHandlerContext,
PluginLogger,
ProviderAugmentModelCatalogContext,
ProviderAuthContext,
@@ -117,7 +116,6 @@ export type {
OpenClawPluginCommandDefinition,
OpenClawPluginDefinition,
PluginLogger,
PluginInteractiveTelegramHandlerContext,
};
export type { OpenClawConfig };

View File

@@ -4,6 +4,7 @@ export * from "../plugins/commands.js";
export * from "../plugins/hook-runner-global.js";
export * from "../plugins/http-path.js";
export * from "../plugins/http-registry.js";
export * from "../plugins/interactive-binding-helpers.js";
export * from "../plugins/interactive.js";
export * from "../plugins/lazy-service-module.js";
export * from "../plugins/types.js";

View File

@@ -52,8 +52,5 @@ export type {
export { createReplyReferencePlanner } from "../auto-reply/reply/reply-reference.js";
export type { GetReplyOptions, ReplyPayload } from "../auto-reply/types.js";
export type { FinalizedMsgContext, MsgContext } from "../auto-reply/templating.js";
export {
resolveAutoTopicLabelConfig,
generateTopicLabel,
} from "../auto-reply/reply/auto-topic-label.js";
export type { AutoTopicLabelParams } from "../auto-reply/reply/auto-topic-label.js";
export { generateConversationLabel } from "../auto-reply/reply/conversation-label-generator.js";
export type { ConversationLabelParams } from "../auto-reply/reply/conversation-label-generator.js";

View File

@@ -2,6 +2,8 @@ import { format } from "node:util";
import type { OutputRuntimeEnv, RuntimeEnv } from "../runtime.js";
export type { OutputRuntimeEnv, RuntimeEnv } from "../runtime.js";
export { createNonExitingRuntime, defaultRuntime } from "../runtime.js";
export { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gateway.js";
export { getChannelsCommandSecretTargetIds } from "../cli/command-secret-targets.js";
export {
danger,
info,
@@ -17,7 +19,18 @@ export {
} from "../globals.js";
export * from "../logging.js";
export { waitForAbortSignal } from "../infra/abort-signal.js";
export {
detectPluginInstallPathIssue,
formatPluginInstallPathIssue,
} from "../infra/plugin-install-path-warnings.js";
export { collectProviderDangerousNameMatchingScopes } from "../config/dangerous-name-matching.js";
export { registerUnhandledRejectionHandler } from "../infra/unhandled-rejections.js";
export { removePluginFromConfig } from "../plugins/uninstall.js";
export {
isDiscordMutableAllowEntry,
isSlackMutableAllowEntry,
isZalouserMutableGroupEntry,
} from "../security/mutable-allowlist-detectors.js";
/** Minimal logger contract accepted by runtime-adapter helpers. */
type LoggerLike = {

View File

@@ -1,7 +1,4 @@
import fs from "node:fs";
import path from "node:path";
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
import { listBundledPluginManifestSnapshots } from "./bundled-manifest-snapshots.js";
import { listBundledPluginMetadata } from "./bundled-plugin-metadata.js";
export type BundledPluginContractSnapshot = {
pluginId: string;
@@ -15,27 +12,6 @@ export type BundledPluginContractSnapshot = {
toolNames: string[];
};
function resolveBundledManifestSnapshotDir(): string | undefined {
const packageRoot = resolveOpenClawPackageRootSync({ moduleUrl: import.meta.url });
if (!packageRoot) {
return undefined;
}
for (const candidate of [
path.join(packageRoot, "extensions"),
path.join(packageRoot, "dist", "extensions"),
path.join(packageRoot, "dist-runtime", "extensions"),
]) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
return undefined;
}
const BUNDLED_PLUGIN_MANIFEST_SNAPSHOTS = listBundledPluginManifestSnapshots({
bundledDir: resolveBundledManifestSnapshotDir(),
});
function uniqueStrings(values: readonly string[] | undefined): string[] {
const result: string[] = [];
const seen = new Set<string>();
@@ -50,8 +26,13 @@ function uniqueStrings(values: readonly string[] | undefined): string[] {
return result;
}
const BUNDLED_PLUGIN_METADATA_FOR_CAPABILITIES = listBundledPluginMetadata({
includeChannelConfigs: false,
includeSyntheticChannelConfigs: false,
});
export const BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS: readonly BundledPluginContractSnapshot[] =
BUNDLED_PLUGIN_MANIFEST_SNAPSHOTS.map(({ manifest }) => ({
BUNDLED_PLUGIN_METADATA_FOR_CAPABILITIES.map(({ manifest }) => ({
pluginId: manifest.id,
cliBackendIds: uniqueStrings(manifest.cliBackends),
providerIds: uniqueStrings(manifest.providers),
@@ -130,7 +111,7 @@ export const BUNDLED_PROVIDER_PLUGIN_ID_ALIASES = Object.fromEntries(
) as Readonly<Record<string, string>>;
export const BUNDLED_LEGACY_PLUGIN_ID_ALIASES = Object.fromEntries(
BUNDLED_PLUGIN_MANIFEST_SNAPSHOTS.flatMap(({ manifest }) =>
BUNDLED_PLUGIN_METADATA_FOR_CAPABILITIES.flatMap(({ manifest }) =>
(manifest.legacyPluginIds ?? []).map(
(legacyPluginId) => [legacyPluginId, manifest.id] as const,
),
@@ -138,7 +119,7 @@ export const BUNDLED_LEGACY_PLUGIN_ID_ALIASES = Object.fromEntries(
) as Readonly<Record<string, string>>;
export const BUNDLED_AUTO_ENABLE_PROVIDER_PLUGIN_IDS = Object.fromEntries(
BUNDLED_PLUGIN_MANIFEST_SNAPSHOTS.flatMap(({ manifest }) =>
BUNDLED_PLUGIN_METADATA_FOR_CAPABILITIES.flatMap(({ manifest }) =>
(manifest.autoEnableWhenConfiguredProviders ?? []).map((providerId) => [
providerId,
manifest.id,

View File

@@ -432,8 +432,6 @@ describe("plugin-sdk subpath exports", () => {
"resolveThreadBindingThreadName",
"resolveThreadBindingsEnabled",
"formatThreadBindingDisabledError",
"DISCORD_THREAD_BINDING_CHANNEL",
"MATRIX_THREAD_BINDING_CHANNEL",
"resolveControlCommandGate",
"resolveCommandAuthorizedFromAuthorizers",
"resolveDualTextControlCommandGate",
@@ -621,8 +619,6 @@ describe("plugin-sdk subpath exports", () => {
]);
expectSourceMentions("conversation-runtime", [
"DISCORD_THREAD_BINDING_CHANNEL",
"MATRIX_THREAD_BINDING_CHANNEL",
"formatThreadBindingDisabledError",
"resolveThreadBindingFarewellText",
"resolveThreadBindingConversationIdFromBindingId",

View File

@@ -1,39 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createDiscordTypingLease,
type CreateDiscordTypingLeaseParams,
} from "./runtime-discord-typing.js";
describe("createDiscordTypingLease", () => {
afterEach(() => {
vi.useRealTimers();
});
it("uses the Discord default interval and forwards pulse params", async () => {
vi.useFakeTimers();
const pulse: CreateDiscordTypingLeaseParams["pulse"] = vi.fn(async () => undefined);
const cfg = { channels: { discord: { token: "x" } } };
const lease = await createDiscordTypingLease({
channelId: "123",
accountId: "work",
cfg,
intervalMs: Number.NaN,
pulse,
});
expect(pulse).toHaveBeenCalledTimes(1);
expect(pulse).toHaveBeenCalledWith({
channelId: "123",
accountId: "work",
cfg,
});
await vi.advanceTimersByTimeAsync(7_999);
expect(pulse).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
expect(pulse).toHaveBeenCalledTimes(2);
lease.stop();
});
});

View File

@@ -1,32 +0,0 @@
import { createTypingLease } from "./typing-lease.js";
export type CreateDiscordTypingLeaseParams = {
channelId: string;
accountId?: string;
cfg?: ReturnType<typeof import("../../config/config.js").loadConfig>;
intervalMs?: number;
pulse: (params: {
channelId: string;
accountId?: string;
cfg?: ReturnType<typeof import("../../config/config.js").loadConfig>;
}) => Promise<void>;
};
const DEFAULT_DISCORD_TYPING_INTERVAL_MS = 8_000;
export async function createDiscordTypingLease(params: CreateDiscordTypingLeaseParams): Promise<{
refresh: () => Promise<void>;
stop: () => void;
}> {
return await createTypingLease({
defaultIntervalMs: DEFAULT_DISCORD_TYPING_INTERVAL_MS,
errorLabel: "discord",
intervalMs: params.intervalMs,
pulse: params.pulse,
pulseArgs: {
channelId: params.channelId,
accountId: params.accountId,
cfg: params.cfg,
},
});
}

View File

@@ -53,6 +53,46 @@ export function resolvePluginRuntimeRecord(
};
}
export function resolvePluginRuntimeRecordByEntryBaseNames(
entryBaseNames: string[],
onMissing?: () => never,
): PluginRuntimeRecord | null {
const manifestRegistry = loadPluginManifestRegistry({
config: readPluginBoundaryConfigSafely(),
cache: true,
});
const matches = manifestRegistry.plugins.filter((plugin) => {
if (!plugin?.source) {
return false;
}
const record = {
rootDir: plugin.rootDir,
source: plugin.source,
};
return entryBaseNames.every(
(entryBaseName) => resolvePluginRuntimeModulePath(record, entryBaseName) !== null,
);
});
if (matches.length === 0) {
if (onMissing) {
onMissing();
}
return null;
}
if (matches.length > 1) {
const pluginIds = matches.map((plugin) => plugin.id).join(", ");
throw new Error(
`plugin runtime boundary is ambiguous for entries [${entryBaseNames.join(", ")}]: ${pluginIds}`,
);
}
const record = matches[0];
return {
...(record.origin ? { origin: record.origin } : {}),
rootDir: record.rootDir,
source: record.source,
};
}
export function resolvePluginRuntimeModulePath(
record: Pick<PluginRuntimeRecord, "rootDir" | "source">,
entryBaseName: string,

View File

@@ -1,26 +1,108 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { createJiti } from "jiti";
type WebChannelHeavyRuntimeModule = typeof import("@openclaw/whatsapp/runtime-api.js");
type WebChannelLightRuntimeModule = typeof import("@openclaw/whatsapp/light-runtime-api.js");
import type { ChannelAgentTool } from "../../channels/plugins/types.core.js";
import type { OpenClawConfig } from "../../config/config.js";
import {
getDefaultLocalRoots as getDefaultLocalRootsImpl,
loadWebMedia as loadWebMediaImpl,
loadWebMediaRaw as loadWebMediaRawImpl,
optimizeImageToJpeg as optimizeImageToJpegImpl,
} from "../../media/web-media.js";
import type { PollInput } from "../../polls.js";
import {
loadPluginBoundaryModuleWithJiti,
resolvePluginRuntimeRecordByEntryBaseNames,
resolvePluginRuntimeModulePath,
resolvePluginRuntimeRecord,
} from "./runtime-plugin-boundary.js";
const WEB_CHANNEL_PLUGIN_ID = "whatsapp";
type WebChannelPluginRecord = {
origin: string;
origin?: string;
rootDir?: string;
source: string;
};
type WebChannelLightRuntimeModule = {
getActiveWebListener: (accountId?: string | null) => unknown;
getWebAuthAgeMs: (authDir?: string) => number | null;
logWebSelfId: (authDir?: string, runtime?: unknown, includeChannelPrefix?: boolean) => void;
logoutWeb: (params: {
authDir?: string;
isLegacyAuthDir?: boolean;
runtime?: unknown;
}) => Promise<boolean>;
readWebSelfId: (authDir?: string) => {
e164: string | null;
jid: string | null;
lid: string | null;
};
webAuthExists: (authDir?: string) => Promise<boolean>;
createWhatsAppLoginTool: () => ChannelAgentTool;
formatError: (error: unknown) => string;
getStatusCode: (error: unknown) => number | undefined;
pickWebChannel: (pref: string, authDir?: string) => Promise<string>;
WA_WEB_AUTH_DIR: string;
};
type WebChannelHeavyRuntimeModule = {
loginWeb: (
verbose: boolean,
waitForConnection?: (sock: unknown) => Promise<void>,
runtime?: unknown,
accountId?: string,
) => Promise<void>;
sendMessageWhatsApp: (
to: string,
body: string,
options: {
verbose: boolean;
cfg?: OpenClawConfig;
mediaUrl?: string;
mediaAccess?: {
localRoots?: readonly string[];
readFile?: (filePath: string) => Promise<Buffer>;
};
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
gifPlayback?: boolean;
accountId?: string;
},
) => Promise<{ messageId: string; toJid: string }>;
sendPollWhatsApp: (
to: string,
poll: PollInput,
options: { verbose: boolean; accountId?: string; cfg?: OpenClawConfig },
) => Promise<{ messageId: string; toJid: string }>;
sendReactionWhatsApp: (
chatJid: string,
messageId: string,
emoji: string,
options: {
verbose: boolean;
fromMe?: boolean;
participant?: string;
accountId?: string;
},
) => Promise<void>;
createWaSocket: (
printQr: boolean,
verbose: boolean,
opts?: { authDir?: string; onQr?: (qr: string) => void },
) => Promise<unknown>;
handleWhatsAppAction: (
params: Record<string, unknown>,
cfg: OpenClawConfig,
) => Promise<AgentToolResult<unknown>>;
monitorWebChannel: (...args: unknown[]) => Promise<unknown>;
monitorWebInbox: (...args: unknown[]) => Promise<unknown>;
runWebHeartbeatOnce: (...args: unknown[]) => Promise<unknown>;
startWebLoginWithQr: (...args: unknown[]) => Promise<unknown>;
waitForWaConnection: (sock: unknown) => Promise<void>;
waitForWebLogin: (...args: unknown[]) => Promise<unknown>;
extractMediaPlaceholder: (...args: unknown[]) => unknown;
extractText: (...args: unknown[]) => unknown;
resolveHeartbeatRecipients: (...args: unknown[]) => unknown;
};
let cachedHeavyModulePath: string | null = null;
let cachedHeavyModule: WebChannelHeavyRuntimeModule | null = null;
let cachedLightModulePath: string | null = null;
@@ -29,9 +111,9 @@ let cachedLightModule: WebChannelLightRuntimeModule | null = null;
const jitiLoaders = new Map<boolean, ReturnType<typeof createJiti>>();
function resolveWebChannelPluginRecord(): WebChannelPluginRecord {
return resolvePluginRuntimeRecord(WEB_CHANNEL_PLUGIN_ID, () => {
return resolvePluginRuntimeRecordByEntryBaseNames(["light-runtime-api", "runtime-api"], () => {
throw new Error(
`web channel plugin runtime is unavailable: missing plugin '${WEB_CHANNEL_PLUGIN_ID}'`,
"web channel plugin runtime is unavailable: missing plugin that provides light-runtime-api and runtime-api",
);
}) as WebChannelPluginRecord;
}
@@ -41,14 +123,10 @@ function resolveWebChannelRuntimeModulePath(
entryBaseName: "light-runtime-api" | "runtime-api",
): string {
const modulePath = resolvePluginRuntimeModulePath(record, entryBaseName, () => {
throw new Error(
`web channel plugin runtime is unavailable: missing ${entryBaseName} for plugin '${WEB_CHANNEL_PLUGIN_ID}'`,
);
throw new Error(`web channel plugin runtime is unavailable: missing ${entryBaseName}`);
});
if (!modulePath) {
throw new Error(
`web channel plugin runtime is unavailable: missing ${entryBaseName} for plugin '${WEB_CHANNEL_PLUGIN_ID}'`,
);
throw new Error(`web channel plugin runtime is unavailable: missing ${entryBaseName}`);
}
return modulePath;
}

View File

@@ -7,97 +7,12 @@ import type { listChannelPlugins } from "../channels/plugins/index.js";
import type { ChannelId } from "../channels/plugins/types.js";
import { inspectReadOnlyChannelAccount } from "../channels/read-only-account-inspect.js";
import { formatCliCommand } from "../cli/command-format.js";
import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../config/commands.js";
import type { OpenClawConfig } from "../config/config.js";
import { isDangerousNameMatchingEnabled } from "../config/dangerous-name-matching.js";
import { formatErrorMessage } from "../infra/errors.js";
import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js";
import { normalizeStringEntries } from "../shared/string-normalization.js";
import type { SecurityAuditFinding, SecurityAuditSeverity } from "./audit.js";
import { resolveDmAllowState } from "./dm-policy-shared.js";
const loadAuditChannelDiscordRuntimeModule = createLazyRuntimeSurface(
() => import("./audit-channel.discord.runtime.js"),
({ auditChannelDiscordRuntime }) => auditChannelDiscordRuntime,
);
const loadAuditChannelAllowFromRuntimeModule = createLazyRuntimeSurface(
() => import("./audit-channel.allow-from.runtime.js"),
({ auditChannelAllowFromRuntime }) => auditChannelAllowFromRuntime,
);
const loadAuditChannelTelegramRuntimeModule = createLazyRuntimeSurface(
() => import("./audit-channel.telegram.runtime.js"),
({ auditChannelTelegramRuntime }) => auditChannelTelegramRuntime,
);
const loadAuditChannelZalouserRuntimeModule = createLazyRuntimeSurface(
() => import("./audit-channel.zalouser.runtime.js"),
({ auditChannelZalouserRuntime }) => auditChannelZalouserRuntime,
);
function normalizeAllowFromList(list: Array<string | number> | undefined | null): string[] {
return normalizeStringEntries(Array.isArray(list) ? list : undefined);
}
function addDiscordNameBasedEntries(params: {
target: Set<string>;
values: unknown;
source: string;
isDiscordMutableAllowEntry: (value: string) => boolean;
}): void {
if (!Array.isArray(params.values)) {
return;
}
for (const value of params.values) {
if (!params.isDiscordMutableAllowEntry(String(value))) {
continue;
}
const text = String(value).trim();
if (!text) {
continue;
}
params.target.add(`${params.source}:${text}`);
}
}
function addZalouserMutableGroupEntries(params: {
target: Set<string>;
groups: unknown;
source: string;
isZalouserMutableGroupEntry: (value: string) => boolean;
}): void {
if (!params.groups || typeof params.groups !== "object" || Array.isArray(params.groups)) {
return;
}
for (const key of Object.keys(params.groups as Record<string, unknown>)) {
if (!params.isZalouserMutableGroupEntry(key)) {
continue;
}
params.target.add(`${params.source}:${key}`);
}
}
async function collectInvalidTelegramAllowFromEntries(params: {
entries: unknown;
target: Set<string>;
}): Promise<void> {
if (!Array.isArray(params.entries)) {
return;
}
const { isNumericTelegramUserId, normalizeTelegramAllowFromEntry } =
await loadAuditChannelTelegramRuntimeModule();
for (const entry of params.entries) {
const normalized = normalizeTelegramAllowFromEntry(entry);
if (!normalized || normalized === "*") {
continue;
}
if (!isNumericTelegramUserId(normalized)) {
params.target.add(normalized);
}
}
}
function classifyChannelWarningSeverity(message: string): SecurityAuditSeverity {
const s = message.toLowerCase();
if (
@@ -277,19 +192,6 @@ export async function collectChannelSecurityFindings(params: {
return { account, enabled, configured, diagnostics };
};
const coerceNativeSetting = (value: unknown): boolean | "auto" | undefined => {
if (value === true) {
return true;
}
if (value === false) {
return false;
}
if (value === "auto") {
return "auto";
}
return undefined;
};
const warnDmPolicy = async (input: {
label: string;
provider: ChannelId;
@@ -411,318 +313,6 @@ export async function collectChannelSecurityFindings(params: {
});
}
if (
plugin.id === "synology-chat" &&
(account as { dangerouslyAllowNameMatching?: unknown } | null)
?.dangerouslyAllowNameMatching === true
) {
const accountNote = formatChannelAccountNote({
orderedAccountIds,
hasExplicitAccountPath,
accountId,
});
findings.push({
checkId: "channels.synology-chat.reply.dangerous_name_matching_enabled",
severity: "info",
title: `Synology Chat dangerous name matching is enabled${accountNote}`,
detail:
"dangerouslyAllowNameMatching=true re-enables mutable username/nickname matching for reply delivery. This is a break-glass compatibility mode, not a hardened default.",
remediation:
"Prefer stable numeric Synology Chat user IDs for reply delivery, then disable dangerouslyAllowNameMatching.",
});
}
if (plugin.id === "discord") {
const { isDiscordMutableAllowEntry } = await loadAuditChannelDiscordRuntimeModule();
const { readChannelAllowFromStore } = await loadAuditChannelAllowFromRuntimeModule();
const discordCfg =
(account as { config?: Record<string, unknown> } | null)?.config ??
({} as Record<string, unknown>);
const dangerousNameMatchingEnabled = isDangerousNameMatchingEnabled(discordCfg);
const storeAllowFrom = await readChannelAllowFromStore(
"discord",
process.env,
accountId,
).catch(() => []);
const discordNameBasedAllowEntries = new Set<string>();
const discordPathPrefix =
orderedAccountIds.length > 1 || hasExplicitAccountPath
? `channels.discord.accounts.${accountId}`
: "channels.discord";
addDiscordNameBasedEntries({
target: discordNameBasedAllowEntries,
values: discordCfg.allowFrom,
source: `${discordPathPrefix}.allowFrom`,
isDiscordMutableAllowEntry,
});
addDiscordNameBasedEntries({
target: discordNameBasedAllowEntries,
values: (discordCfg.dm as { allowFrom?: unknown } | undefined)?.allowFrom,
source: `${discordPathPrefix}.dm.allowFrom`,
isDiscordMutableAllowEntry,
});
addDiscordNameBasedEntries({
target: discordNameBasedAllowEntries,
values: storeAllowFrom,
source: "~/.openclaw/credentials/discord-allowFrom.json",
isDiscordMutableAllowEntry,
});
const discordGuildEntries =
(discordCfg.guilds as Record<string, unknown> | undefined) ?? {};
for (const [guildKey, guildValue] of Object.entries(discordGuildEntries)) {
if (!guildValue || typeof guildValue !== "object") {
continue;
}
const guild = guildValue as Record<string, unknown>;
addDiscordNameBasedEntries({
target: discordNameBasedAllowEntries,
values: guild.users,
source: `${discordPathPrefix}.guilds.${guildKey}.users`,
isDiscordMutableAllowEntry,
});
const channels = guild.channels;
if (!channels || typeof channels !== "object") {
continue;
}
for (const [channelKey, channelValue] of Object.entries(
channels as Record<string, unknown>,
)) {
if (!channelValue || typeof channelValue !== "object") {
continue;
}
const channel = channelValue as Record<string, unknown>;
addDiscordNameBasedEntries({
target: discordNameBasedAllowEntries,
values: channel.users,
source: `${discordPathPrefix}.guilds.${guildKey}.channels.${channelKey}.users`,
isDiscordMutableAllowEntry,
});
}
}
if (discordNameBasedAllowEntries.size > 0) {
const examples = Array.from(discordNameBasedAllowEntries).slice(0, 5);
const more =
discordNameBasedAllowEntries.size > examples.length
? ` (+${discordNameBasedAllowEntries.size - examples.length} more)`
: "";
findings.push({
checkId: "channels.discord.allowFrom.name_based_entries",
severity: dangerousNameMatchingEnabled ? "info" : "warn",
title: dangerousNameMatchingEnabled
? "Discord allowlist uses break-glass name/tag matching"
: "Discord allowlist contains name or tag entries",
detail: dangerousNameMatchingEnabled
? "Discord name/tag allowlist matching is explicitly enabled via dangerouslyAllowNameMatching. This mutable-identity mode is operator-selected break-glass behavior and out-of-scope for vulnerability reports by itself. " +
`Found: ${examples.join(", ")}${more}.`
: "Discord name/tag allowlist matching uses normalized slugs and can collide across users. " +
`Found: ${examples.join(", ")}${more}.`,
remediation: dangerousNameMatchingEnabled
? "Prefer stable Discord IDs (or <@id>/user:<id>/pk:<id>), then disable dangerouslyAllowNameMatching."
: "Prefer stable Discord IDs (or <@id>/user:<id>/pk:<id>) in channels.discord.allowFrom and channels.discord.guilds.*.users, or explicitly opt in with dangerouslyAllowNameMatching=true if you accept the risk.",
});
}
const nativeEnabled = resolveNativeCommandsEnabled({
providerId: "discord",
providerSetting: coerceNativeSetting(
(discordCfg.commands as { native?: unknown } | undefined)?.native,
),
globalSetting: params.cfg.commands?.native,
});
const nativeSkillsEnabled = resolveNativeSkillsEnabled({
providerId: "discord",
providerSetting: coerceNativeSetting(
(discordCfg.commands as { nativeSkills?: unknown } | undefined)?.nativeSkills,
),
globalSetting: params.cfg.commands?.nativeSkills,
});
const slashEnabled = nativeEnabled || nativeSkillsEnabled;
if (slashEnabled) {
const defaultGroupPolicy = params.cfg.channels?.defaults?.groupPolicy;
const groupPolicy =
(discordCfg.groupPolicy as string | undefined) ?? defaultGroupPolicy ?? "allowlist";
const guildEntries = discordGuildEntries;
const guildsConfigured = Object.keys(guildEntries).length > 0;
const hasAnyUserAllowlist = Object.values(guildEntries).some((guild) => {
if (!guild || typeof guild !== "object") {
return false;
}
const g = guild as Record<string, unknown>;
if (Array.isArray(g.users) && g.users.length > 0) {
return true;
}
const channels = g.channels;
if (!channels || typeof channels !== "object") {
return false;
}
return Object.values(channels as Record<string, unknown>).some((channel) => {
if (!channel || typeof channel !== "object") {
return false;
}
const c = channel as Record<string, unknown>;
return Array.isArray(c.users) && c.users.length > 0;
});
});
const dmAllowFromRaw = (discordCfg.dm as { allowFrom?: unknown } | undefined)?.allowFrom;
const dmAllowFrom = Array.isArray(dmAllowFromRaw) ? dmAllowFromRaw : [];
const ownerAllowFromConfigured =
normalizeAllowFromList([...dmAllowFrom, ...storeAllowFrom]).length > 0;
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
if (
!useAccessGroups &&
groupPolicy !== "disabled" &&
guildsConfigured &&
!hasAnyUserAllowlist
) {
findings.push({
checkId: "channels.discord.commands.native.unrestricted",
severity: "critical",
title: "Discord slash commands are unrestricted",
detail:
"commands.useAccessGroups=false disables sender allowlists for Discord slash commands unless a per-guild/channel users allowlist is configured; with no users allowlist, any user in allowed guild channels can invoke /… commands.",
remediation:
"Set commands.useAccessGroups=true (recommended), or configure channels.discord.guilds.<id>.users (or channels.discord.guilds.<id>.channels.<channel>.users).",
});
} else if (
useAccessGroups &&
groupPolicy !== "disabled" &&
guildsConfigured &&
!ownerAllowFromConfigured &&
!hasAnyUserAllowlist
) {
findings.push({
checkId: "channels.discord.commands.native.no_allowlists",
severity: "warn",
title: "Discord slash commands have no allowlists",
detail:
"Discord slash commands are enabled, but neither an owner allowFrom list nor any per-guild/channel users allowlist is configured; /… commands will be rejected for everyone.",
remediation:
"Add your user id to channels.discord.allowFrom (or approve yourself via pairing), or configure channels.discord.guilds.<id>.users.",
});
}
}
}
if (plugin.id === "zalouser") {
const { isZalouserMutableGroupEntry } = await loadAuditChannelZalouserRuntimeModule();
const zalouserCfg =
(account as { config?: Record<string, unknown> } | null)?.config ??
({} as Record<string, unknown>);
const dangerousNameMatchingEnabled = isDangerousNameMatchingEnabled(zalouserCfg);
const zalouserPathPrefix =
orderedAccountIds.length > 1 || hasExplicitAccountPath
? `channels.zalouser.accounts.${accountId}`
: "channels.zalouser";
const mutableGroupEntries = new Set<string>();
addZalouserMutableGroupEntries({
target: mutableGroupEntries,
groups: zalouserCfg.groups,
source: `${zalouserPathPrefix}.groups`,
isZalouserMutableGroupEntry,
});
if (mutableGroupEntries.size > 0) {
const examples = Array.from(mutableGroupEntries).slice(0, 5);
const more =
mutableGroupEntries.size > examples.length
? ` (+${mutableGroupEntries.size - examples.length} more)`
: "";
findings.push({
checkId: "channels.zalouser.groups.mutable_entries",
severity: dangerousNameMatchingEnabled ? "info" : "warn",
title: dangerousNameMatchingEnabled
? "Zalouser group routing uses break-glass name matching"
: "Zalouser group routing contains mutable group entries",
detail: dangerousNameMatchingEnabled
? "Zalouser group-name routing is explicitly enabled via dangerouslyAllowNameMatching. This mutable-identity mode is operator-selected break-glass behavior and out-of-scope for vulnerability reports by itself. " +
`Found: ${examples.join(", ")}${more}.`
: "Zalouser group auth is ID-only by default, so unresolved group-name or slug entries are ignored for auth and can drift from the intended trusted group. " +
`Found: ${examples.join(", ")}${more}.`,
remediation: dangerousNameMatchingEnabled
? "Prefer stable Zalo group IDs (for example group:<id> or provider-native g- ids), then disable dangerouslyAllowNameMatching."
: "Prefer stable Zalo group IDs in channels.zalouser.groups, or explicitly opt in with dangerouslyAllowNameMatching=true if you accept mutable group-name matching.",
});
}
}
if (plugin.id === "slack") {
const { readChannelAllowFromStore } = await loadAuditChannelAllowFromRuntimeModule();
const slackCfg =
(account as { config?: Record<string, unknown>; dm?: Record<string, unknown> } | null)
?.config ?? ({} as Record<string, unknown>);
const nativeEnabled = resolveNativeCommandsEnabled({
providerId: "slack",
providerSetting: coerceNativeSetting(
(slackCfg.commands as { native?: unknown } | undefined)?.native,
),
globalSetting: params.cfg.commands?.native,
});
const nativeSkillsEnabled = resolveNativeSkillsEnabled({
providerId: "slack",
providerSetting: coerceNativeSetting(
(slackCfg.commands as { nativeSkills?: unknown } | undefined)?.nativeSkills,
),
globalSetting: params.cfg.commands?.nativeSkills,
});
const slashCommandEnabled =
nativeEnabled ||
nativeSkillsEnabled ||
(slackCfg.slashCommand as { enabled?: unknown } | undefined)?.enabled === true;
if (slashCommandEnabled) {
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
if (!useAccessGroups) {
findings.push({
checkId: "channels.slack.commands.slash.useAccessGroups_off",
severity: "critical",
title: "Slack slash commands bypass access groups",
detail:
"Slack slash/native commands are enabled while commands.useAccessGroups=false; this can allow unrestricted /… command execution from channels/users you didn't explicitly authorize.",
remediation: "Set commands.useAccessGroups=true (recommended).",
});
} else {
const allowFromRaw = (
account as
| { config?: { allowFrom?: unknown }; dm?: { allowFrom?: unknown } }
| null
| undefined
)?.config?.allowFrom;
const legacyAllowFromRaw = (
account as { dm?: { allowFrom?: unknown } } | null | undefined
)?.dm?.allowFrom;
const allowFrom = Array.isArray(allowFromRaw)
? allowFromRaw
: Array.isArray(legacyAllowFromRaw)
? legacyAllowFromRaw
: [];
const storeAllowFrom = await readChannelAllowFromStore(
"slack",
process.env,
accountId,
).catch(() => []);
const ownerAllowFromConfigured =
normalizeAllowFromList([...allowFrom, ...storeAllowFrom]).length > 0;
const channels = (slackCfg.channels as Record<string, unknown> | undefined) ?? {};
const hasAnyChannelUsersAllowlist = Object.values(channels).some((value) => {
if (!value || typeof value !== "object") {
return false;
}
const channel = value as Record<string, unknown>;
return Array.isArray(channel.users) && channel.users.length > 0;
});
if (!ownerAllowFromConfigured && !hasAnyChannelUsersAllowlist) {
findings.push({
checkId: "channels.slack.commands.slash.no_allowlists",
severity: "warn",
title: "Slack slash commands have no allowlists",
detail:
"Slack slash/native commands are enabled, but neither an owner allowFrom list nor any channels.<id>.users allowlist is configured; /… commands will be rejected for everyone.",
remediation:
"Approve yourself via pairing (recommended), or set channels.slack.allowFrom and/or channels.slack.channels.<id>.users.",
});
}
}
}
}
const dmPolicy = plugin.security.resolveDmPolicy?.({
cfg: params.cfg,
accountId,
@@ -760,145 +350,19 @@ export async function collectChannelSecurityFindings(params: {
});
}
}
if (plugin.id !== "telegram") {
continue;
}
const allowTextCommands = params.cfg.commands?.text !== false;
if (!allowTextCommands) {
continue;
}
const telegramCfg =
(account as { config?: Record<string, unknown> } | null)?.config ??
({} as Record<string, unknown>);
const defaultGroupPolicy = params.cfg.channels?.defaults?.groupPolicy;
const groupPolicy =
(telegramCfg.groupPolicy as string | undefined) ?? defaultGroupPolicy ?? "allowlist";
const groups = telegramCfg.groups as Record<string, unknown> | undefined;
const groupsConfigured = Boolean(groups) && Object.keys(groups ?? {}).length > 0;
const groupAccessPossible =
groupPolicy === "open" || (groupPolicy === "allowlist" && groupsConfigured);
if (!groupAccessPossible) {
continue;
}
const { readChannelAllowFromStore } = await loadAuditChannelAllowFromRuntimeModule();
const storeAllowFrom = await readChannelAllowFromStore(
"telegram",
process.env,
accountId,
).catch(() => []);
const storeHasWildcard = storeAllowFrom.some((value) => String(value).trim() === "*");
const invalidTelegramAllowFromEntries = new Set<string>();
await collectInvalidTelegramAllowFromEntries({
entries: storeAllowFrom,
target: invalidTelegramAllowFromEntries,
});
const groupAllowFrom = Array.isArray(telegramCfg.groupAllowFrom)
? telegramCfg.groupAllowFrom
: [];
const groupAllowFromHasWildcard = groupAllowFrom.some((v) => String(v).trim() === "*");
await collectInvalidTelegramAllowFromEntries({
entries: groupAllowFrom,
target: invalidTelegramAllowFromEntries,
});
const dmAllowFrom = Array.isArray(telegramCfg.allowFrom) ? telegramCfg.allowFrom : [];
await collectInvalidTelegramAllowFromEntries({
entries: dmAllowFrom,
target: invalidTelegramAllowFromEntries,
});
let anyGroupOverride = false;
if (groups) {
for (const value of Object.values(groups)) {
if (!value || typeof value !== "object") {
continue;
}
const group = value as Record<string, unknown>;
const allowFrom = Array.isArray(group.allowFrom) ? group.allowFrom : [];
if (allowFrom.length > 0) {
anyGroupOverride = true;
await collectInvalidTelegramAllowFromEntries({
entries: allowFrom,
target: invalidTelegramAllowFromEntries,
});
}
const topics = group.topics;
if (!topics || typeof topics !== "object") {
continue;
}
for (const topicValue of Object.values(topics as Record<string, unknown>)) {
if (!topicValue || typeof topicValue !== "object") {
continue;
}
const topic = topicValue as Record<string, unknown>;
const topicAllow = Array.isArray(topic.allowFrom) ? topic.allowFrom : [];
if (topicAllow.length > 0) {
anyGroupOverride = true;
}
await collectInvalidTelegramAllowFromEntries({
entries: topicAllow,
target: invalidTelegramAllowFromEntries,
});
}
if (plugin.security.collectAuditFindings) {
const auditFindings = await plugin.security.collectAuditFindings({
cfg: params.cfg,
sourceConfig,
accountId,
account,
orderedAccountIds,
hasExplicitAccountPath,
});
for (const finding of auditFindings ?? []) {
findings.push(finding);
}
}
const hasAnySenderAllowlist =
storeAllowFrom.length > 0 || groupAllowFrom.length > 0 || anyGroupOverride;
if (invalidTelegramAllowFromEntries.size > 0) {
const examples = Array.from(invalidTelegramAllowFromEntries).slice(0, 5);
const more =
invalidTelegramAllowFromEntries.size > examples.length
? ` (+${invalidTelegramAllowFromEntries.size - examples.length} more)`
: "";
findings.push({
checkId: "channels.telegram.allowFrom.invalid_entries",
severity: "warn",
title: "Telegram allowlist contains non-numeric entries",
detail:
"Telegram sender authorization requires numeric Telegram user IDs. " +
`Found non-numeric allowFrom entries: ${examples.join(", ")}${more}.`,
remediation:
"Replace @username entries with numeric Telegram user IDs (use setup to resolve), then re-run the audit.",
});
}
if (storeHasWildcard || groupAllowFromHasWildcard) {
findings.push({
checkId: "channels.telegram.groups.allowFrom.wildcard",
severity: "critical",
title: "Telegram group allowlist contains wildcard",
detail:
'Telegram group sender allowlist contains "*", which allows any group member to run /… commands and control directives.',
remediation:
'Remove "*" from channels.telegram.groupAllowFrom and pairing store; prefer explicit numeric Telegram user IDs.',
});
continue;
}
if (!hasAnySenderAllowlist) {
const providerSetting = (telegramCfg.commands as { nativeSkills?: unknown } | undefined)
// oxlint-disable-next-line typescript/no-explicit-any
?.nativeSkills as any;
const skillsEnabled = resolveNativeSkillsEnabled({
providerId: "telegram",
providerSetting,
globalSetting: params.cfg.commands?.nativeSkills,
});
findings.push({
checkId: "channels.telegram.groups.allowFrom.missing",
severity: "critical",
title: "Telegram group commands have no sender allowlist",
detail:
`Telegram group access is enabled but no sender allowlist is configured; this allows any group member to invoke /… commands` +
(skillsEnabled ? " (including skill commands)." : "."),
remediation:
"Approve yourself via pairing (recommended), or set channels.telegram.groupAllowFrom (or per-group groups.<id>.allowFrom).",
});
}
}
}

View File

@@ -15,13 +15,14 @@ import { resolveSkillSource } from "../agents/skills/source.js";
import { isToolAllowedByPolicies } from "../agents/tool-policy-match.js";
import { resolveToolProfilePolicy } from "../agents/tool-policy.js";
import { listAgentWorkspaceDirs } from "../agents/workspace-dirs.js";
import { listChannelPlugins } from "../channels/plugins/index.js";
import { inspectReadOnlyChannelAccount } from "../channels/read-only-account-inspect.js";
import { formatCliCommand } from "../cli/command-format.js";
import { MANIFEST_KEY } from "../compat/legacy-names.js";
import { resolveNativeSkillsEnabled } from "../config/commands.js";
import type { OpenClawConfig, ConfigFileSnapshot } from "../config/config.js";
import { collectIncludePathsRecursive } from "../config/includes-scan.js";
import { resolveOAuthDir } from "../config/paths.js";
import { hasConfiguredSecretInput } from "../config/types.secrets.js";
import type { AgentToolsConfig } from "../config/types.tools.js";
import { normalizePluginsConfig } from "../plugins/config-state.js";
import { normalizeAgentId } from "../routing/session-key.js";
@@ -118,6 +119,85 @@ function formatCodeSafetyDetails(findings: SkillScanFinding[], rootDir: string):
.join("\n");
}
function readChannelCommandSetting(
cfg: OpenClawConfig,
channelId: string,
key: "native" | "nativeSkills",
): unknown {
const channelCfg = cfg.channels?.[channelId as keyof NonNullable<OpenClawConfig["channels"]>];
if (!channelCfg || typeof channelCfg !== "object" || Array.isArray(channelCfg)) {
return undefined;
}
const commands = (channelCfg as { commands?: unknown }).commands;
if (!commands || typeof commands !== "object" || Array.isArray(commands)) {
return undefined;
}
return (commands as Record<string, unknown>)[key];
}
async function isChannelPluginConfigured(
cfg: OpenClawConfig,
plugin: ReturnType<typeof listChannelPlugins>[number],
): Promise<boolean> {
const accountIds = plugin.config.listAccountIds(cfg);
const candidates = accountIds.length > 0 ? accountIds : [undefined];
for (const accountId of candidates) {
const inspected =
plugin.config.inspectAccount?.(cfg, accountId) ??
(await inspectReadOnlyChannelAccount({
channelId: plugin.id,
cfg,
accountId,
}));
const inspectedRecord =
inspected && typeof inspected === "object" && !Array.isArray(inspected)
? (inspected as Record<string, unknown>)
: null;
let resolvedAccount: unknown = inspected;
if (!resolvedAccount) {
try {
resolvedAccount = plugin.config.resolveAccount(cfg, accountId);
} catch {
resolvedAccount = null;
}
}
let enabled =
typeof inspectedRecord?.enabled === "boolean"
? inspectedRecord.enabled
: resolvedAccount != null;
if (
typeof inspectedRecord?.enabled !== "boolean" &&
resolvedAccount != null &&
plugin.config.isEnabled
) {
try {
enabled = plugin.config.isEnabled(resolvedAccount, cfg);
} catch {
enabled = false;
}
}
let configured =
typeof inspectedRecord?.configured === "boolean"
? inspectedRecord.configured
: resolvedAccount != null;
if (
typeof inspectedRecord?.configured !== "boolean" &&
resolvedAccount != null &&
plugin.config.isConfigured
) {
try {
configured = await plugin.config.isConfigured(resolvedAccount, cfg);
} catch {
configured = false;
}
}
if (enabled && configured) {
return true;
}
}
return false;
}
async function listInstalledPluginDirs(params: {
stateDir: string;
onReadError?: (error: unknown) => void;
@@ -544,75 +624,29 @@ export async function collectPluginsTrustFindings(params: {
const allow = params.cfg.plugins?.allow;
const allowConfigured = Array.isArray(allow) && allow.length > 0;
if (!allowConfigured) {
const hasString = (value: unknown) => typeof value === "string" && value.trim().length > 0;
const hasSecretInput = (value: unknown) =>
hasConfiguredSecretInput(value, params.cfg.secrets?.defaults);
const hasAccountStringKey = (account: unknown, key: string) =>
Boolean(
account &&
typeof account === "object" &&
hasString((account as Record<string, unknown>)[key]),
);
const hasAccountSecretInputKey = (account: unknown, key: string) =>
Boolean(
account &&
typeof account === "object" &&
hasSecretInput((account as Record<string, unknown>)[key]),
);
const discordConfigured =
hasSecretInput(params.cfg.channels?.discord?.token) ||
Boolean(
params.cfg.channels?.discord?.accounts &&
Object.values(params.cfg.channels.discord.accounts).some((a) =>
hasAccountSecretInputKey(a, "token"),
),
) ||
hasString(process.env.DISCORD_BOT_TOKEN);
const telegramConfigured =
hasSecretInput(params.cfg.channels?.telegram?.botToken) ||
hasString(params.cfg.channels?.telegram?.tokenFile) ||
Boolean(
params.cfg.channels?.telegram?.accounts &&
Object.values(params.cfg.channels.telegram.accounts).some(
(a) => hasAccountSecretInputKey(a, "botToken") || hasAccountStringKey(a, "tokenFile"),
),
) ||
hasString(process.env.TELEGRAM_BOT_TOKEN);
const slackConfigured =
hasSecretInput(params.cfg.channels?.slack?.botToken) ||
hasSecretInput(params.cfg.channels?.slack?.appToken) ||
Boolean(
params.cfg.channels?.slack?.accounts &&
Object.values(params.cfg.channels.slack.accounts).some(
(a) =>
hasAccountSecretInputKey(a, "botToken") || hasAccountSecretInputKey(a, "appToken"),
),
) ||
hasString(process.env.SLACK_BOT_TOKEN) ||
hasString(process.env.SLACK_APP_TOKEN);
const skillCommandsLikelyExposed =
(discordConfigured &&
resolveNativeSkillsEnabled({
providerId: "discord",
providerSetting: params.cfg.channels?.discord?.commands?.nativeSkills,
globalSetting: params.cfg.commands?.nativeSkills,
})) ||
(telegramConfigured &&
resolveNativeSkillsEnabled({
providerId: "telegram",
providerSetting: params.cfg.channels?.telegram?.commands?.nativeSkills,
globalSetting: params.cfg.commands?.nativeSkills,
})) ||
(slackConfigured &&
resolveNativeSkillsEnabled({
providerId: "slack",
providerSetting: params.cfg.channels?.slack?.commands?.nativeSkills,
globalSetting: params.cfg.commands?.nativeSkills,
}));
const skillCommandsLikelyExposed = (
await Promise.all(
listChannelPlugins().map(async (plugin) => {
if (
plugin.capabilities.nativeCommands !== true &&
plugin.commands?.nativeSkillsAutoEnabled !== true
) {
return false;
}
if (!(await isChannelPluginConfigured(params.cfg, plugin))) {
return false;
}
return resolveNativeSkillsEnabled({
providerId: plugin.id,
providerSetting: readChannelCommandSetting(params.cfg, plugin.id, "nativeSkills") as
| "auto"
| boolean
| undefined,
globalSetting: params.cfg.commands?.nativeSkills,
});
}),
)
).some(Boolean);
findings.push({
checkId: "plugins.extensions_no_allowlist",

View File

@@ -2,9 +2,16 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { collectDiscordSecurityAuditFindings } from "../../extensions/discord/api.js";
import { collectSlackSecurityAuditFindings } from "../../extensions/slack/api.js";
import { collectSynologyChatSecurityAuditFindings } from "../../extensions/synology-chat/api.js";
import { collectTelegramSecurityAuditFindings } from "../../extensions/telegram/api.js";
import { collectZalouserSecurityAuditFindings } from "../../extensions/zalouser/api.js";
import type { ChannelPlugin } from "../channels/plugins/types.js";
import type { OpenClawConfig } from "../config/config.js";
import { saveExecApprovals } from "../infra/exec-approvals.js";
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js";
import { createPathResolutionEnv, withEnvAsync } from "../test-utils/env.js";
import type { SecurityAuditOptions, SecurityAuditReport } from "./audit.js";
import { runSecurityAudit } from "./audit.js";
@@ -39,7 +46,47 @@ function stubChannelPlugin(params: {
listAccountIds?: (cfg: OpenClawConfig) => string[];
isConfigured?: (account: unknown, cfg: OpenClawConfig) => boolean;
isEnabled?: (account: unknown, cfg: OpenClawConfig) => boolean;
collectAuditFindings?: NonNullable<ChannelPlugin["security"]>["collectAuditFindings"];
commands?: ChannelPlugin["commands"];
}): ChannelPlugin {
const channelConfigured = (cfg: OpenClawConfig) =>
Boolean((cfg.channels as Record<string, unknown> | undefined)?.[params.id]);
const defaultCollectAuditFindings =
params.collectAuditFindings ??
(params.id === "discord"
? (collectDiscordSecurityAuditFindings as NonNullable<
ChannelPlugin["security"]
>["collectAuditFindings"])
: params.id === "slack"
? (collectSlackSecurityAuditFindings as NonNullable<
ChannelPlugin["security"]
>["collectAuditFindings"])
: params.id === "synology-chat"
? (collectSynologyChatSecurityAuditFindings as NonNullable<
ChannelPlugin["security"]
>["collectAuditFindings"])
: params.id === "telegram"
? (collectTelegramSecurityAuditFindings as NonNullable<
ChannelPlugin["security"]
>["collectAuditFindings"])
: params.id === "zalouser"
? (collectZalouserSecurityAuditFindings as NonNullable<
ChannelPlugin["security"]
>["collectAuditFindings"])
: undefined);
const defaultCommands =
params.commands ??
(params.id === "discord" || params.id === "telegram"
? {
nativeCommandsAutoEnabled: true,
nativeSkillsAutoEnabled: true,
}
: params.id === "slack"
? {
nativeCommandsAutoEnabled: false,
nativeSkillsAutoEnabled: false,
}
: undefined);
return {
id: params.id,
meta: {
@@ -52,7 +99,12 @@ function stubChannelPlugin(params: {
capabilities: {
chatTypes: ["direct", "group"],
},
security: {},
...(defaultCommands ? { commands: defaultCommands } : {}),
security: defaultCollectAuditFindings
? {
collectAuditFindings: defaultCollectAuditFindings,
}
: {},
config: {
listAccountIds:
params.listAccountIds ??
@@ -78,14 +130,14 @@ function stubChannelPlugin(params: {
const config = account?.config ?? {};
return {
accountId: resolvedAccountId,
enabled: params.isEnabled?.(account, cfg) ?? true,
configured: params.isConfigured?.(account, cfg) ?? true,
enabled: params.isEnabled?.(account, cfg) ?? channelConfigured(cfg),
configured: params.isConfigured?.(account, cfg) ?? channelConfigured(cfg),
config,
};
}),
resolveAccount: (cfg, accountId) => params.resolveAccount(cfg, accountId),
isEnabled: (account, cfg) => params.isEnabled?.(account, cfg) ?? true,
isConfigured: (account, cfg) => params.isConfigured?.(account, cfg) ?? true,
isEnabled: (account, cfg) => params.isEnabled?.(account, cfg) ?? channelConfigured(cfg),
isConfigured: (account, cfg) => params.isConfigured?.(account, cfg) ?? channelConfigured(cfg),
},
};
}
@@ -180,6 +232,14 @@ const synologyChatPlugin = stubChannelPlugin({
},
});
const BASE_AUDIT_CHANNEL_PLUGINS = [
discordPlugin,
slackPlugin,
telegramPlugin,
zalouserPlugin,
synologyChatPlugin,
] satisfies ChannelPlugin[];
function successfulProbeResult(url: string) {
return {
ok: true,
@@ -202,12 +262,14 @@ async function audit(
saveExecApprovals({ version: 1, agents: {} });
}
const { preserveExecApprovals: _preserveExecApprovals, ...options } = extra ?? {};
return runSecurityAudit({
config: cfg,
includeFilesystem: false,
includeChannelSecurity: false,
...options,
});
return withActiveAuditChannelPlugins(options.plugins ?? BASE_AUDIT_CHANNEL_PLUGINS, () =>
runSecurityAudit({
config: cfg,
includeFilesystem: false,
includeChannelSecurity: false,
...options,
}),
);
}
async function runAuditCases<T>(
@@ -299,12 +361,33 @@ async function runChannelSecurityAudit(
cfg: OpenClawConfig,
plugins: ChannelPlugin[],
): Promise<SecurityAuditReport> {
return runSecurityAudit({
config: cfg,
includeFilesystem: false,
includeChannelSecurity: true,
plugins,
});
return withActiveAuditChannelPlugins(plugins, () =>
runSecurityAudit({
config: cfg,
includeFilesystem: false,
includeChannelSecurity: true,
plugins,
}),
);
}
async function withActiveAuditChannelPlugins<T>(
plugins: ChannelPlugin[],
run: () => Promise<T>,
): Promise<T> {
const previousRegistry = getActivePluginRegistry();
const registry = createEmptyPluginRegistry();
registry.channels = plugins.map((plugin) => ({
pluginId: plugin.id,
plugin,
source: "test",
}));
setActivePluginRegistry(registry);
try {
return await run();
} finally {
setActivePluginRegistry(previousRegistry ?? createEmptyPluginRegistry());
}
}
async function runInstallMetadataAudit(
@@ -2191,12 +2274,14 @@ describe("security audit", () => {
},
];
const res = await runSecurityAudit({
config: cfg,
includeFilesystem: false,
includeChannelSecurity: true,
plugins,
});
const res = await withActiveAuditChannelPlugins(plugins, () =>
runSecurityAudit({
config: cfg,
includeFilesystem: false,
includeChannelSecurity: true,
plugins,
}),
);
expect(res.findings).toEqual(
expect.arrayContaining([
@@ -2255,12 +2340,14 @@ describe("security audit", () => {
] as const;
await runChannelSecurityStateCases(cases, async (testCase) => {
const res = await runSecurityAudit({
config: testCase.cfg,
includeFilesystem: false,
includeChannelSecurity: true,
plugins: [discordPlugin],
});
const res = await withActiveAuditChannelPlugins([discordPlugin], () =>
runSecurityAudit({
config: testCase.cfg,
includeFilesystem: false,
includeChannelSecurity: true,
plugins: [discordPlugin],
}),
);
expect(
res.findings.some(
@@ -2463,13 +2550,16 @@ describe("security audit", () => {
] as const;
await runChannelSecurityStateCases(cases, async (testCase) => {
const res = await runSecurityAudit({
config: testCase.resolvedConfig,
sourceConfig: testCase.sourceConfig,
includeFilesystem: false,
includeChannelSecurity: true,
plugins: [testCase.plugin(testCase.sourceConfig)],
});
const plugins = [testCase.plugin(testCase.sourceConfig)];
const res = await withActiveAuditChannelPlugins(plugins, () =>
runSecurityAudit({
config: testCase.resolvedConfig,
sourceConfig: testCase.sourceConfig,
includeFilesystem: false,
includeChannelSecurity: true,
plugins,
}),
);
expect(res.findings, testCase.name).toEqual(
expect.arrayContaining([
@@ -2500,12 +2590,14 @@ describe("security audit", () => {
},
};
const res = await runSecurityAudit({
config: cfg,
includeFilesystem: false,
includeChannelSecurity: true,
plugins: [plugin],
});
const res = await withActiveAuditChannelPlugins([plugin], () =>
runSecurityAudit({
config: cfg,
includeFilesystem: false,
includeChannelSecurity: true,
plugins: [plugin],
}),
);
const finding = res.findings.find(
(entry) => entry.checkId === "channels.zalouser.account.read_only_resolution",
@@ -2765,12 +2857,14 @@ describe("security audit", () => {
},
};
const res = await runSecurityAudit({
config: cfg,
includeFilesystem: false,
includeChannelSecurity: true,
plugins: [pluginWithProtoDefaultAccount],
});
const res = await withActiveAuditChannelPlugins([pluginWithProtoDefaultAccount], () =>
runSecurityAudit({
config: cfg,
includeFilesystem: false,
includeChannelSecurity: true,
plugins: [pluginWithProtoDefaultAccount],
}),
);
const dangerousMatchingFinding = res.findings.find(
(entry) => entry.checkId === "channels.discord.allowFrom.dangerous_name_matching_enabled",

View File

@@ -120,6 +120,9 @@ let auditDeepModulePromise: Promise<typeof import("./audit.deep.runtime.js")> |
let auditChannelModulePromise:
| Promise<typeof import("./audit-channel.collect.runtime.js")>
| undefined;
let pluginRegistryLoaderModulePromise:
| Promise<typeof import("../plugins/runtime/runtime-registry-loader.js")>
| undefined;
let gatewayProbeDepsPromise:
| Promise<{
buildGatewayConnectionDetails: typeof import("../gateway/call.js").buildGatewayConnectionDetails;
@@ -148,6 +151,11 @@ async function loadAuditChannelModule() {
return await auditChannelModulePromise;
}
async function loadPluginRegistryLoaderModule() {
pluginRegistryLoaderModulePromise ??= import("../plugins/runtime/runtime-registry-loader.js");
return await pluginRegistryLoaderModulePromise;
}
async function loadGatewayProbeDeps() {
gatewayProbeDepsPromise ??= Promise.all([
import("../gateway/call.js"),
@@ -1455,6 +1463,14 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<Secu
context.includeChannelSecurity &&
(context.plugins !== undefined || hasPotentialConfiguredChannels(cfg, env));
if (shouldAuditChannelSecurity) {
if (context.plugins === undefined) {
(await loadPluginRegistryLoaderModule()).ensurePluginRegistryLoaded({
scope: "configured-channels",
config: cfg,
activationSourceConfig: context.sourceConfig,
env,
});
}
const channelPlugins = context.plugins ?? (await loadChannelPlugins()).listChannelPlugins();
const { collectChannelSecurityFindings } = await loadAuditChannelModule();
findings.push(