diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index 1127720e1724..79070185f9bb 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -1162,6 +1162,7 @@ Important examples: | `openclaw.setupEntry` | Lightweight setup-only entrypoint used during onboarding, deferred channel startup, and read-only channel status/SecretRef discovery. Must stay inside the plugin package directory. | | `openclaw.runtimeSetupEntry` | Declares the built JavaScript setup entrypoint for installed packages. Requires `setupEntry`, must exist, and must stay inside the plugin package directory. | | `openclaw.channel` | Cheap channel catalog metadata like labels, docs paths, aliases, and selection copy. | +| `openclaw.channel.approvalFlags` | Closed approval behavior flags available before runtime load. `native` means the channel owns native approval UI and same-turn resolution. | | `openclaw.channel.commands` | Static native command and native skill auto-default metadata used by config, audit, and command-list surfaces before channel runtime loads. | | `openclaw.channel.configuredState` | Lightweight configured-state checker metadata that can answer "does env-only setup already exist?" without loading the full channel runtime. | | `openclaw.channel.persistedAuthState` | Lightweight persisted-auth checker metadata that can answer "is anything already signed in?" without loading the full channel runtime. | diff --git a/extensions/discord/package.json b/extensions/discord/package.json index 56f22d934c05..a695582d2946 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -39,6 +39,7 @@ }, "channel": { "id": "discord", + "approvalFlags": ["native"], "label": "Discord", "selectionLabel": "Discord (Bot API)", "detailLabel": "Discord Bot", diff --git a/extensions/googlechat/package.json b/extensions/googlechat/package.json index 2a3b3ed05888..8d26da79c499 100644 --- a/extensions/googlechat/package.json +++ b/extensions/googlechat/package.json @@ -30,6 +30,7 @@ "setupEntry": "./setup-entry.ts", "channel": { "id": "googlechat", + "approvalFlags": ["native"], "label": "Google Chat", "selectionLabel": "Google Chat (Chat API)", "detailLabel": "Google Chat", diff --git a/extensions/imessage/package.json b/extensions/imessage/package.json index c1848a23d691..35ab0a61bd99 100644 --- a/extensions/imessage/package.json +++ b/extensions/imessage/package.json @@ -20,6 +20,7 @@ }, "channel": { "id": "imessage", + "approvalFlags": ["native"], "label": "iMessage", "selectionLabel": "iMessage (imsg)", "detailLabel": "iMessage", diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json index 4370a479bb2d..05e3d479348f 100644 --- a/extensions/matrix/package.json +++ b/extensions/matrix/package.json @@ -39,6 +39,7 @@ }, "channel": { "id": "matrix", + "approvalFlags": ["native"], "label": "Matrix", "selectionLabel": "Matrix (plugin)", "docsPath": "/channels/matrix", diff --git a/extensions/qqbot/package.json b/extensions/qqbot/package.json index 333730c01a55..c4ae137ea500 100644 --- a/extensions/qqbot/package.json +++ b/extensions/qqbot/package.json @@ -37,6 +37,7 @@ "setupEntry": "./setup-entry.ts", "channel": { "id": "qqbot", + "approvalFlags": ["native"], "label": "QQ Bot", "selectionLabel": "QQ Bot (Official API)", "detailLabel": "QQ Bot", diff --git a/extensions/signal/package.json b/extensions/signal/package.json index b6a3b291a8ef..8ff8a2df89c1 100644 --- a/extensions/signal/package.json +++ b/extensions/signal/package.json @@ -16,6 +16,7 @@ "setupEntry": "./setup-entry.ts", "channel": { "id": "signal", + "approvalFlags": ["native"], "label": "Signal", "selectionLabel": "Signal (signal-cli)", "detailLabel": "Signal REST", diff --git a/extensions/slack/package.json b/extensions/slack/package.json index 1a13e749c098..dcd126803897 100644 --- a/extensions/slack/package.json +++ b/extensions/slack/package.json @@ -35,6 +35,7 @@ "setupEntry": "./setup-entry.ts", "channel": { "id": "slack", + "approvalFlags": ["native"], "label": "Slack", "selectionLabel": "Slack (Socket Mode)", "detailLabel": "Slack Bot", diff --git a/extensions/slack/src/monitor/slash.ts b/extensions/slack/src/monitor/slash.ts index 229a7faeb025..e4e062e320ef 100644 --- a/extensions/slack/src/monitor/slash.ts +++ b/extensions/slack/src/monitor/slash.ts @@ -687,7 +687,7 @@ export async function registerSlackMonitorSlashCommands(params: { sessionPrefix: slashCommand.sessionPrefix, userId: command.user_id, targetSessionKey: route.sessionKey, - lowercaseSessionKey: true, + sessionKeyCase: "lowercase", }); const slashReplyTarget = !slashCommand.ephemeral && isRoomish diff --git a/extensions/telegram/package.json b/extensions/telegram/package.json index c2dedd371af0..6c27262a608d 100644 --- a/extensions/telegram/package.json +++ b/extensions/telegram/package.json @@ -25,6 +25,7 @@ }, "channel": { "id": "telegram", + "approvalFlags": ["native"], "label": "Telegram", "selectionLabel": "Telegram (Bot API)", "detailLabel": "Telegram Bot", diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json index 6fe54f4d1393..86a932371025 100644 --- a/extensions/whatsapp/package.json +++ b/extensions/whatsapp/package.json @@ -35,6 +35,7 @@ }, "channel": { "id": "whatsapp", + "approvalFlags": ["native"], "label": "WhatsApp", "selectionLabel": "WhatsApp (QR link)", "detailLabel": "WhatsApp Web", diff --git a/src/agents/bash-tools.exec.approval-id.test.ts b/src/agents/bash-tools.exec.approval-id.test.ts index 09fdf652e886..6efb04bc2fe5 100644 --- a/src/agents/bash-tools.exec.approval-id.test.ts +++ b/src/agents/bash-tools.exec.approval-id.test.ts @@ -37,18 +37,6 @@ vi.mock("../infra/outbound/message.js", () => ({ vi.mock("../utils/message-channel.js", () => { const INTERNAL_MESSAGE_CHANNEL = "webchat"; - const NATIVE_APPROVAL_CHANNELS = new Set([ - "webchat", - "discord", - "googlechat", - "imessage", - "matrix", - "qqbot", - "signal", - "slack", - "telegram", - "whatsapp", - ]); const normalizeMessageChannel = (raw?: string | null) => { const normalized = raw?.trim().toLowerCase(); if (!normalized) { @@ -63,7 +51,7 @@ vi.mock("../utils/message-channel.js", () => { return { INTERNAL_MESSAGE_CHANNEL, isNativeApprovalChannel: (value?: string | null) => - typeof value === "string" && NATIVE_APPROVAL_CHANNELS.has(value), + value === INTERNAL_MESSAGE_CHANNEL || value === "discord", isDeliverableMessageChannel: (value: string) => { const channel = normalizeMessageChannel(value); return Boolean(channel && channel !== INTERNAL_MESSAGE_CHANNEL && channel !== "tui"); diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index 7ecde6421b06..eedc3e4874ed 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -1275,11 +1275,12 @@ describe("buildAgentSystemPrompt", () => { expect(prompt).toContain("Plain /approve only when"); }); - it("suppresses plain chat approval commands for native approval channels", () => { + it("suppresses plain chat approval commands for native approval runtimes", () => { const prompt = buildAgentSystemPrompt({ workspaceDir: "/tmp/openclaw", runtimeInfo: { - channel: "slack", + channel: "whatsapp", + capabilities: ["nativeApprovals"], }, }); diff --git a/src/channels/bundled-channel-catalog-read.test.ts b/src/channels/bundled-channel-catalog-read.test.ts index 8aa42155adca..b8de1c3e1d18 100644 --- a/src/channels/bundled-channel-catalog-read.test.ts +++ b/src/channels/bundled-channel-catalog-read.test.ts @@ -74,7 +74,14 @@ function seedRoot(prefix: string): string { function seedChannelPkg( pkgJsonPath: string, - opts: { id: string; docsPath: string; label?: string; blurb?: string; markdownCapable?: boolean }, + opts: { + id: string; + docsPath: string; + label?: string; + blurb?: string; + markdownCapable?: boolean; + approvalFlags?: readonly ["native"]; + }, ): void { const pluginDir = path.dirname(pkgJsonPath); writeJsonFile(pkgJsonPath, { @@ -86,6 +93,7 @@ function seedChannelPkg( docsPath: opts.docsPath, blurb: opts.blurb ?? "test blurb", ...(opts.markdownCapable !== undefined ? { markdownCapable: opts.markdownCapable } : {}), + ...(opts.approvalFlags ? { approvalFlags: opts.approvalFlags } : {}), }, }, }); @@ -109,6 +117,7 @@ describe("listBundledChannelCatalogEntries", () => { id: "telegram", docsPath: "/channels/telegram", label: "Telegram", + approvalFlags: ["native"], }); seedChannelPkg(path.join(extensionsRoot, "imessage", "package.json"), { id: "imessage", @@ -124,6 +133,7 @@ describe("listBundledChannelCatalogEntries", () => { const telegram = entries.find((entry) => entry.id === "telegram"); expect(telegram?.channel.docsPath).toBe("/channels/telegram"); expect(telegram?.channel.label).toBe("Telegram"); + expect(telegram?.channel.approvalFlags).toEqual(["native"]); }); it("merges the generated official catalog with bundled package metadata", () => { diff --git a/src/channels/native-command-session-targets.test.ts b/src/channels/native-command-session-targets.test.ts index 2c8fe4f34fdc..395a1ca78d86 100644 --- a/src/channels/native-command-session-targets.test.ts +++ b/src/channels/native-command-session-targets.test.ts @@ -39,7 +39,7 @@ describe("resolveNativeCommandSessionTargets", () => { sessionPrefix: "Slack:Slash", userId: "U123", targetSessionKey: "agent:qwen:slack:channel:c1", - lowercaseSessionKey: true, + sessionKeyCase: "lowercase", }), ).toEqual({ sessionKey: "agent:qwen:slack:slash:u123", diff --git a/src/channels/native-command-session-targets.ts b/src/channels/native-command-session-targets.ts index 0057edcaab8c..942bcca74b44 100644 --- a/src/channels/native-command-session-targets.ts +++ b/src/channels/native-command-session-targets.ts @@ -4,6 +4,7 @@ * Chooses storage and command target session keys for channel-native command events. */ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; +import type { ChannelMessagingAdapter } from "./plugins/types.core.js"; /** * Inputs for resolving where a native channel command should attach session state. @@ -14,7 +15,7 @@ export type ResolveNativeCommandSessionTargetsParams = { userId: string; targetSessionKey: string; boundSessionKey?: string; - lowercaseSessionKey?: boolean; + sessionKeyCase?: NonNullable; }; /** @@ -28,9 +29,10 @@ export function resolveNativeCommandSessionTargets( return { // Some providers normalize user ids case-insensitively; keep this opt-in so existing // case-sensitive bindings are preserved for channels that need them. - sessionKey: params.lowercaseSessionKey - ? normalizeLowercaseStringOrEmpty(rawSessionKey) - : rawSessionKey, + sessionKey: + params.sessionKeyCase === "lowercase" + ? normalizeLowercaseStringOrEmpty(rawSessionKey) + : rawSessionKey, commandTargetSessionKey: params.boundSessionKey ?? params.targetSessionKey, }; } diff --git a/src/channels/plugins/native-approval-prompt.ts b/src/channels/plugins/native-approval-prompt.ts index 19d4fdd01afb..09f0adaa127e 100644 --- a/src/channels/plugins/native-approval-prompt.ts +++ b/src/channels/plugins/native-approval-prompt.ts @@ -4,6 +4,7 @@ * Detects loaded or known channels that can render approval prompts natively. */ import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; +import { listBundledChannelCatalogEntries } from "../bundled-channel-catalog-read.js"; import { resolveChannelApprovalCapability } from "./approvals.js"; import type { ChannelPlugin } from "./types.plugin.js"; @@ -11,16 +12,6 @@ export const NATIVE_APPROVAL_PROMPT_RUNTIME_CAPABILITY = "nativeApprovals"; const NATIVE_APPROVAL_PROMPT_RUNTIME_CAPABILITY_NORMALIZED = "nativeapprovals"; -const KNOWN_NATIVE_APPROVAL_PROMPT_CHANNELS = new Set([ - "discord", - "googlechat", - "matrix", - "qqbot", - "slack", - "telegram", - "signal", -]); - export function channelPluginHasNativeApprovalPromptUi( plugin?: Pick | null, ): boolean { @@ -30,7 +21,12 @@ export function channelPluginHasNativeApprovalPromptUi( export function isKnownNativeApprovalPromptChannel(channel?: string | null): boolean { const normalized = normalizeOptionalLowercaseString(channel); - return Boolean(normalized && KNOWN_NATIVE_APPROVAL_PROMPT_CHANNELS.has(normalized)); + return Boolean( + normalized && + listBundledChannelCatalogEntries().some( + (entry) => entry.id === normalized && entry.channel.approvalFlags?.includes("native"), + ), + ); } export function hasNativeApprovalPromptRuntimeCapability( diff --git a/src/plugins/contracts/session-attachments.contract.test.ts b/src/plugins/contracts/session-attachments.contract.test.ts index fa9b8dcc3e95..dfe40489d76b 100644 --- a/src/plugins/contracts/session-attachments.contract.test.ts +++ b/src/plugins/contracts/session-attachments.contract.test.ts @@ -154,7 +154,7 @@ describe("plugin session attachments", () => { delete (globalThis as { proofAttachmentLog?: unknown[] }).proofAttachmentLog; }); - it("sends validated files through the session delivery route with channel hints", async () => { + it("sends validated files through the session delivery route with portable hints", async () => { await withSessionStore(async ({ storePath, filePath }) => { await writeSessionEntry(storePath, { deliveryContext: { @@ -167,7 +167,7 @@ describe("plugin session attachments", () => { const result = await sendBundledSessionAttachment({ files: [{ path: filePath }], - channelHints: { telegram: { disableNotification: true, parseMode: "HTML" } }, + channelHints: { silent: true, parseMode: "HTML", threadId: "attachment-thread" }, }); expect(result).toEqual({ @@ -181,7 +181,7 @@ describe("plugin session attachments", () => { expect(sendParams.to).toBe("12345"); expect(sendParams.channel).toBe("telegram"); expect(sendParams.accountId).toBe("default"); - expect(sendParams.threadId).toBe(42); + expect(sendParams.threadId).toBe("attachment-thread"); expect(sendParams.mediaUrls).toEqual([filePath]); expect(sendParams.bestEffort).toBe(false); expect(sendParams.silent).toBe(true); @@ -208,7 +208,7 @@ describe("plugin session attachments", () => { }); }); - it("escapes plain Telegram attachment captions before HTML delivery", async () => { + it("keeps shipped Telegram hints compatible when escaping plain captions", async () => { await withSessionStore(async ({ storePath, filePath }) => { await writeSessionEntry(storePath); mockSuccessfulAttachmentDelivery(); @@ -226,6 +226,23 @@ describe("plugin session attachments", () => { }); }); + it("keeps shipped Slack thread hints compatible", async () => { + await withSessionStore(async ({ storePath, filePath }) => { + await writeSessionEntry(storePath, { + deliveryContext: { channel: "slack", to: "C123" }, + }); + mockSuccessfulAttachmentDelivery(); + + const result = await sendBundledSessionAttachment({ + files: [{ path: filePath }], + channelHints: { slack: { threadTs: "171234.567" } }, + }); + + expect(result).toMatchObject({ ok: true, channel: "slack", deliveredTo: "C123" }); + expect(requireFirstSendMessageParams().threadId).toBe("171234.567"); + }); + }); + it("resolves relative attachment paths against the session agent workspace", async () => { await withSessionStore(async ({ storePath, stateDir }) => { const workspaceDir = path.join(stateDir, "workspace"); @@ -345,7 +362,7 @@ describe("plugin session attachments", () => { await expect( sendBundledSessionAttachment({ files: [{ path: filePath }], - channelHints: { telegram: { forceDocumentMime: "application/pdf" } }, + channelHints: { forceDocumentMime: "application/pdf" }, }), ).resolves.toEqual({ ok: false, @@ -356,7 +373,7 @@ describe("plugin session attachments", () => { await expect( sendBundledSessionAttachment({ files: [{ path: fakePdfPath }], - channelHints: { telegram: { forceDocumentMime: "application/pdf" } }, + channelHints: { forceDocumentMime: "application/pdf" }, }), ).resolves.toEqual({ ok: false, @@ -379,7 +396,7 @@ describe("plugin session attachments", () => { const result = await sendBundledSessionAttachment({ files: [{ path: pdfPath }], forceDocument: false, - channelHints: { telegram: { forceDocumentMime: "application/pdf" } }, + channelHints: { forceDocumentMime: "application/pdf" }, }); expectTelegramAttachmentResult(result, 1); const sendParams = requireFirstSendMessageParams(); diff --git a/src/plugins/host-hook-attachments.ts b/src/plugins/host-hook-attachments.ts index 0a9759fac269..d0dc872b8a09 100644 --- a/src/plugins/host-hook-attachments.ts +++ b/src/plugins/host-hook-attachments.ts @@ -50,9 +50,9 @@ const loadGetChannelPlugin = createLazyRuntimeModule(() => type ResolvedAttachmentDelivery = { parseMode?: "HTML"; escapePlainHtmlCaption?: boolean; - disableNotification?: boolean; + silent?: boolean; forceDocumentMime?: string; - threadTs?: string; + threadId?: string | number; }; function captionFormatToParseMode( @@ -68,6 +68,13 @@ function escapeHtmlText(text: string): string { return text.replace(/&/g, "&").replace(//g, ">"); } +function normalizeOptionalThreadId(value: unknown): string | number | undefined { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + return normalizeOptionalString(value); +} + async function readMimeSniffBuffer( filePath: string, size: number, @@ -88,7 +95,7 @@ async function readMimeSniffBuffer( } } -/** Resolves channel-specific attachment delivery options from caption format and hints. */ +/** Resolves portable attachment delivery options while honoring shipped channel-specific hints. */ function resolveAttachmentDelivery(params: { channel: string; captionFormat?: PluginSessionAttachmentCaptionFormat; @@ -96,33 +103,28 @@ function resolveAttachmentDelivery(params: { }): ResolvedAttachmentDelivery { const fallbackParseMode = captionFormatToParseMode(params.captionFormat); const channel = params.channel.trim().toLowerCase(); - if (channel === "telegram") { - const hint = params.channelHints?.telegram; - const parseMode = - hint?.parseMode ?? (params.captionFormat === "plain" ? "HTML" : fallbackParseMode); - const escapePlainHtmlCaption = params.captionFormat === "plain" && parseMode === "HTML"; - const forceDocumentMime = normalizeMimeType(hint?.forceDocumentMime); - return { - ...(parseMode ? { parseMode } : {}), - ...(escapePlainHtmlCaption ? { escapePlainHtmlCaption: true } : {}), - ...(hint?.disableNotification !== undefined - ? { disableNotification: hint.disableNotification } - : {}), - ...(forceDocumentMime ? { forceDocumentMime } : {}), - }; - } - if (channel === "discord") { - return fallbackParseMode ? { parseMode: fallbackParseMode } : {}; - } - if (channel === "slack") { - const hint = params.channelHints?.slack; - const threadTs = normalizeOptionalString(hint?.threadTs); - return { - ...(fallbackParseMode ? { parseMode: fallbackParseMode } : {}), - ...(threadTs ? { threadTs } : {}), - }; - } - return fallbackParseMode ? { parseMode: fallbackParseMode } : {}; + const hints = params.channelHints; + // These nested fields shipped before attachment hints became transport-neutral. + const legacyTelegram = channel === "telegram" ? hints?.telegram : undefined; + const legacySlack = channel === "slack" ? hints?.slack : undefined; + const parseMode = + hints?.parseMode ?? + legacyTelegram?.parseMode ?? + (channel === "telegram" && params.captionFormat === "plain" ? "HTML" : fallbackParseMode); + const escapePlainHtmlCaption = params.captionFormat === "plain" && parseMode === "HTML"; + const silent = hints?.silent ?? legacyTelegram?.disableNotification; + const forceDocumentMime = normalizeMimeType( + hints?.forceDocumentMime ?? legacyTelegram?.forceDocumentMime, + ); + const threadId = + normalizeOptionalThreadId(hints?.threadId) ?? normalizeOptionalString(legacySlack?.threadTs); + return { + ...(parseMode ? { parseMode } : {}), + ...(escapePlainHtmlCaption ? { escapePlainHtmlCaption: true } : {}), + ...(silent !== undefined ? { silent } : {}), + ...(forceDocumentMime ? { forceDocumentMime } : {}), + ...(threadId !== undefined ? { threadId } : {}), + }; } async function validateAttachmentFiles( @@ -205,22 +207,15 @@ function resolveAttachmentFilePath(params: { return resolvePathFromInput(params.filePath, resolveWorkspaceRoot(workspaceDir)); } -function normalizeOptionalThreadId(value: unknown): string | number | undefined { - if (typeof value === "number" && Number.isFinite(value)) { - return value; - } - return normalizeOptionalString(value); -} - /** Resolves the thread id used when delivering a plugin session attachment. */ function resolveSessionAttachmentThreadId(params: { deliveryThreadId?: unknown; explicitThreadId?: unknown; fallbackThreadId?: unknown; - hintThreadTs?: string; + hintThreadId?: unknown; }): string | number | undefined { return ( - params.hintThreadTs ?? + normalizeOptionalThreadId(params.hintThreadId) ?? normalizeOptionalThreadId(params.explicitThreadId) ?? normalizeOptionalThreadId(params.fallbackThreadId) ?? normalizeOptionalThreadId(params.deliveryThreadId) @@ -289,7 +284,7 @@ export async function sendPluginSessionAttachment( deliveryThreadId: deliveryContext.threadId, explicitThreadId: params.threadId, fallbackThreadId: threadId, - hintThreadTs: resolvedDelivery.threadTs, + hintThreadId: resolvedDelivery.threadId, }); let result: Awaited>; try { @@ -306,9 +301,7 @@ export async function sendPluginSessionAttachment( bestEffort: false, cfg: params.config, ...(resolvedDelivery.parseMode ? { parseMode: resolvedDelivery.parseMode } : {}), - ...(resolvedDelivery.disableNotification !== undefined - ? { silent: resolvedDelivery.disableNotification } - : {}), + ...(resolvedDelivery.silent !== undefined ? { silent: resolvedDelivery.silent } : {}), }); } catch (error) { return { ok: false, error: `attachment delivery failed: ${formatErrorMessage(error)}` }; diff --git a/src/plugins/host-hooks.ts b/src/plugins/host-hooks.ts index 68fd036420f6..3d334019dfff 100644 --- a/src/plugins/host-hooks.ts +++ b/src/plugins/host-hooks.ts @@ -222,6 +222,12 @@ type PluginSessionAttachmentFile = { }; export type PluginAttachmentChannelHints = { + parseMode?: "HTML"; + silent?: boolean; + /** Require host detection to match this MIME before forcing document delivery. */ + forceDocumentMime?: string; + threadId?: string | number; + /** @deprecated Put portable attachment hints directly on `channelHints`. */ telegram?: { parseMode?: "HTML"; disableNotification?: boolean; @@ -231,6 +237,7 @@ export type PluginAttachmentChannelHints = { */ forceDocumentMime?: string; }; + /** @deprecated Use `channelHints.threadId`. */ slack?: { threadTs?: string; }; diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index 293d99339409..5c1045b2421b 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -1959,6 +1959,8 @@ export function loadPluginManifest( } // package.json "openclaw" metadata (used for setup/catalog) +type PluginPackageChannelApprovalFlag = "native"; + export type PluginPackageChannel = { id?: string; label?: string; @@ -1975,6 +1977,8 @@ export type PluginPackageChannel = { selectionDocsOmitLabel?: boolean; selectionExtras?: readonly string[]; markdownCapable?: boolean; + /** Closed manifest flags for approval behavior available before the channel runtime loads. */ + approvalFlags?: readonly PluginPackageChannelApprovalFlag[]; exposure?: { configured?: boolean; setup?: boolean; diff --git a/src/utils/message-channel-constants.ts b/src/utils/message-channel-constants.ts index 2ec7c10df4d5..49cb859bcb46 100644 --- a/src/utils/message-channel-constants.ts +++ b/src/utils/message-channel-constants.ts @@ -29,33 +29,3 @@ export function isInternalNonDeliveryChannel( ): value is (typeof INTERNAL_NON_DELIVERY_CHANNELS)[number] { return isStringOption(value, INTERNAL_NON_DELIVERY_CHANNELS); } - -// Channels that ship a native chat exec approval client (in-chat `/approve` -// flow backed by an `approval-handler.runtime` adapter). When the originating -// turn can be approved in the same chat, the gateway can resolve the approval -// in place and the agent can wait inline for the result instead of falling -// back to a fire-and-forget followup that loses the agent's session. -// -// Keep this list aligned with bundled channels whose -// `approvalCapability.nativeRuntime` handles exec approvals; webchat is -// core-owned. Listing a channel without that runtime re-introduces the -// "approval loop" the inline path was added to avoid. -const NATIVE_APPROVAL_CHANNELS = [ - "webchat", - "discord", - "googlechat", - "imessage", - "matrix", - "qqbot", - "signal", - "slack", - "telegram", - "whatsapp", -] as const; -type NativeApprovalChannel = (typeof NATIVE_APPROVAL_CHANNELS)[number]; - -export function isNativeApprovalChannel( - value: string | null | undefined, -): value is NativeApprovalChannel { - return isStringOption(value, NATIVE_APPROVAL_CHANNELS); -} diff --git a/src/utils/message-channel.test.ts b/src/utils/message-channel.test.ts index a3c4addc0503..4e279cdfa328 100644 --- a/src/utils/message-channel.test.ts +++ b/src/utils/message-channel.test.ts @@ -10,7 +10,6 @@ import { isEphemeralGatewayClient, isInternalNonDeliveryChannel, isMarkdownCapableMessageChannel, - isNativeApprovalChannel, isOperatorUiClient, resolveGatewayMessageChannel, } from "./message-channel.js"; @@ -103,28 +102,35 @@ describe("message-channel", () => { expect(isInternalNonDeliveryChannel("HEARTBEAT")).toBe(false); }); - it("lists native chat exec approval channels", () => { - for (const channel of [ - "webchat", - "discord", - "googlechat", - "imessage", - "matrix", - "qqbot", - "signal", - "slack", - "telegram", - "whatsapp", - ]) { - expect(isNativeApprovalChannel(channel)).toBe(true); + it("reads native approval behavior from bundled channel manifests", async () => { + const previousBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; + const previousTrust = process.env.OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR; + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = path.resolve("extensions"); + process.env.OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR = "1"; + vi.resetModules(); + try { + const channelModule = await import("./message-channel.js"); + const promptModule = await import("../channels/plugins/native-approval-prompt.js"); + for (const channel of ["webchat", "discord", "imessage", "telegram", "whatsapp"]) { + expect(channelModule.isNativeApprovalChannel(channel), channel).toBe(true); + } + expect(promptModule.isKnownNativeApprovalPromptChannel("whatsapp")).toBe(true); + for (const channel of ["feishu", "msteams", "line", "heartbeat", "", "TELEGRAM"]) { + expect(channelModule.isNativeApprovalChannel(channel), channel).toBe(false); + } + } finally { + if (previousBundledPluginsDir === undefined) { + delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; + } else { + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = previousBundledPluginsDir; + } + if (previousTrust === undefined) { + delete process.env.OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR; + } else { + process.env.OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR = previousTrust; + } + vi.resetModules(); } - // Channels without a bundled exec-capable native approval runtime must not claim it. - expect(isNativeApprovalChannel("feishu")).toBe(false); - expect(isNativeApprovalChannel("msteams")).toBe(false); - expect(isNativeApprovalChannel("line")).toBe(false); - expect(isNativeApprovalChannel("heartbeat")).toBe(false); - expect(isNativeApprovalChannel("")).toBe(false); - expect(isNativeApprovalChannel("TELEGRAM")).toBe(false); }); it("reads markdown capability from channel metadata", () => { diff --git a/src/utils/message-channel.ts b/src/utils/message-channel.ts index 6593858f77bd..d54f2813aa6c 100644 --- a/src/utils/message-channel.ts +++ b/src/utils/message-channel.ts @@ -23,7 +23,6 @@ export { export { INTERNAL_MESSAGE_CHANNEL, isInternalNonDeliveryChannel, - isNativeApprovalChannel, } from "./message-channel-constants.js"; import { INTERNAL_MESSAGE_CHANNEL } from "./message-channel-constants.js"; import { normalizeMessageChannel } from "./message-channel-normalize.js"; @@ -92,6 +91,19 @@ export function isInternalMessageChannel( return normalizeMessageChannel(raw) === INTERNAL_MESSAGE_CHANNEL; } +/** Return whether a channel can resolve exec approvals in the originating chat. */ +export function isNativeApprovalChannel(value?: string | null): boolean { + if (!value) { + return false; + } + if (value === INTERNAL_MESSAGE_CHANNEL) { + return true; + } + return listBundledChannelCatalogEntries().some( + (entry) => entry.id === value && entry.channel.approvalFlags?.includes("native"), + ); +} + /** Return whether a Gateway client is the public webchat surface. */ export function isWebchatClient(client?: GatewayClientInfoLike | null): boolean { const mode = normalizeGatewayClientMode(client?.mode);