From d70808433dab14e5e0342ae138dfb1f9103b635b Mon Sep 17 00:00:00 2001 From: kakahu Date: Tue, 28 Apr 2026 02:52:02 +0800 Subject: [PATCH] Add structured Matrix approval metadata (#72432) Merged via squash. Prepared head SHA: 0e06533dff4abf40e5cc4ccb8af239d29ff8976c Co-authored-by: kakahu2015 <17962485+kakahu2015@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- CHANGELOG.md | 1 + docs/channels/matrix.md | 6 + docs/tools/exec-approvals-advanced.md | 3 + .../src/approval-handler.runtime.test.ts | 246 +++++++++++++++++- .../matrix/src/approval-handler.runtime.ts | 151 ++++++++++- extensions/matrix/src/matrix/send.test.ts | 22 ++ extensions/matrix/src/matrix/send.ts | 5 +- extensions/matrix/src/matrix/send/types.ts | 2 + 8 files changed, 426 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71d91b10b11..eb7b4d9bfb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai - Gateway/runtime: reuse the current plugin metadata snapshot for provider discovery so repeated model-provider discovery avoids rebuilding plugin manifest metadata. Thanks @shakkernerd. - Gateway/startup: pass the plugin metadata snapshot from config validation into plugin bootstrap so startup reuses one manifest product instead of rebuilding plugin metadata. Thanks @shakkernerd. - ACP/runtime: add an opt-in bundled Coven backend extension that routes ACP coding sessions through a local Coven daemon when `acp.backend="coven"`, while preserving the existing ACPX backend as the default fallback path. Thanks @BunsDev. +- Matrix: attach versioned structured approval metadata to pending approval messages so capable Matrix clients can render richer approval UI while body text and reaction fallback keep working. (#72432) Thanks @kakahu2015. ### Fixes diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md index 2c0e64cdcc8..908fdbc5018 100644 --- a/docs/channels/matrix.md +++ b/docs/channels/matrix.md @@ -208,6 +208,12 @@ Notes: - Media replies always send attachments normally. If a stale preview can no longer be reused safely, OpenClaw redacts it before sending the final media reply. - Preview edits cost extra Matrix API calls. Leave `streaming: "off"` if you want the most conservative rate-limit profile. +## Approval metadata + +Matrix native approval prompts are normal `m.room.message` events with OpenClaw-specific custom event content under `com.openclaw.approval`. Matrix permits custom event-content keys, so stock clients still render the text body while OpenClaw-aware clients can read the structured approval id, kind, state, available decisions, and exec/plugin details. + +When an approval prompt is too long for one Matrix event, OpenClaw chunks the visible text and attaches `com.openclaw.approval` to the first chunk only. Reactions for allow/deny decisions are bound to that first event, so long prompts keep the same approval target as single-event prompts. + ### Self-hosted push rules for quiet finalized previews `streaming: "quiet"` only notifies recipients once a block or turn is finalized — a per-user push rule has to match the finalized preview marker. See [Matrix push rules for quiet previews](/channels/matrix-push-rules) for the full recipe (recipient token, pusher check, rule install, per-homeserver notes). diff --git a/docs/tools/exec-approvals-advanced.md b/docs/tools/exec-approvals-advanced.md index 782b7ee51ae..14ea8755ec0 100644 --- a/docs/tools/exec-approvals-advanced.md +++ b/docs/tools/exec-approvals-advanced.md @@ -301,6 +301,9 @@ Shared behavior: without a second Slack-local fallback layer - Matrix native DM/channel routing and reaction shortcuts handle both exec and plugin approvals; plugin authorization still comes from `channels.matrix.dm.allowFrom` +- Matrix native prompts include `com.openclaw.approval` custom event content on the first prompt + event so OpenClaw-aware Matrix clients can read structured approval state while stock clients + keep the plain-text `/approve` fallback - the requester does not need to be an approver - the originating chat can approve directly with `/approve` when that chat already supports commands and replies - native Discord approval buttons route by approval id kind: `plugin:` ids go diff --git a/extensions/matrix/src/approval-handler.runtime.test.ts b/extensions/matrix/src/approval-handler.runtime.test.ts index 11366d2f0a1..8ef5a537664 100644 --- a/extensions/matrix/src/approval-handler.runtime.test.ts +++ b/extensions/matrix/src/approval-handler.runtime.test.ts @@ -1,7 +1,251 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { matrixApprovalNativeRuntime } from "./approval-handler.runtime.js"; describe("matrixApprovalNativeRuntime", () => { + it("sends versioned Matrix approval content with pending exec approvals", async () => { + const sendSingleTextMessage = vi.fn().mockResolvedValue({ + messageId: "$approval", + primaryMessageId: "$approval", + messageIds: ["$approval"], + roomId: "!room:example.org", + }); + const reactMessage = vi.fn().mockResolvedValue(undefined); + const pendingPayload = await matrixApprovalNativeRuntime.presentation.buildPendingPayload({ + cfg: {} as never, + accountId: "default", + context: { client: {} as never }, + request: { + id: "req-1", + request: { + command: "echo hi", + cwd: "/repo", + host: "gateway", + agentId: "agent-1", + }, + createdAtMs: 0, + expiresAtMs: 1_000, + }, + approvalKind: "exec", + nowMs: 100, + view: { + approvalKind: "exec", + approvalId: "req-1", + phase: "pending", + title: "Exec Approval Required", + description: "A command needs your approval.", + metadata: [], + ask: "on-request", + agentId: "agent-1", + commandText: "echo hi", + commandPreview: "echo hi", + cwd: "/repo", + host: "gateway", + actions: [ + { + decision: "allow-once", + label: "Allow Once", + style: "success", + command: "/approve req-1 allow-once", + }, + { + decision: "deny", + label: "Deny", + style: "danger", + command: "/approve req-1 deny", + }, + ], + expiresAtMs: 1_000, + } as never, + }); + + await matrixApprovalNativeRuntime.transport.deliverPending({ + cfg: {} as never, + accountId: "default", + context: { + client: {} as never, + deps: { + sendSingleTextMessage, + reactMessage, + }, + }, + request: {} as never, + approvalKind: "exec", + preparedTarget: { + to: "room:!room:example.org", + roomId: "!room:example.org", + }, + pendingPayload, + }); + + expect(sendSingleTextMessage).toHaveBeenCalledWith( + "room:!room:example.org", + expect.stringContaining("echo hi"), + expect.objectContaining({ + extraContent: { + "com.openclaw.approval": expect.objectContaining({ + version: 1, + type: "approval.request", + state: "pending", + id: "req-1", + kind: "exec", + commandText: "echo hi", + cwd: "/repo", + agentId: "agent-1", + allowedDecisions: ["allow-once", "deny"], + }), + }, + }), + ); + }); + + it("includes plugin approval fields in Matrix approval content", async () => { + const pendingPayload = await matrixApprovalNativeRuntime.presentation.buildPendingPayload({ + cfg: {} as never, + accountId: "default", + context: { client: {} as never }, + request: { + id: "plugin:req-1", + request: { + title: "Plugin Approval Required", + description: "Approve the tool call.", + severity: "critical", + toolName: "deploy", + pluginId: "ops", + agentId: "agent-1", + }, + createdAtMs: 0, + expiresAtMs: 1_000, + }, + approvalKind: "plugin", + nowMs: 100, + view: { + approvalKind: "plugin", + approvalId: "plugin:req-1", + phase: "pending", + title: "Plugin Approval Required", + description: "Approve the tool call.", + metadata: [], + agentId: "agent-1", + pluginId: "ops", + toolName: "deploy", + severity: "critical", + actions: [ + { + decision: "allow-once", + label: "Allow Once", + style: "success", + command: "/approve plugin:req-1 allow-once", + }, + ], + expiresAtMs: 1_000, + } as never, + }); + + expect(pendingPayload).toMatchObject({ + extraContent: { + "com.openclaw.approval": { + version: 1, + type: "approval.request", + state: "pending", + id: "plugin:req-1", + kind: "plugin", + pluginId: "ops", + toolName: "deploy", + agentId: "agent-1", + severity: "critical", + }, + }, + }); + }); + + it("falls back to chunked Matrix delivery when approval content exceeds one event", async () => { + const sendSingleTextMessage = vi + .fn() + .mockRejectedValue(new Error("Matrix single-message text exceeds limit (5000 > 4000)")); + const sendMessage = vi.fn().mockResolvedValue({ + messageId: "$last", + primaryMessageId: "$primary", + messageIds: ["$primary", "$last"], + roomId: "!room:example.org", + }); + const reactMessage = vi.fn().mockResolvedValue(undefined); + const pendingPayload = await matrixApprovalNativeRuntime.presentation.buildPendingPayload({ + cfg: {} as never, + accountId: "default", + context: { client: {} as never }, + request: { + id: "req-1", + request: { + command: "echo hi", + }, + createdAtMs: 0, + expiresAtMs: 1_000, + }, + approvalKind: "exec", + nowMs: 100, + view: { + approvalKind: "exec", + approvalId: "req-1", + phase: "pending", + title: "Exec Approval Required", + description: "A command needs your approval.", + metadata: [], + commandText: "echo hi", + actions: [ + { + decision: "allow-once", + label: "Allow Once", + style: "success", + command: "/approve req-1 allow-once", + }, + ], + expiresAtMs: 1_000, + } as never, + }); + + const entry = await matrixApprovalNativeRuntime.transport.deliverPending({ + cfg: {} as never, + accountId: "default", + context: { + client: {} as never, + deps: { + sendSingleTextMessage, + sendMessage, + reactMessage, + }, + }, + request: {} as never, + approvalKind: "exec", + preparedTarget: { + to: "room:!room:example.org", + roomId: "!room:example.org", + }, + pendingPayload, + }); + + expect(sendMessage).toHaveBeenCalledWith( + "room:!room:example.org", + pendingPayload.text, + expect.objectContaining({ + accountId: "default", + extraContent: pendingPayload.extraContent, + }), + ); + expect(reactMessage).toHaveBeenCalledWith( + "!room:example.org", + "$primary", + expect.any(String), + expect.objectContaining({ + accountId: "default", + }), + ); + expect(entry).toMatchObject({ + roomId: "!room:example.org", + messageIds: ["$primary", "$last"], + reactionEventId: "$primary", + }); + }); + it("uses a longer code fence when resolved commands contain triple backticks", async () => { const result = await matrixApprovalNativeRuntime.presentation.buildResolvedResult({ cfg: {} as never, diff --git a/extensions/matrix/src/approval-handler.runtime.ts b/extensions/matrix/src/approval-handler.runtime.ts index 0277f18235f..223d44d2a0a 100644 --- a/extensions/matrix/src/approval-handler.runtime.ts +++ b/extensions/matrix/src/approval-handler.runtime.ts @@ -26,10 +26,17 @@ import { resolveMatrixAccount } from "./matrix/accounts.js"; import { deleteMatrixMessage, editMatrixMessage } from "./matrix/actions/messages.js"; import { repairMatrixDirectRooms } from "./matrix/direct-management.js"; import type { MatrixClient } from "./matrix/sdk.js"; -import { reactMatrixMessage, sendMessageMatrix } from "./matrix/send.js"; +import { + reactMatrixMessage, + sendMessageMatrix, + sendSingleTextMessageMatrix, +} from "./matrix/send.js"; import { resolveMatrixTargetIdentity } from "./matrix/target-ids.js"; import type { CoreConfig } from "./types.js"; +// OpenClaw Matrix custom event content for capable clients; body and reactions remain fallback. +const MATRIX_APPROVAL_METADATA_KEY = "com.openclaw.approval" as const; + type PendingMessage = { roomId: string; messageIds: readonly string[]; @@ -40,10 +47,58 @@ type PreparedMatrixTarget = { roomId: string; threadId?: string; }; +type MatrixApprovalMetadataAction = { + decision: ExecApprovalReplyDecision; + label: string; + style: PendingApprovalView["actions"][number]["style"]; + command: string; +}; +type MatrixApprovalMetadataBase = { + version: 1; + type: "approval.request"; + id: string; + state: "pending"; + kind: PendingApprovalView["approvalKind"]; + phase: "pending"; + title: string; + description?: string; + expiresAtMs: number; + metadata: PendingApprovalView["metadata"]; + allowedDecisions: ExecApprovalReplyDecision[]; + actions: MatrixApprovalMetadataAction[]; +}; +type MatrixExecApprovalMetadata = MatrixApprovalMetadataBase & { + kind: "exec"; + ask?: string; + agentId?: string; + commandText: string; + commandPreview?: string; + cwd?: string; + envKeys?: readonly string[]; + host?: string; + nodeId?: string; + sessionKey?: string; +}; +type MatrixPluginApprovalSeverity = Extract< + PendingApprovalView, + { approvalKind: "plugin" } +>["severity"]; +type MatrixPluginApprovalMetadata = MatrixApprovalMetadataBase & { + kind: "plugin"; + agentId?: string; + pluginId?: string; + toolName?: string; + severity: MatrixPluginApprovalSeverity; +}; +type MatrixApprovalMetadata = MatrixExecApprovalMetadata | MatrixPluginApprovalMetadata; +type MatrixApprovalExtraContent = { + [MATRIX_APPROVAL_METADATA_KEY]: MatrixApprovalMetadata; +}; type PendingApprovalContent = { approvalId: string; text: string; allowedDecisions: readonly ExecApprovalReplyDecision[]; + extraContent: MatrixApprovalExtraContent; }; type ReactionTargetRef = { roomId: string; @@ -64,6 +119,7 @@ type MatrixPrepareTargetParams = { export type MatrixApprovalHandlerDeps = { nowMs?: () => number; sendMessage?: typeof sendMessageMatrix; + sendSingleTextMessage?: typeof sendSingleTextMessageMatrix; reactMessage?: typeof reactMatrixMessage; editMessage?: typeof editMatrixMessage; deleteMessage?: typeof deleteMatrixMessage; @@ -105,6 +161,12 @@ function normalizeThreadId(value?: string | number | null): string | undefined { return trimmed || undefined; } +function isSingleMatrixMessageLimitError(error: unknown): boolean { + return ( + error instanceof Error && error.message.includes("Matrix single-message text exceeds limit") + ); +} + async function prepareTarget( params: MatrixPrepareTargetParams, ): Promise { @@ -144,6 +206,56 @@ async function prepareTarget( }; } +function buildMatrixApprovalMetadata(params: { + view: PendingApprovalView; + allowedDecisions: readonly ExecApprovalReplyDecision[]; +}): MatrixApprovalMetadata { + const base: MatrixApprovalMetadataBase = { + version: 1, + type: "approval.request", + id: params.view.approvalId, + state: "pending", + kind: params.view.approvalKind, + phase: params.view.phase, + title: params.view.title, + expiresAtMs: params.view.expiresAtMs, + metadata: params.view.metadata, + allowedDecisions: Array.from(params.allowedDecisions), + actions: params.view.actions.map((action) => ({ + decision: action.decision, + label: action.label, + style: action.style, + command: action.command, + })), + ...(params.view.description != null ? { description: params.view.description } : {}), + }; + + if (params.view.approvalKind === "plugin") { + return { + ...base, + kind: "plugin", + severity: params.view.severity, + ...(params.view.agentId != null ? { agentId: params.view.agentId } : {}), + ...(params.view.pluginId != null ? { pluginId: params.view.pluginId } : {}), + ...(params.view.toolName != null ? { toolName: params.view.toolName } : {}), + }; + } + + return { + ...base, + kind: "exec", + commandText: params.view.commandText, + ...(params.view.ask != null ? { ask: params.view.ask } : {}), + ...(params.view.agentId != null ? { agentId: params.view.agentId } : {}), + ...(params.view.commandPreview != null ? { commandPreview: params.view.commandPreview } : {}), + ...(params.view.cwd != null ? { cwd: params.view.cwd } : {}), + ...(params.view.envKeys != null ? { envKeys: params.view.envKeys } : {}), + ...(params.view.host != null ? { host: params.view.host } : {}), + ...(params.view.nodeId != null ? { nodeId: params.view.nodeId } : {}), + ...(params.view.sessionKey != null ? { sessionKey: params.view.sessionKey } : {}), + }; +} + function buildPendingApprovalContent(params: { view: PendingApprovalView; nowMs: number; @@ -189,6 +301,12 @@ function buildPendingApprovalContent(params: { approvalId: params.view.approvalId, text: hint ? (text ? `${hint}\n\n${text}` : hint) : text, allowedDecisions, + extraContent: { + [MATRIX_APPROVAL_METADATA_KEY]: buildMatrixApprovalMetadata({ + view: params.view, + allowedDecisions, + }), + }, }; } @@ -292,14 +410,31 @@ export const matrixApprovalNativeRuntime = createChannelApprovalNativeRuntimeAda if (!resolved) { return null; } - const sendMessage = resolved.context.deps?.sendMessage ?? sendMessageMatrix; + const sendSingleTextMessage = + resolved.context.deps?.sendSingleTextMessage ?? sendSingleTextMessageMatrix; const reactMessage = resolved.context.deps?.reactMessage ?? reactMatrixMessage; - const result = await sendMessage(preparedTarget.to, pendingPayload.text, { - cfg: cfg as CoreConfig, - accountId: resolved.accountId, - client: resolved.context.client, - threadId: preparedTarget.threadId, - }); + let result; + try { + result = await sendSingleTextMessage(preparedTarget.to, pendingPayload.text, { + cfg: cfg as CoreConfig, + accountId: resolved.accountId, + client: resolved.context.client, + threadId: preparedTarget.threadId, + extraContent: pendingPayload.extraContent, + }); + } catch (error) { + if (!isSingleMatrixMessageLimitError(error)) { + throw error; + } + const sendMessage = resolved.context.deps?.sendMessage ?? sendMessageMatrix; + result = await sendMessage(preparedTarget.to, pendingPayload.text, { + cfg: cfg as CoreConfig, + accountId: resolved.accountId, + client: resolved.context.client, + threadId: preparedTarget.threadId, + extraContent: pendingPayload.extraContent, + }); + } const messageIds = Array.from( new Set( (result.messageIds ?? [result.messageId]) diff --git a/extensions/matrix/src/matrix/send.test.ts b/extensions/matrix/src/matrix/send.test.ts index dd206927e8e..e1be0f26dcd 100644 --- a/extensions/matrix/src/matrix/send.test.ts +++ b/extensions/matrix/src/matrix/send.test.ts @@ -630,6 +630,28 @@ describe("sendMessageMatrix threads", () => { messageIds: ["$m1", "$m2", "$m3"], }); }); + + it("merges extra content into only the first chunked text event", async () => { + const { client, sendMessage } = makeClient(); + convertMarkdownTablesMock.mockImplementation(() => "first|second|third"); + chunkMarkdownTextWithModeMock.mockImplementation((text: string) => text.split("|")); + + await sendMessageMatrix("room:!room:example", "ignored", { + client, + cfg: {} as never, + extraContent: { "com.openclaw.approval": { id: "req-1" } }, + }); + + expect(sendMessage).toHaveBeenCalledTimes(3); + expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({ + body: "first", + "com.openclaw.approval": { id: "req-1" }, + }); + expect(sendMessage.mock.calls[1]?.[1]).toMatchObject({ body: "second" }); + expect(sendMessage.mock.calls[1]?.[1]).not.toHaveProperty("com.openclaw.approval"); + expect(sendMessage.mock.calls[2]?.[1]).toMatchObject({ body: "third" }); + expect(sendMessage.mock.calls[2]?.[1]).not.toHaveProperty("com.openclaw.approval"); + }); }); describe("sendSingleTextMessageMatrix", () => { diff --git a/extensions/matrix/src/matrix/send.ts b/extensions/matrix/src/matrix/send.ts index ca792de330e..e8189729c62 100644 --- a/extensions/matrix/src/matrix/send.ts +++ b/extensions/matrix/src/matrix/send.ts @@ -211,8 +211,11 @@ export async function sendMessageMatrix( const relation = threadId ? buildThreadRelation(threadId, opts.replyToId) : buildReplyRelation(opts.replyToId); + let pendingExtraContent = opts.extraContent; const sendContent = async (content: MatrixOutboundContent) => { - const eventId = await client.sendMessage(roomId, content); + const contentWithExtra = withMatrixExtraContentFields(content, pendingExtraContent); + pendingExtraContent = undefined; + const eventId = await client.sendMessage(roomId, contentWithExtra); return eventId; }; diff --git a/extensions/matrix/src/matrix/send/types.ts b/extensions/matrix/src/matrix/send/types.ts index 89ba915f283..da90dd9de62 100644 --- a/extensions/matrix/src/matrix/send/types.ts +++ b/extensions/matrix/src/matrix/send/types.ts @@ -102,6 +102,8 @@ export type MatrixSendOpts = { replyToId?: string; threadId?: string | number | null; timeoutMs?: number; + /** Additional Matrix event content fields to merge into the first sent event. */ + extraContent?: MatrixExtraContentFields; /** Send audio as voice message instead of audio file. Defaults to false. */ audioAsVoice?: boolean; };