mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:40:43 +00:00
fix: harden matrix approval metadata
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -1,7 +1,250 @@
|
||||
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",
|
||||
}),
|
||||
);
|
||||
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,
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
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 = {
|
||||
@@ -114,7 +115,9 @@ function normalizeThreadId(value?: string | number | null): string | undefined {
|
||||
}
|
||||
|
||||
function isSingleMatrixMessageLimitError(error: unknown): boolean {
|
||||
return error instanceof Error && error.message.includes("Matrix single-message text exceeds limit");
|
||||
return (
|
||||
error instanceof Error && error.message.includes("Matrix single-message text exceeds limit")
|
||||
);
|
||||
}
|
||||
|
||||
async function prepareTarget(
|
||||
@@ -166,7 +169,9 @@ function buildMatrixApprovalMetadata(params: {
|
||||
}): Record<string, unknown> {
|
||||
const base = removeUndefinedValues({
|
||||
version: 1,
|
||||
type: "approval.request",
|
||||
id: params.view.approvalId,
|
||||
state: "pending",
|
||||
kind: params.view.approvalKind,
|
||||
phase: params.view.phase,
|
||||
title: params.view.title,
|
||||
|
||||
Reference in New Issue
Block a user