Add structured Matrix approval metadata (#72432)

Merged via squash.

Prepared head SHA: 0e06533dff
Co-authored-by: kakahu2015 <17962485+kakahu2015@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
kakahu
2026-04-28 02:52:02 +08:00
committed by GitHub
parent d0be08a9a4
commit d70808433d
8 changed files with 426 additions and 10 deletions

View File

@@ -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

View File

@@ -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).

View File

@@ -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

View File

@@ -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,

View File

@@ -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<PreparedMatrixTarget | null> {
@@ -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])

View File

@@ -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", () => {

View File

@@ -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;
};

View File

@@ -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;
};