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

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(