Refactor: centralize native approval lifecycle assembly (#62135)

Merged via squash.

Prepared head SHA: b7c20a7398
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Gustavo Madeira Santana
2026-04-07 14:40:26 -04:00
committed by GitHub
parent 4108901932
commit d78512b09d
128 changed files with 8839 additions and 3995 deletions

View File

@@ -2,7 +2,7 @@ import {
createResolvedApproverActionAuthAdapter,
resolveApprovalApprovers,
} from "openclaw/plugin-sdk/approval-auth-runtime";
import { normalizeMatrixApproverId } from "./exec-approvals.js";
import { normalizeMatrixApproverId } from "./approval-ids.js";
import { resolveMatrixAccount } from "./matrix/accounts.js";
import type { CoreConfig } from "./types.js";

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { matrixApprovalNativeRuntime } from "./approval-handler.runtime.js";
describe("matrixApprovalNativeRuntime", () => {
it("uses a longer code fence when resolved commands contain triple backticks", async () => {
const result = await matrixApprovalNativeRuntime.presentation.buildResolvedResult({
cfg: {} as never,
accountId: "default",
context: {
client: {} as never,
},
request: {
id: "req-1",
request: {
command: "echo hi",
},
createdAtMs: 0,
expiresAtMs: 1_000,
},
resolved: {
id: "req-1",
decision: "allow-once",
ts: 0,
},
view: {
approvalKind: "exec",
approvalId: "req-1",
decision: "allow-once",
commandText: "echo ```danger```",
} as never,
entry: {} as never,
});
expect(result).toEqual({
kind: "update",
payload: [
"Exec approval: Allowed once",
"",
"Command",
"````",
"echo ```danger```",
"````",
].join("\n"),
});
});
});

View File

@@ -0,0 +1,393 @@
import type {
ChannelApprovalCapabilityHandlerContext,
PendingApprovalView,
ResolvedApprovalView,
} from "openclaw/plugin-sdk/approval-handler-runtime";
import { createChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime";
import { buildChannelApprovalNativeTargetKey } from "openclaw/plugin-sdk/approval-native-runtime";
import {
buildExecApprovalPendingReplyPayload,
buildPluginApprovalPendingReplyPayload,
type ExecApprovalReplyDecision,
} from "openclaw/plugin-sdk/approval-reply-runtime";
import { buildPluginApprovalResolvedReplyPayload } from "openclaw/plugin-sdk/approval-runtime";
import type { ExecApprovalRequest, PluginApprovalRequest } from "openclaw/plugin-sdk/infra-runtime";
import {
buildMatrixApprovalReactionHint,
listMatrixApprovalReactionBindings,
registerMatrixApprovalReactionTarget,
unregisterMatrixApprovalReactionTarget,
} from "./approval-reactions.js";
import {
isMatrixAnyApprovalClientEnabled,
shouldHandleMatrixApprovalRequest,
} from "./exec-approvals.js";
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 { resolveMatrixTargetIdentity } from "./matrix/target-ids.js";
import type { CoreConfig } from "./types.js";
type PendingMessage = {
roomId: string;
messageIds: readonly string[];
reactionEventId: string;
};
type PreparedMatrixTarget = {
to: string;
roomId: string;
threadId?: string;
};
type PendingApprovalContent = {
approvalId: string;
text: string;
allowedDecisions: readonly ExecApprovalReplyDecision[];
};
type ReactionTargetRef = {
roomId: string;
eventId: string;
};
export type MatrixApprovalHandlerDeps = {
nowMs?: () => number;
sendMessage?: typeof sendMessageMatrix;
reactMessage?: typeof reactMatrixMessage;
editMessage?: typeof editMatrixMessage;
deleteMessage?: typeof deleteMatrixMessage;
repairDirectRooms?: typeof repairMatrixDirectRooms;
};
export type MatrixApprovalHandlerContext = {
client: MatrixClient;
deps?: MatrixApprovalHandlerDeps;
};
function resolveHandlerContext(params: ChannelApprovalCapabilityHandlerContext): {
accountId: string;
context: MatrixApprovalHandlerContext;
} | null {
const context = params.context as MatrixApprovalHandlerContext | undefined;
const accountId = params.accountId?.trim() || "";
if (!context?.client || !accountId) {
return null;
}
return { accountId, context };
}
function normalizePendingMessageIds(entry: PendingMessage): string[] {
return Array.from(new Set(entry.messageIds.map((messageId) => messageId.trim()).filter(Boolean)));
}
function normalizeReactionTargetRef(params: ReactionTargetRef): ReactionTargetRef | null {
const roomId = params.roomId.trim();
const eventId = params.eventId.trim();
if (!roomId || !eventId) {
return null;
}
return { roomId, eventId };
}
function normalizeThreadId(value?: string | number | null): string | undefined {
const trimmed = value == null ? "" : String(value).trim();
return trimmed || undefined;
}
async function prepareTarget(
params: ChannelApprovalCapabilityHandlerContext & {
rawTarget: {
to: string;
threadId?: string | number | null;
};
},
): Promise<PreparedMatrixTarget | null> {
const resolved = resolveHandlerContext(params);
if (!resolved) {
return null;
}
const target = resolveMatrixTargetIdentity(params.rawTarget.to);
if (!target) {
return null;
}
const threadId = normalizeThreadId(params.rawTarget.threadId);
if (target.kind === "user") {
const account = resolveMatrixAccount({
cfg: params.cfg as CoreConfig,
accountId: resolved.accountId,
});
const repairDirectRooms = resolved.context.deps?.repairDirectRooms ?? repairMatrixDirectRooms;
const repaired = await repairDirectRooms({
client: resolved.context.client,
remoteUserId: target.id,
encrypted: account.config.encryption === true,
});
if (!repaired.activeRoomId) {
return null;
}
return {
to: `room:${repaired.activeRoomId}`,
roomId: repaired.activeRoomId,
threadId,
};
}
return {
to: `room:${target.id}`,
roomId: target.id,
threadId,
};
}
function buildPendingApprovalContent(params: {
view: PendingApprovalView;
nowMs: number;
}): PendingApprovalContent {
const allowedDecisions = params.view.actions.map((action) => action.decision);
const payload =
params.view.approvalKind === "plugin"
? buildPluginApprovalPendingReplyPayload({
request: {
id: params.view.approvalId,
request: {
title: params.view.title,
description: params.view.description ?? "",
severity: params.view.severity,
toolName: params.view.toolName ?? undefined,
pluginId: params.view.pluginId ?? undefined,
agentId: params.view.agentId ?? undefined,
},
createdAtMs: 0,
expiresAtMs: params.view.expiresAtMs,
} satisfies PluginApprovalRequest,
nowMs: params.nowMs,
allowedDecisions,
})
: buildExecApprovalPendingReplyPayload({
approvalId: params.view.approvalId,
approvalSlug: params.view.approvalId.slice(0, 8),
approvalCommandId: params.view.approvalId,
ask: params.view.ask ?? undefined,
agentId: params.view.agentId ?? undefined,
allowedDecisions,
command: params.view.commandText,
cwd: params.view.cwd ?? undefined,
host: params.view.host === "node" ? "node" : "gateway",
nodeId: params.view.nodeId ?? undefined,
sessionKey: params.view.sessionKey ?? undefined,
expiresAtMs: params.view.expiresAtMs,
nowMs: params.nowMs,
});
const hint = buildMatrixApprovalReactionHint(allowedDecisions);
const text = payload.text ?? "";
return {
approvalId: params.view.approvalId,
text: hint ? (text ? `${hint}\n\n${text}` : hint) : text,
allowedDecisions,
};
}
function buildResolvedApprovalText(view: ResolvedApprovalView): string {
if (view.approvalKind === "plugin") {
return (
buildPluginApprovalResolvedReplyPayload({
resolved: {
id: view.approvalId,
decision: view.decision,
resolvedBy: view.resolvedBy ?? undefined,
ts: 0,
},
}).text ?? ""
);
}
const decisionLabel =
view.decision === "allow-once"
? "Allowed once"
: view.decision === "allow-always"
? "Allowed always"
: "Denied";
return [
`Exec approval: ${decisionLabel}`,
"",
"Command",
buildMarkdownCodeBlock(view.commandText),
].join("\n");
}
function buildMarkdownCodeBlock(text: string): string {
const longestFence = Math.max(...Array.from(text.matchAll(/`+/g), (match) => match[0].length), 0);
const fence = "`".repeat(Math.max(3, longestFence + 1));
return [fence, text, fence].join("\n");
}
export const matrixApprovalNativeRuntime = createChannelApprovalNativeRuntimeAdapter<
PendingApprovalContent,
PreparedMatrixTarget,
PendingMessage,
ReactionTargetRef
>({
eventKinds: ["exec", "plugin"],
availability: {
isConfigured: ({ cfg, accountId, context }) => {
const resolved = resolveHandlerContext({ cfg, accountId, context });
if (!resolved) {
return false;
}
return isMatrixAnyApprovalClientEnabled({
cfg,
accountId: resolved.accountId,
});
},
shouldHandle: ({ cfg, accountId, request, context }) => {
const resolved = resolveHandlerContext({ cfg, accountId, context });
if (!resolved) {
return false;
}
return shouldHandleMatrixApprovalRequest({
cfg,
accountId: resolved.accountId,
request: request as ExecApprovalRequest | PluginApprovalRequest,
});
},
},
presentation: {
buildPendingPayload: ({ view, nowMs }) =>
buildPendingApprovalContent({
view,
nowMs,
}),
buildResolvedResult: ({ view }) => ({
kind: "update",
payload: buildResolvedApprovalText(view),
}),
buildExpiredResult: () => ({ kind: "delete" }),
},
transport: {
prepareTarget: ({ cfg, accountId, context, plannedTarget }) => {
return prepareTarget({
cfg,
accountId,
context,
rawTarget: plannedTarget.target,
}).then((preparedTarget) =>
preparedTarget
? {
dedupeKey: buildChannelApprovalNativeTargetKey({
to: preparedTarget.roomId,
threadId: preparedTarget.threadId,
}),
target: preparedTarget,
}
: null,
);
},
deliverPending: async ({ cfg, accountId, context, preparedTarget, pendingPayload }) => {
const resolved = resolveHandlerContext({ cfg, accountId, context });
if (!resolved) {
return null;
}
const sendMessage = resolved.context.deps?.sendMessage ?? sendMessageMatrix;
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,
});
const messageIds = Array.from(
new Set(
(result.messageIds ?? [result.messageId])
.map((messageId) => messageId.trim())
.filter(Boolean),
),
);
const reactionEventId =
result.primaryMessageId?.trim() || messageIds[0] || result.messageId.trim();
await Promise.allSettled(
listMatrixApprovalReactionBindings(pendingPayload.allowedDecisions).map(
async ({ emoji }) => {
await reactMessage(result.roomId, reactionEventId, emoji, {
cfg: cfg as CoreConfig,
accountId: resolved.accountId,
client: resolved.context.client,
});
},
),
);
return {
roomId: result.roomId,
messageIds,
reactionEventId,
};
},
updateEntry: async ({ cfg, accountId, context, entry, payload }) => {
const resolved = resolveHandlerContext({ cfg, accountId, context });
if (!resolved) {
return;
}
const editMessage = resolved.context.deps?.editMessage ?? editMatrixMessage;
const deleteMessage = resolved.context.deps?.deleteMessage ?? deleteMatrixMessage;
const [primaryMessageId, ...staleMessageIds] = normalizePendingMessageIds(entry);
if (!primaryMessageId) {
return;
}
const text = payload as string;
await Promise.allSettled([
editMessage(entry.roomId, primaryMessageId, text, {
cfg: cfg as CoreConfig,
accountId: resolved.accountId,
client: resolved.context.client,
}),
...staleMessageIds.map(async (messageId) => {
await deleteMessage(entry.roomId, messageId, {
cfg: cfg as CoreConfig,
accountId: resolved.accountId,
client: resolved.context.client,
reason: "approval resolved",
});
}),
]);
},
deleteEntry: async ({ cfg, accountId, context, entry, phase }) => {
const resolved = resolveHandlerContext({ cfg, accountId, context });
if (!resolved) {
return;
}
const deleteMessage = resolved.context.deps?.deleteMessage ?? deleteMatrixMessage;
await Promise.allSettled(
normalizePendingMessageIds(entry).map(async (messageId) => {
await deleteMessage(entry.roomId, messageId, {
cfg: cfg as CoreConfig,
accountId: resolved.accountId,
client: resolved.context.client,
reason: phase === "expired" ? "approval expired" : "approval resolved",
});
}),
);
},
},
interactions: {
bindPending: ({ entry, pendingPayload }) => {
const target = normalizeReactionTargetRef({
roomId: entry.roomId,
eventId: entry.reactionEventId,
});
if (!target) {
return null;
}
registerMatrixApprovalReactionTarget({
roomId: target.roomId,
eventId: target.eventId,
approvalId: pendingPayload.approvalId,
allowedDecisions: pendingPayload.allowedDecisions,
});
return target;
},
unbindPending: ({ binding }) => {
const target = normalizeReactionTargetRef(binding);
if (!target) {
return;
}
unregisterMatrixApprovalReactionTarget(target);
},
},
});

View File

@@ -0,0 +1,6 @@
import { normalizeMatrixUserId } from "./matrix/monitor/allowlist.js";
export function normalizeMatrixApproverId(value: string | number): string | undefined {
const normalized = normalizeMatrixUserId(String(value));
return normalized || undefined;
}

View File

@@ -69,7 +69,7 @@ describe("matrix native approval adapter", () => {
preferredSurface: "both",
supportsOriginSurface: true,
supportsApproverDmSurface: true,
notifyOriginWhenDmOnly: false,
notifyOriginWhenDmOnly: true,
});
});
@@ -117,7 +117,33 @@ describe("matrix native approval adapter", () => {
expect(targets).toEqual([{ to: "user:@owner:example.org" }]);
});
it("keeps plugin forwarding fallback active when native delivery is exec-only", () => {
it("falls back to the session-key origin target for plugin approvals when the store is missing", async () => {
const target = await matrixNativeApprovalAdapter.native?.resolveOriginTarget?.({
cfg: buildConfig({
dm: { allowFrom: ["@owner:example.org"] },
}),
accountId: "default",
approvalKind: "plugin",
request: {
id: "plugin:req-1",
request: {
title: "Plugin Approval Required",
description: "Allow plugin access",
pluginId: "git-tools",
sessionKey: "agent:main:matrix:channel:!ops:example.org:thread:$root",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
});
expect(target).toEqual({
to: "room:!ops:example.org",
threadId: "$root",
});
});
it("suppresses same-channel plugin forwarding when Matrix native delivery is available", () => {
const shouldSuppress = matrixNativeApprovalAdapter.delivery?.shouldSuppressForwardingFallback;
if (!shouldSuppress) {
throw new Error("delivery suppression helper unavailable");
@@ -125,7 +151,9 @@ describe("matrix native approval adapter", () => {
expect(
shouldSuppress({
cfg: buildConfig(),
cfg: buildConfig({
dm: { allowFrom: ["@owner:example.org"] },
}),
approvalKind: "plugin",
target: {
channel: "matrix",
@@ -133,9 +161,11 @@ describe("matrix native approval adapter", () => {
accountId: "default",
},
request: {
id: "req-1",
id: "plugin:req-1",
request: {
command: "echo hi",
title: "Plugin Approval Required",
description: "Allow plugin action",
pluginId: "git-tools",
turnSourceChannel: "matrix",
turnSourceTo: "room:!ops:example.org",
turnSourceAccountId: "default",
@@ -144,7 +174,7 @@ describe("matrix native approval adapter", () => {
expiresAtMs: 1000,
},
}),
).toBe(false);
).toBe(true);
});
it("preserves room-id case when matching Matrix origin targets", async () => {
@@ -241,7 +271,63 @@ describe("matrix native approval adapter", () => {
});
});
it("disables matrix-native plugin approval delivery", () => {
it("reports exec initiating-surface availability independently from plugin auth", () => {
const cfg = buildConfig({
dm: { allowFrom: ["@owner:example.org"] },
execApprovals: {
enabled: false,
approvers: [],
target: "both",
},
});
expect(
matrixApprovalCapability.getActionAvailabilityState?.({
cfg,
accountId: "default",
action: "approve",
approvalKind: "plugin",
}),
).toEqual({ kind: "enabled" });
expect(
matrixApprovalCapability.getExecInitiatingSurfaceState?.({
cfg,
accountId: "default",
action: "approve",
}),
).toEqual({ kind: "disabled" });
});
it("enables matrix-native plugin approval delivery when DM approvers are configured", () => {
const capabilities = matrixNativeApprovalAdapter.native?.describeDeliveryCapabilities({
cfg: buildConfig({
dm: { allowFrom: ["@owner:example.org"] },
}),
accountId: "default",
approvalKind: "plugin",
request: {
id: "plugin:req-1",
request: {
title: "Plugin Approval Required",
description: "Allow plugin access",
pluginId: "git-tools",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
});
expect(capabilities).toEqual({
enabled: true,
preferredSurface: "both",
supportsOriginSurface: true,
supportsApproverDmSurface: true,
notifyOriginWhenDmOnly: true,
});
});
it("keeps matrix-native plugin approval delivery disabled without DM approvers", () => {
const capabilities = matrixNativeApprovalAdapter.native?.describeDeliveryCapabilities({
cfg: buildConfig(),
accountId: "default",
@@ -260,10 +346,10 @@ describe("matrix native approval adapter", () => {
expect(capabilities).toEqual({
enabled: false,
preferredSurface: "approver-dm",
supportsOriginSurface: false,
supportsApproverDmSurface: false,
notifyOriginWhenDmOnly: false,
preferredSurface: "both",
supportsOriginSurface: true,
supportsApproverDmSurface: true,
notifyOriginWhenDmOnly: true,
});
});
});

View File

@@ -3,23 +3,27 @@ import {
createApproverRestrictedNativeApprovalCapability,
splitChannelApprovalCapability,
} from "openclaw/plugin-sdk/approval-delivery-runtime";
import { createLazyChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime";
import {
createChannelApproverDmTargetResolver,
createChannelNativeOriginTargetResolver,
resolveApprovalRequestSessionConversation,
} from "openclaw/plugin-sdk/approval-native-runtime";
import type { ExecApprovalRequest, PluginApprovalRequest } from "openclaw/plugin-sdk/infra-runtime";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
normalizeOptionalStringifiedId,
} from "openclaw/plugin-sdk/text-runtime";
import { getMatrixApprovalAuthApprovers, matrixApprovalAuth } from "./approval-auth.js";
import { normalizeMatrixApproverId } from "./approval-ids.js";
import {
getMatrixApprovalApprovers,
getMatrixExecApprovalApprovers,
isMatrixExecApprovalAuthorizedSender,
isMatrixAnyApprovalClientEnabled,
isMatrixApprovalClientEnabled,
isMatrixExecApprovalClientEnabled,
isMatrixExecApprovalAuthorizedSender,
resolveMatrixExecApprovalTarget,
shouldHandleMatrixExecApprovalRequest,
shouldHandleMatrixApprovalRequest,
} from "./exec-approvals.js";
import { listMatrixAccountIds } from "./matrix/accounts.js";
import { normalizeMatrixUserId } from "./matrix/monitor/allowlist.js";
@@ -27,14 +31,8 @@ import { resolveMatrixTargetIdentity } from "./matrix/target-ids.js";
import type { CoreConfig } from "./types.js";
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
type ApprovalKind = "exec" | "plugin";
type MatrixOriginTarget = { to: string; threadId?: string };
const MATRIX_PLUGIN_NATIVE_DELIVERY_DISABLED = {
enabled: false,
preferredSurface: "approver-dm" as const,
supportsOriginSurface: false,
supportsApproverDmSurface: false,
notifyOriginWhenDmOnly: false,
};
function normalizeComparableTarget(value: string): string {
const target = resolveMatrixTargetIdentity(value);
@@ -93,10 +91,63 @@ function hasMatrixPluginApprovers(params: { cfg: CoreConfig; accountId?: string
return getMatrixApprovalAuthApprovers(params).length > 0;
}
function availabilityState(enabled: boolean) {
return enabled ? ({ kind: "enabled" } as const) : ({ kind: "disabled" } as const);
}
function hasMatrixApprovalApprovers(params: {
cfg: CoreConfig;
accountId?: string | null;
approvalKind: ApprovalKind;
}): boolean {
return (
getMatrixApprovalApprovers({
cfg: params.cfg,
accountId: params.accountId,
approvalKind: params.approvalKind,
}).length > 0
);
}
function hasAnyMatrixApprovalApprovers(params: {
cfg: CoreConfig;
accountId?: string | null;
}): boolean {
return (
getMatrixExecApprovalApprovers(params).length > 0 ||
getMatrixApprovalAuthApprovers(params).length > 0
);
}
function isMatrixPluginAuthorizedSender(params: {
cfg: CoreConfig;
accountId?: string | null;
senderId?: string | null;
}): boolean {
const normalizedSenderId = params.senderId
? normalizeMatrixApproverId(params.senderId)
: undefined;
if (!normalizedSenderId) {
return false;
}
return getMatrixApprovalAuthApprovers(params).includes(normalizedSenderId);
}
function resolveSuppressionAccountId(params: {
target: { accountId?: string | null };
request: { request: { turnSourceAccountId?: string | null } };
}): string | undefined {
return (
params.target.accountId?.trim() ||
params.request.request.turnSourceAccountId?.trim() ||
undefined
);
}
const resolveMatrixOriginTarget = createChannelNativeOriginTargetResolver({
channel: "matrix",
shouldHandleRequest: ({ cfg, accountId, request }) =>
shouldHandleMatrixExecApprovalRequest({
shouldHandleMatrixApprovalRequest({
cfg,
accountId,
request,
@@ -104,22 +155,42 @@ const resolveMatrixOriginTarget = createChannelNativeOriginTargetResolver({
resolveTurnSourceTarget: resolveTurnSourceMatrixOriginTarget,
resolveSessionTarget: resolveSessionMatrixOriginTarget,
targetsMatch: matrixTargetsMatch,
});
const resolveMatrixApproverDmTargets = createChannelApproverDmTargetResolver({
shouldHandleRequest: ({ cfg, accountId, request }) =>
shouldHandleMatrixExecApprovalRequest({
cfg,
accountId,
resolveFallbackTarget: (request) => {
const sessionConversation = resolveApprovalRequestSessionConversation({
request,
}),
resolveApprovers: getMatrixExecApprovalApprovers,
mapApprover: (approver) => {
const normalized = normalizeMatrixUserId(approver);
return normalized ? { to: `user:${normalized}` } : null;
channel: "matrix",
});
if (!sessionConversation) {
return null;
}
const target = resolveMatrixNativeTarget(sessionConversation.id);
if (!target) {
return null;
}
return {
to: target,
threadId: normalizeOptionalStringifiedId(sessionConversation.threadId),
};
},
});
function resolveMatrixApproverDmTargets(params: {
cfg: CoreConfig;
accountId?: string | null;
approvalKind: ApprovalKind;
request: ApprovalRequest;
}): { to: string }[] {
if (!shouldHandleMatrixApprovalRequest(params)) {
return [];
}
return getMatrixApprovalApprovers(params)
.map((approver) => {
const normalized = normalizeMatrixUserId(approver);
return normalized ? { to: `user:${normalized}` } : null;
})
.filter((target): target is { to: string } => target !== null);
}
const matrixNativeApprovalCapability = createApproverRestrictedNativeApprovalCapability({
channel: "matrix",
channelLabel: "Matrix",
@@ -132,19 +203,42 @@ const matrixNativeApprovalCapability = createApproverRestrictedNativeApprovalCap
},
listAccountIds: listMatrixAccountIds,
hasApprovers: ({ cfg, accountId }) =>
getMatrixExecApprovalApprovers({ cfg, accountId }).length > 0,
hasAnyMatrixApprovalApprovers({
cfg: cfg as CoreConfig,
accountId,
}),
isExecAuthorizedSender: ({ cfg, accountId, senderId }) =>
isMatrixExecApprovalAuthorizedSender({ cfg, accountId, senderId }),
isPluginAuthorizedSender: ({ cfg, accountId, senderId }) =>
isMatrixPluginAuthorizedSender({
cfg: cfg as CoreConfig,
accountId,
senderId,
}),
isNativeDeliveryEnabled: ({ cfg, accountId }) =>
isMatrixExecApprovalClientEnabled({ cfg, accountId }),
resolveNativeDeliveryMode: ({ cfg, accountId }) =>
resolveMatrixExecApprovalTarget({ cfg, accountId }),
requireMatchingTurnSourceChannel: true,
resolveSuppressionAccountId: ({ target, request }) =>
normalizeOptionalString(target.accountId) ??
normalizeOptionalString(request.request.turnSourceAccountId),
resolveSuppressionAccountId,
resolveOriginTarget: resolveMatrixOriginTarget,
resolveApproverDmTargets: resolveMatrixApproverDmTargets,
notifyOriginWhenDmOnly: true,
nativeRuntime: createLazyChannelApprovalNativeRuntimeAdapter({
eventKinds: ["exec", "plugin"],
isConfigured: ({ cfg, accountId }) =>
isMatrixAnyApprovalClientEnabled({
cfg,
accountId,
}),
shouldHandle: ({ cfg, accountId, request }) =>
shouldHandleMatrixApprovalRequest({
cfg,
accountId,
request,
}),
load: async () => (await import("./approval-handler.runtime.js")).matrixApprovalNativeRuntime,
}),
});
const splitMatrixApprovalCapability = splitChannelApprovalCapability(
@@ -157,32 +251,42 @@ type MatrixForwardingSuppressionParams = Parameters<
>[0];
const matrixDeliveryAdapter = matrixBaseDeliveryAdapter && {
...matrixBaseDeliveryAdapter,
shouldSuppressForwardingFallback: (params: MatrixForwardingSuppressionParams) =>
params.approvalKind === "plugin"
? false
: (matrixBaseDeliveryAdapter.shouldSuppressForwardingFallback?.(params) ?? false),
shouldSuppressForwardingFallback: (params: MatrixForwardingSuppressionParams) => {
const accountId = resolveSuppressionAccountId(params);
if (
!hasMatrixApprovalApprovers({
cfg: params.cfg as CoreConfig,
accountId,
approvalKind: params.approvalKind,
})
) {
return false;
}
return matrixBaseDeliveryAdapter.shouldSuppressForwardingFallback?.(params) ?? false;
},
};
const matrixExecOnlyNativeApprovalAdapter = matrixBaseNativeApprovalAdapter && {
const matrixNativeAdapter = matrixBaseNativeApprovalAdapter && {
describeDeliveryCapabilities: (
params: Parameters<typeof matrixBaseNativeApprovalAdapter.describeDeliveryCapabilities>[0],
) =>
params.approvalKind === "plugin"
? MATRIX_PLUGIN_NATIVE_DELIVERY_DISABLED
: matrixBaseNativeApprovalAdapter.describeDeliveryCapabilities(params),
resolveOriginTarget: async (
params: Parameters<NonNullable<typeof matrixBaseNativeApprovalAdapter.resolveOriginTarget>>[0],
) =>
params.approvalKind === "plugin"
? null
: ((await matrixBaseNativeApprovalAdapter.resolveOriginTarget?.(params)) ?? null),
resolveApproverDmTargets: async (
params: Parameters<
NonNullable<typeof matrixBaseNativeApprovalAdapter.resolveApproverDmTargets>
>[0],
) =>
params.approvalKind === "plugin"
? []
: ((await matrixBaseNativeApprovalAdapter.resolveApproverDmTargets?.(params)) ?? []),
) => {
const capabilities = matrixBaseNativeApprovalAdapter.describeDeliveryCapabilities(params);
const hasApprovers = hasMatrixApprovalApprovers({
cfg: params.cfg as CoreConfig,
accountId: params.accountId,
approvalKind: params.approvalKind,
});
const clientEnabled = isMatrixApprovalClientEnabled({
cfg: params.cfg,
accountId: params.accountId,
approvalKind: params.approvalKind,
});
return {
...capabilities,
enabled: capabilities.enabled && hasApprovers && clientEnabled,
};
},
resolveOriginTarget: matrixBaseNativeApprovalAdapter.resolveOriginTarget,
resolveApproverDmTargets: matrixBaseNativeApprovalAdapter.resolveApproverDmTargets,
};
export const matrixApprovalCapability = createChannelApprovalCapability({
@@ -203,28 +307,39 @@ export const matrixApprovalCapability = createChannelApprovalCapability({
}
return matrixApprovalAuth.authorizeActorAction(params);
},
getActionAvailabilityState: (params) =>
hasMatrixPluginApprovers({
cfg: params.cfg as CoreConfig,
accountId: params.accountId,
})
? ({ kind: "enabled" } as const)
: (matrixNativeApprovalCapability.getActionAvailabilityState?.(params) ??
({ kind: "disabled" } as const)),
describeExecApprovalSetup: matrixNativeApprovalCapability.describeExecApprovalSetup,
approvals: {
delivery: matrixDeliveryAdapter,
native: matrixExecOnlyNativeApprovalAdapter,
render: matrixNativeApprovalCapability.render,
getActionAvailabilityState: (params) => {
if (params.approvalKind === "plugin") {
return availabilityState(
hasMatrixPluginApprovers({
cfg: params.cfg as CoreConfig,
accountId: params.accountId,
}),
);
}
return (
matrixNativeApprovalCapability.getActionAvailabilityState?.(params) ?? {
kind: "disabled",
}
);
},
getExecInitiatingSurfaceState: (params) =>
matrixNativeApprovalCapability.getExecInitiatingSurfaceState?.(params) ??
({ kind: "disabled" } as const),
describeExecApprovalSetup: matrixNativeApprovalCapability.describeExecApprovalSetup,
delivery: matrixDeliveryAdapter,
nativeRuntime: matrixNativeApprovalCapability.nativeRuntime,
native: matrixNativeAdapter,
render: matrixNativeApprovalCapability.render,
});
export const matrixNativeApprovalAdapter = {
auth: {
authorizeActorAction: matrixApprovalCapability.authorizeActorAction,
getActionAvailabilityState: matrixApprovalCapability.getActionAvailabilityState,
getExecInitiatingSurfaceState: matrixApprovalCapability.getExecInitiatingSurfaceState,
},
delivery: matrixDeliveryAdapter,
nativeRuntime: matrixApprovalCapability.nativeRuntime,
render: matrixApprovalCapability.render,
native: matrixExecOnlyNativeApprovalAdapter,
native: matrixNativeAdapter,
};

View File

@@ -487,6 +487,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount, MatrixProbe> =
return monitorMatrixProvider({
runtime: ctx.runtime,
channelRuntime: ctx.channelRuntime,
abortSignal: ctx.abortSignal,
mediaMaxMb: account.config.mediaMaxMb,
initialSyncLimit: account.config.initialSyncLimit,

View File

@@ -1,37 +1,56 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const gatewayRuntimeHoisted = vi.hoisted(() => ({
requestSpy: vi.fn(),
withClientSpy: vi.fn(),
const approvalRuntimeHoisted = vi.hoisted(() => ({
resolveApprovalOverGatewaySpy: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/gateway-runtime", () => ({
withOperatorApprovalsGatewayClient: gatewayRuntimeHoisted.withClientSpy,
vi.mock("openclaw/plugin-sdk/approval-handler-runtime", () => ({
resolveApprovalOverGateway: (...args: unknown[]) =>
approvalRuntimeHoisted.resolveApprovalOverGatewaySpy(...args),
}));
describe("resolveMatrixExecApproval", () => {
describe("resolveMatrixApproval", () => {
beforeEach(() => {
gatewayRuntimeHoisted.requestSpy.mockReset();
gatewayRuntimeHoisted.withClientSpy.mockReset().mockImplementation(async (_params, run) => {
await run({
request: gatewayRuntimeHoisted.requestSpy,
} as never);
});
approvalRuntimeHoisted.resolveApprovalOverGatewaySpy.mockReset();
});
it("submits exec approval resolutions through the gateway approvals client", async () => {
const { resolveMatrixExecApproval } = await import("./exec-approval-resolver.js");
it("submits exec approval resolutions through the shared gateway resolver", async () => {
const { resolveMatrixApproval } = await import("./exec-approval-resolver.js");
await resolveMatrixExecApproval({
await resolveMatrixApproval({
cfg: {} as never,
approvalId: "req-123",
decision: "allow-once",
senderId: "@owner:example.org",
});
expect(gatewayRuntimeHoisted.requestSpy).toHaveBeenCalledWith("exec.approval.resolve", {
id: "req-123",
expect(approvalRuntimeHoisted.resolveApprovalOverGatewaySpy).toHaveBeenCalledWith({
cfg: {} as never,
approvalId: "req-123",
decision: "allow-once",
senderId: "@owner:example.org",
gatewayUrl: undefined,
clientDisplayName: "Matrix approval (@owner:example.org)",
});
});
it("passes plugin approval ids through unchanged", async () => {
const { resolveMatrixApproval } = await import("./exec-approval-resolver.js");
await resolveMatrixApproval({
cfg: {} as never,
approvalId: "plugin:req-123",
decision: "deny",
senderId: "@owner:example.org",
});
expect(approvalRuntimeHoisted.resolveApprovalOverGatewaySpy).toHaveBeenCalledWith({
cfg: {} as never,
approvalId: "plugin:req-123",
decision: "deny",
senderId: "@owner:example.org",
gatewayUrl: undefined,
clientDisplayName: "Matrix approval (@owner:example.org)",
});
});

View File

@@ -1,28 +1,25 @@
import { resolveApprovalOverGateway } from "openclaw/plugin-sdk/approval-handler-runtime";
import type { ExecApprovalReplyDecision } from "openclaw/plugin-sdk/approval-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { isApprovalNotFoundError } from "openclaw/plugin-sdk/error-runtime";
import { withOperatorApprovalsGatewayClient } from "openclaw/plugin-sdk/gateway-runtime";
export { isApprovalNotFoundError };
export async function resolveMatrixExecApproval(params: {
export async function resolveMatrixApproval(params: {
cfg: OpenClawConfig;
approvalId: string;
decision: ExecApprovalReplyDecision;
senderId?: string | null;
gatewayUrl?: string;
}): Promise<void> {
await withOperatorApprovalsGatewayClient(
{
config: params.cfg,
gatewayUrl: params.gatewayUrl,
clientDisplayName: `Matrix approval (${params.senderId?.trim() || "unknown"})`,
},
async (gatewayClient) => {
await gatewayClient.request("exec.approval.resolve", {
id: params.approvalId,
decision: params.decision,
});
},
);
await resolveApprovalOverGateway({
cfg: params.cfg,
approvalId: params.approvalId,
decision: params.decision,
senderId: params.senderId,
gatewayUrl: params.gatewayUrl,
clientDisplayName: `Matrix approval (${params.senderId?.trim() || "unknown"})`,
});
}
export const resolveMatrixExecApproval = resolveMatrixApproval;

View File

@@ -1,556 +0,0 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
clearMatrixApprovalReactionTargetsForTest,
resolveMatrixApprovalReactionTarget,
} from "./approval-reactions.js";
import { MatrixExecApprovalHandler } from "./exec-approvals-handler.js";
const baseRequest = {
id: "9f1c7d5d-b1fb-46ef-ac45-662723b65bb7",
request: {
command: "npm view diver name version description",
agentId: "main",
sessionKey: "agent:main:matrix:channel:!ops:example.org",
turnSourceChannel: "matrix",
turnSourceTo: "room:!ops:example.org",
turnSourceThreadId: "$thread",
turnSourceAccountId: "default",
},
createdAtMs: 1000,
expiresAtMs: 61_000,
};
function createHandler(cfg: OpenClawConfig, accountId = "default") {
const client = {} as never;
const sendMessage = vi
.fn()
.mockResolvedValueOnce({ messageId: "$m1", roomId: "!ops:example.org" })
.mockResolvedValue({ messageId: "$m2", roomId: "!dm-owner:example.org" });
const reactMessage = vi.fn().mockResolvedValue(undefined);
const editMessage = vi.fn().mockResolvedValue({ eventId: "$edit1" });
const deleteMessage = vi.fn().mockResolvedValue(undefined);
const repairDirectRooms = vi.fn().mockResolvedValue({
activeRoomId: "!dm-owner:example.org",
});
const handler = new MatrixExecApprovalHandler(
{
client,
accountId,
cfg,
},
{
nowMs: () => 1000,
sendMessage,
reactMessage,
editMessage,
deleteMessage,
repairDirectRooms,
},
);
return {
client,
handler,
sendMessage,
reactMessage,
editMessage,
deleteMessage,
repairDirectRooms,
};
}
afterEach(() => {
vi.useRealTimers();
clearMatrixApprovalReactionTargetsForTest();
});
describe("MatrixExecApprovalHandler", () => {
it("sends approval prompts to the originating matrix room when target=channel", async () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok",
execApprovals: {
enabled: true,
approvers: ["@owner:example.org"],
target: "channel",
},
},
},
} as OpenClawConfig;
const { handler, sendMessage } = createHandler(cfg);
await handler.handleRequested(baseRequest);
expect(sendMessage).toHaveBeenCalledWith(
"room:!ops:example.org",
expect.stringContaining("/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-once"),
expect.objectContaining({
accountId: "default",
threadId: "$thread",
}),
);
});
it("seeds emoji reactions for each allowed approval decision", async () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok",
execApprovals: {
enabled: true,
approvers: ["@owner:example.org"],
target: "channel",
},
},
},
} as OpenClawConfig;
const { handler, reactMessage, sendMessage } = createHandler(cfg);
await handler.handleRequested(baseRequest);
expect(sendMessage).toHaveBeenCalledWith(
"room:!ops:example.org",
expect.stringContaining("React here: ✅ Allow once, ♾️ Allow always, ❌ Deny"),
expect.anything(),
);
expect(reactMessage).toHaveBeenCalledTimes(3);
expect(reactMessage).toHaveBeenNthCalledWith(
1,
"!ops:example.org",
"$m1",
"✅",
expect.anything(),
);
expect(reactMessage).toHaveBeenNthCalledWith(
2,
"!ops:example.org",
"$m1",
"♾️",
expect.anything(),
);
expect(reactMessage).toHaveBeenNthCalledWith(
3,
"!ops:example.org",
"$m1",
"❌",
expect.anything(),
);
});
it("falls back to approver dms when channel routing is unavailable", async () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok",
execApprovals: {
enabled: true,
approvers: ["@owner:example.org"],
target: "channel",
},
},
},
} as OpenClawConfig;
const { client, handler, sendMessage, repairDirectRooms } = createHandler(cfg);
await handler.handleRequested({
...baseRequest,
request: {
...baseRequest.request,
turnSourceChannel: "slack",
turnSourceTo: "channel:C1",
turnSourceAccountId: null,
turnSourceThreadId: null,
},
});
expect(repairDirectRooms).toHaveBeenCalledWith({
client,
remoteUserId: "@owner:example.org",
encrypted: false,
});
expect(sendMessage).toHaveBeenCalledWith(
"room:!dm-owner:example.org",
expect.stringContaining("/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-once"),
expect.objectContaining({
accountId: "default",
}),
);
});
it("does not send foreign-channel approvals from unbound multi-account matrix configs", async () => {
const cfg = {
channels: {
matrix: {
accounts: {
default: {
homeserver: "https://matrix.example.org",
userId: "@bot-default:example.org",
accessToken: "tok-default",
execApprovals: {
enabled: true,
approvers: ["@owner:example.org"],
target: "channel",
},
},
ops: {
homeserver: "https://matrix.example.org",
userId: "@bot-ops:example.org",
accessToken: "tok-ops",
execApprovals: {
enabled: true,
approvers: ["@owner:example.org"],
target: "channel",
},
},
},
},
},
} as OpenClawConfig;
const defaultHandler = createHandler(cfg, "default");
const opsHandler = createHandler(cfg, "ops");
const request = {
...baseRequest,
request: {
...baseRequest.request,
sessionKey: "agent:main:missing",
turnSourceChannel: "slack",
turnSourceTo: "channel:C1",
turnSourceAccountId: null,
turnSourceThreadId: null,
},
};
await defaultHandler.handler.handleRequested(request);
await opsHandler.handler.handleRequested(request);
expect(defaultHandler.sendMessage).not.toHaveBeenCalled();
expect(opsHandler.sendMessage).not.toHaveBeenCalled();
expect(defaultHandler.repairDirectRooms).not.toHaveBeenCalled();
expect(opsHandler.repairDirectRooms).not.toHaveBeenCalled();
});
it("does not double-send when the origin room is the approver dm", async () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok",
execApprovals: {
enabled: true,
approvers: ["@owner:example.org"],
target: "dm",
},
},
},
} as OpenClawConfig;
const { handler, sendMessage } = createHandler(cfg);
await handler.handleRequested({
...baseRequest,
request: {
...baseRequest.request,
sessionKey: "agent:main:matrix:direct:!dm-owner:example.org",
turnSourceTo: "room:!dm-owner:example.org",
turnSourceThreadId: undefined,
},
});
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(sendMessage).toHaveBeenCalledWith(
"room:!dm-owner:example.org",
expect.stringContaining("/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-once"),
expect.objectContaining({
accountId: "default",
}),
);
});
it("edits tracked approval messages when resolved", async () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok",
execApprovals: {
enabled: true,
approvers: ["@owner:example.org"],
target: "both",
},
},
},
} as OpenClawConfig;
const { handler, editMessage } = createHandler(cfg);
await handler.handleRequested(baseRequest);
await handler.handleResolved({
id: baseRequest.id,
decision: "allow-once",
resolvedBy: "matrix:@owner:example.org",
ts: 2000,
});
expect(editMessage).toHaveBeenCalledWith(
"!ops:example.org",
"$m1",
expect.stringContaining("Exec approval: Allowed once"),
expect.objectContaining({
accountId: "default",
}),
);
});
it("anchors reactions on the first chunk and clears stale chunks on resolve", async () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok",
execApprovals: {
enabled: true,
approvers: ["@owner:example.org"],
target: "channel",
},
},
},
} as OpenClawConfig;
const { handler, sendMessage, reactMessage, editMessage, deleteMessage } = createHandler(cfg);
sendMessage.mockReset().mockResolvedValue({
messageId: "$m3",
primaryMessageId: "$m1",
messageIds: ["$m1", "$m2", "$m3"],
roomId: "!ops:example.org",
});
await handler.handleRequested(baseRequest);
await handler.handleResolved({
id: baseRequest.id,
decision: "allow-once",
resolvedBy: "matrix:@owner:example.org",
ts: 2000,
});
expect(reactMessage).toHaveBeenNthCalledWith(
1,
"!ops:example.org",
"$m1",
"✅",
expect.anything(),
);
expect(editMessage).toHaveBeenCalledWith(
"!ops:example.org",
"$m1",
expect.stringContaining("Exec approval: Allowed once"),
expect.anything(),
);
expect(deleteMessage).toHaveBeenCalledWith(
"!ops:example.org",
"$m2",
expect.objectContaining({ reason: "approval resolved" }),
);
expect(deleteMessage).toHaveBeenCalledWith(
"!ops:example.org",
"$m3",
expect.objectContaining({ reason: "approval resolved" }),
);
});
it("deletes tracked approval messages when they expire", async () => {
vi.useFakeTimers();
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok",
execApprovals: {
enabled: true,
approvers: ["@owner:example.org"],
target: "both",
},
},
},
} as OpenClawConfig;
const { handler, deleteMessage } = createHandler(cfg);
await handler.handleRequested(baseRequest);
await vi.advanceTimersByTimeAsync(60_000);
expect(deleteMessage).toHaveBeenCalledWith(
"!ops:example.org",
"$m1",
expect.objectContaining({
accountId: "default",
reason: "approval expired",
}),
);
});
it("deletes every chunk of a tracked approval prompt when it expires", async () => {
vi.useFakeTimers();
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok",
execApprovals: {
enabled: true,
approvers: ["@owner:example.org"],
target: "channel",
},
},
},
} as OpenClawConfig;
const { handler, sendMessage, deleteMessage } = createHandler(cfg);
sendMessage.mockReset().mockResolvedValue({
messageId: "$m3",
primaryMessageId: "$m1",
messageIds: ["$m1", "$m2", "$m3"],
roomId: "!ops:example.org",
});
await handler.handleRequested(baseRequest);
await vi.advanceTimersByTimeAsync(60_000);
expect(deleteMessage).toHaveBeenCalledWith(
"!ops:example.org",
"$m1",
expect.objectContaining({ reason: "approval expired" }),
);
expect(deleteMessage).toHaveBeenCalledWith(
"!ops:example.org",
"$m2",
expect.objectContaining({ reason: "approval expired" }),
);
expect(deleteMessage).toHaveBeenCalledWith(
"!ops:example.org",
"$m3",
expect.objectContaining({ reason: "approval expired" }),
);
});
it("clears tracked approval anchors when the handler stops", async () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok",
execApprovals: {
enabled: true,
approvers: ["@owner:example.org"],
target: "channel",
},
},
},
} as OpenClawConfig;
const { handler } = createHandler(cfg);
await handler.handleRequested(baseRequest);
await handler.stop();
expect(
resolveMatrixApprovalReactionTarget({
roomId: "!ops:example.org",
eventId: "$m1",
reactionKey: "✅",
}),
).toBeNull();
});
it("honors request decision constraints in pending approval text", async () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok",
execApprovals: {
enabled: true,
approvers: ["@owner:example.org"],
target: "channel",
},
},
},
} as OpenClawConfig;
const { handler, sendMessage, reactMessage } = createHandler(cfg);
await handler.handleRequested({
...baseRequest,
request: {
...baseRequest.request,
ask: "always",
allowedDecisions: ["allow-once", "deny"],
},
});
expect(sendMessage).toHaveBeenCalledWith(
"room:!ops:example.org",
expect.not.stringContaining("allow-always"),
expect.anything(),
);
expect(sendMessage).toHaveBeenCalledWith(
"room:!ops:example.org",
expect.stringContaining("React here: ✅ Allow once, ❌ Deny"),
expect.anything(),
);
expect(reactMessage).toHaveBeenCalledTimes(2);
expect(reactMessage).toHaveBeenNthCalledWith(
1,
"!ops:example.org",
"$m1",
"✅",
expect.anything(),
);
expect(reactMessage).toHaveBeenNthCalledWith(
2,
"!ops:example.org",
"$m1",
"❌",
expect.anything(),
);
});
it("keeps the reaction hint at the start of long approval prompts", async () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok",
execApprovals: {
enabled: true,
approvers: ["@owner:example.org"],
target: "channel",
},
},
},
} as OpenClawConfig;
const { handler, sendMessage } = createHandler(cfg);
await handler.handleRequested({
...baseRequest,
request: {
...baseRequest.request,
command: `printf '%s' "${"x".repeat(8_000)}"`,
},
});
const sentText = sendMessage.mock.calls[0]?.[1];
expect(typeof sentText).toBe("string");
expect(sentText).toContain("Pending command:");
expect(sentText).toMatch(
/^React here: ✅ Allow once, ♾️ Allow always, ❌ Deny\n\nApproval required\./,
);
});
});

View File

@@ -1,408 +0,0 @@
import {
buildExecApprovalPendingReplyPayload,
type ExecApprovalReplyDecision,
getExecApprovalApproverDmNoticeText,
resolveExecApprovalAllowedDecisions,
resolveExecApprovalCommandDisplay,
} from "openclaw/plugin-sdk/approval-reply-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
createChannelNativeApprovalRuntime,
type ExecApprovalChannelRuntime,
type ExecApprovalRequest,
type ExecApprovalResolved,
} from "openclaw/plugin-sdk/infra-runtime";
import { normalizeOptionalStringifiedId } from "openclaw/plugin-sdk/text-runtime";
import { matrixNativeApprovalAdapter } from "./approval-native.js";
import {
buildMatrixApprovalReactionHint,
listMatrixApprovalReactionBindings,
registerMatrixApprovalReactionTarget,
unregisterMatrixApprovalReactionTarget,
} from "./approval-reactions.js";
import {
isMatrixExecApprovalClientEnabled,
shouldHandleMatrixExecApprovalRequest,
} from "./exec-approvals.js";
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 { resolveMatrixTargetIdentity } from "./matrix/target-ids.js";
import type { CoreConfig } from "./types.js";
type ApprovalRequest = ExecApprovalRequest;
type ApprovalResolved = ExecApprovalResolved;
type PendingMessage = {
roomId: string;
messageIds: readonly string[];
reactionEventId: string;
};
type PreparedMatrixTarget = {
to: string;
roomId: string;
threadId?: string;
};
type PendingApprovalContent = {
approvalId: string;
text: string;
allowedDecisions: readonly ExecApprovalReplyDecision[];
};
type ReactionTargetRef = {
roomId: string;
eventId: string;
};
export type MatrixExecApprovalHandlerOpts = {
client: MatrixClient;
accountId: string;
cfg: OpenClawConfig;
gatewayUrl?: string;
};
export type MatrixExecApprovalHandlerDeps = {
nowMs?: () => number;
sendMessage?: typeof sendMessageMatrix;
reactMessage?: typeof reactMatrixMessage;
editMessage?: typeof editMatrixMessage;
deleteMessage?: typeof deleteMatrixMessage;
repairDirectRooms?: typeof repairMatrixDirectRooms;
};
function normalizePendingMessageIds(entry: PendingMessage): string[] {
return Array.from(new Set(entry.messageIds.map((messageId) => messageId.trim()).filter(Boolean)));
}
function normalizeReactionTargetRef(params: ReactionTargetRef): ReactionTargetRef | null {
const roomId = params.roomId.trim();
const eventId = params.eventId.trim();
if (!roomId || !eventId) {
return null;
}
return { roomId, eventId };
}
function buildReactionTargetRefKey(params: ReactionTargetRef): string | null {
const normalized = normalizeReactionTargetRef(params);
if (!normalized) {
return null;
}
return `${normalized.roomId}\u0000${normalized.eventId}`;
}
function isHandlerConfigured(params: { cfg: OpenClawConfig; accountId: string }): boolean {
return isMatrixExecApprovalClientEnabled(params);
}
function buildPendingApprovalContent(params: {
request: ApprovalRequest;
nowMs: number;
}): PendingApprovalContent {
const allowedDecisions =
params.request.request.allowedDecisions ??
resolveExecApprovalAllowedDecisions({ ask: params.request.request.ask ?? undefined });
const payload = buildExecApprovalPendingReplyPayload({
approvalId: params.request.id,
approvalSlug: params.request.id.slice(0, 8),
approvalCommandId: params.request.id,
ask: params.request.request.ask ?? undefined,
agentId: params.request.request.agentId ?? undefined,
allowedDecisions,
command: resolveExecApprovalCommandDisplay(params.request.request).commandText,
cwd: params.request.request.cwd ?? undefined,
host: params.request.request.host === "node" ? "node" : "gateway",
nodeId: params.request.request.nodeId ?? undefined,
sessionKey: params.request.request.sessionKey ?? undefined,
expiresAtMs: params.request.expiresAtMs,
nowMs: params.nowMs,
});
const hint = buildMatrixApprovalReactionHint(allowedDecisions);
const text = payload.text ?? "";
return {
approvalId: params.request.id,
// Reactions are anchored to the first Matrix event for a chunked send, so keep
// the reaction hint at the start of the message where that anchor always lives.
text: hint ? (text ? `${hint}\n\n${text}` : hint) : text,
allowedDecisions,
};
}
function buildResolvedApprovalText(params: {
request: ApprovalRequest;
resolved: ApprovalResolved;
}): string {
const command = resolveExecApprovalCommandDisplay(params.request.request).commandText;
const decisionLabel =
params.resolved.decision === "allow-once"
? "Allowed once"
: params.resolved.decision === "allow-always"
? "Allowed always"
: "Denied";
return [`Exec approval: ${decisionLabel}`, "", "Command", "```", command, "```"].join("\n");
}
export class MatrixExecApprovalHandler {
private readonly runtime: ExecApprovalChannelRuntime;
private readonly trackedReactionTargets = new Map<string, ReactionTargetRef>();
private readonly nowMs: () => number;
private readonly sendMessage: typeof sendMessageMatrix;
private readonly reactMessage: typeof reactMatrixMessage;
private readonly editMessage: typeof editMatrixMessage;
private readonly deleteMessage: typeof deleteMatrixMessage;
private readonly repairDirectRooms: typeof repairMatrixDirectRooms;
constructor(
private readonly opts: MatrixExecApprovalHandlerOpts,
deps: MatrixExecApprovalHandlerDeps = {},
) {
this.nowMs = deps.nowMs ?? Date.now;
this.sendMessage = deps.sendMessage ?? sendMessageMatrix;
this.reactMessage = deps.reactMessage ?? reactMatrixMessage;
this.editMessage = deps.editMessage ?? editMatrixMessage;
this.deleteMessage = deps.deleteMessage ?? deleteMatrixMessage;
this.repairDirectRooms = deps.repairDirectRooms ?? repairMatrixDirectRooms;
this.runtime = createChannelNativeApprovalRuntime<
PendingMessage,
PreparedMatrixTarget,
PendingApprovalContent,
ApprovalRequest,
ApprovalResolved
>({
label: "matrix/exec-approvals",
clientDisplayName: `Matrix Exec Approvals (${this.opts.accountId})`,
cfg: this.opts.cfg,
accountId: this.opts.accountId,
gatewayUrl: this.opts.gatewayUrl,
eventKinds: ["exec"],
nowMs: this.nowMs,
nativeAdapter: matrixNativeApprovalAdapter.native,
isConfigured: () =>
isHandlerConfigured({ cfg: this.opts.cfg, accountId: this.opts.accountId }),
shouldHandle: (request) =>
shouldHandleMatrixExecApprovalRequest({
cfg: this.opts.cfg,
accountId: this.opts.accountId,
request,
}),
buildPendingContent: ({ request, nowMs }) =>
buildPendingApprovalContent({
request,
nowMs,
}),
sendOriginNotice: async ({ originTarget }) => {
const preparedTarget = await this.prepareTarget(originTarget);
if (!preparedTarget) {
return;
}
await this.sendMessage(preparedTarget.to, getExecApprovalApproverDmNoticeText(), {
cfg: this.opts.cfg as CoreConfig,
accountId: this.opts.accountId,
client: this.opts.client,
threadId: preparedTarget.threadId,
});
},
prepareTarget: async ({ plannedTarget }) => {
const preparedTarget = await this.prepareTarget(plannedTarget.target);
if (!preparedTarget) {
return null;
}
return {
dedupeKey: `${preparedTarget.roomId}:${preparedTarget.threadId ?? ""}`,
target: preparedTarget,
};
},
deliverTarget: async ({ preparedTarget, pendingContent }) => {
const result = await this.sendMessage(preparedTarget.to, pendingContent.text, {
cfg: this.opts.cfg as CoreConfig,
accountId: this.opts.accountId,
client: this.opts.client,
threadId: preparedTarget.threadId,
});
const messageIds = Array.from(
new Set(
(result.messageIds ?? [result.messageId])
.map((messageId) => messageId.trim())
.filter(Boolean),
),
);
const reactionEventId =
result.primaryMessageId?.trim() || messageIds[0] || result.messageId.trim();
this.trackReactionTarget({
roomId: result.roomId,
eventId: reactionEventId,
approvalId: pendingContent.approvalId,
allowedDecisions: pendingContent.allowedDecisions,
});
await Promise.allSettled(
listMatrixApprovalReactionBindings(pendingContent.allowedDecisions).map(
async ({ emoji }) => {
await this.reactMessage(result.roomId, reactionEventId, emoji, {
cfg: this.opts.cfg as CoreConfig,
accountId: this.opts.accountId,
client: this.opts.client,
});
},
),
);
return {
roomId: result.roomId,
messageIds,
reactionEventId,
};
},
finalizeResolved: async ({ request, resolved, entries }) => {
await this.finalizeResolved(request, resolved, entries);
},
finalizeExpired: async ({ entries }) => {
await this.clearPending(entries);
},
});
}
async start(): Promise<void> {
await this.runtime.start();
}
async stop(): Promise<void> {
await this.runtime.stop();
this.clearTrackedReactionTargets();
}
async handleRequested(request: ApprovalRequest): Promise<void> {
await this.runtime.handleRequested(request);
}
async handleResolved(resolved: ApprovalResolved): Promise<void> {
await this.runtime.handleResolved(resolved);
}
private async prepareTarget(rawTarget: {
to: string;
threadId?: string | number | null;
}): Promise<PreparedMatrixTarget | null> {
const target = resolveMatrixTargetIdentity(rawTarget.to);
if (!target) {
return null;
}
const threadId = normalizeOptionalStringifiedId(rawTarget.threadId);
if (target.kind === "user") {
const account = resolveMatrixAccount({
cfg: this.opts.cfg as CoreConfig,
accountId: this.opts.accountId,
});
const repaired = await this.repairDirectRooms({
client: this.opts.client,
remoteUserId: target.id,
encrypted: account.config.encryption === true,
});
if (!repaired.activeRoomId) {
return null;
}
return {
to: `room:${repaired.activeRoomId}`,
roomId: repaired.activeRoomId,
threadId,
};
}
return {
to: `room:${target.id}`,
roomId: target.id,
threadId,
};
}
private async finalizeResolved(
request: ApprovalRequest,
resolved: ApprovalResolved,
entries: PendingMessage[],
): Promise<void> {
const text = buildResolvedApprovalText({ request, resolved });
await Promise.allSettled(
entries.map(async (entry) => {
this.untrackReactionTarget({
roomId: entry.roomId,
eventId: entry.reactionEventId,
});
const [primaryMessageId, ...staleMessageIds] = normalizePendingMessageIds(entry);
if (!primaryMessageId) {
return;
}
await Promise.allSettled([
this.editMessage(entry.roomId, primaryMessageId, text, {
cfg: this.opts.cfg as CoreConfig,
accountId: this.opts.accountId,
client: this.opts.client,
}),
...staleMessageIds.map(async (messageId) => {
await this.deleteMessage(entry.roomId, messageId, {
cfg: this.opts.cfg as CoreConfig,
accountId: this.opts.accountId,
client: this.opts.client,
reason: "approval resolved",
});
}),
]);
}),
);
}
private async clearPending(entries: PendingMessage[]): Promise<void> {
await Promise.allSettled(
entries.map(async (entry) => {
this.untrackReactionTarget({
roomId: entry.roomId,
eventId: entry.reactionEventId,
});
await Promise.allSettled(
normalizePendingMessageIds(entry).map(async (messageId) => {
await this.deleteMessage(entry.roomId, messageId, {
cfg: this.opts.cfg as CoreConfig,
accountId: this.opts.accountId,
client: this.opts.client,
reason: "approval expired",
});
}),
);
}),
);
}
private trackReactionTarget(
params: ReactionTargetRef & {
approvalId: string;
allowedDecisions: readonly ExecApprovalReplyDecision[];
},
): void {
const normalized = normalizeReactionTargetRef(params);
const key = normalized ? buildReactionTargetRefKey(normalized) : null;
if (!normalized || !key) {
return;
}
registerMatrixApprovalReactionTarget({
roomId: normalized.roomId,
eventId: normalized.eventId,
approvalId: params.approvalId,
allowedDecisions: params.allowedDecisions,
});
this.trackedReactionTargets.set(key, normalized);
}
private untrackReactionTarget(params: ReactionTargetRef): void {
const normalized = normalizeReactionTargetRef(params);
const key = normalized ? buildReactionTargetRefKey(normalized) : null;
if (!normalized || !key) {
return;
}
unregisterMatrixApprovalReactionTarget(normalized);
this.trackedReactionTargets.delete(key);
}
private clearTrackedReactionTargets(): void {
for (const target of this.trackedReactionTargets.values()) {
unregisterMatrixApprovalReactionTarget(target);
}
this.trackedReactionTargets.clear();
}
}

View File

@@ -202,7 +202,7 @@ describe("matrix exec approvals", () => {
).toBe(true);
});
it("does not suppress local prompts for plugin approval payloads", () => {
it("suppresses local prompts for plugin approval payloads when DM approvers are configured", () => {
const payload = {
channelData: {
execApproval: {
@@ -215,10 +215,13 @@ describe("matrix exec approvals", () => {
expect(
shouldSuppressLocalMatrixExecApprovalPrompt({
cfg: buildConfig({ enabled: true, approvers: ["@owner:example.org"] }),
cfg: buildConfig(
{ enabled: true, approvers: ["@owner:example.org"] },
{ dm: { allowFrom: ["@owner:example.org"] } },
),
payload,
}),
).toBe(false);
).toBe(true);
});
it("normalizes prefixed approver ids", () => {

View File

@@ -12,15 +12,15 @@ import type { ExecApprovalRequest, PluginApprovalRequest } from "openclaw/plugin
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { getMatrixApprovalAuthApprovers } from "./approval-auth.js";
import { normalizeMatrixApproverId } from "./approval-ids.js";
import { listMatrixAccountIds, resolveMatrixAccount } from "./matrix/accounts.js";
import { normalizeMatrixUserId } from "./matrix/monitor/allowlist.js";
import type { CoreConfig } from "./types.js";
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
type ApprovalKind = "exec" | "plugin";
export function normalizeMatrixApproverId(value: string | number): string | undefined {
const normalized = normalizeMatrixUserId(String(value));
return normalized || undefined;
}
export { normalizeMatrixApproverId };
function normalizeMatrixExecApproverId(value: string | number): string | undefined {
const normalized = normalizeMatrixApproverId(value);
@@ -45,6 +45,7 @@ function resolveMatrixExecApprovalConfig(params: {
function countMatrixExecApprovalEligibleAccounts(params: {
cfg: OpenClawConfig;
request: ApprovalRequest;
approvalKind: ApprovalKind;
}): number {
return listMatrixAccountIds(params.cfg).filter((accountId) => {
const account = resolveMatrixAccount({ cfg: params.cfg, accountId });
@@ -67,7 +68,11 @@ function countMatrixExecApprovalEligibleAccounts(params: {
return (
isChannelExecApprovalClientEnabledFromConfig({
enabled: config?.enabled,
approverCount: getMatrixExecApprovalApprovers({ cfg: params.cfg, accountId }).length,
approverCount: getMatrixApprovalApprovers({
cfg: params.cfg,
accountId,
approvalKind: params.approvalKind,
}).length,
}) &&
matchesApprovalRequestFilters({
request: params.request.request,
@@ -82,6 +87,7 @@ function matchesMatrixRequestAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
request: ApprovalRequest;
approvalKind: ApprovalKind;
}): boolean {
const turnSourceChannel = normalizeLowercaseStringOrEmpty(
params.request.request.turnSourceChannel,
@@ -96,6 +102,7 @@ function matchesMatrixRequestAccount(params: {
countMatrixExecApprovalEligibleAccounts({
cfg: params.cfg,
request: params.request,
approvalKind: params.approvalKind,
}) <= 1
);
}
@@ -118,6 +125,24 @@ export function getMatrixExecApprovalApprovers(params: {
});
}
function resolveMatrixApprovalKind(request: ApprovalRequest): ApprovalKind {
return request.id.startsWith("plugin:") ? "plugin" : "exec";
}
export function getMatrixApprovalApprovers(params: {
cfg: OpenClawConfig;
accountId?: string | null;
approvalKind: ApprovalKind;
}): string[] {
if (params.approvalKind === "plugin") {
return getMatrixApprovalAuthApprovers({
cfg: params.cfg as CoreConfig,
accountId: params.accountId,
});
}
return getMatrixExecApprovalApprovers(params);
}
export function isMatrixExecApprovalTargetRecipient(params: {
cfg: OpenClawConfig;
senderId?: string | null;
@@ -137,7 +162,11 @@ const matrixExecApprovalProfile = createChannelExecApprovalProfile({
resolveApprovers: getMatrixExecApprovalApprovers,
normalizeSenderId: normalizeMatrixApproverId,
isTargetRecipient: isMatrixExecApprovalTargetRecipient,
matchesRequestAccount: matchesMatrixRequestAccount,
matchesRequestAccount: (params) =>
matchesMatrixRequestAccount({
...params,
approvalKind: "exec",
}),
});
export const isMatrixExecApprovalClientEnabled = matrixExecApprovalProfile.isClientEnabled;
@@ -146,9 +175,86 @@ export const isMatrixExecApprovalAuthorizedSender = matrixExecApprovalProfile.is
export const resolveMatrixExecApprovalTarget = matrixExecApprovalProfile.resolveTarget;
export const shouldHandleMatrixExecApprovalRequest = matrixExecApprovalProfile.shouldHandleRequest;
export function isMatrixApprovalClientEnabled(params: {
cfg: OpenClawConfig;
accountId?: string | null;
approvalKind: ApprovalKind;
}): boolean {
if (params.approvalKind === "exec") {
return isMatrixExecApprovalClientEnabled(params);
}
const config = resolveMatrixExecApprovalConfig(params);
return isChannelExecApprovalClientEnabledFromConfig({
enabled: config?.enabled,
approverCount: getMatrixApprovalApprovers(params).length,
});
}
export function isMatrixAnyApprovalClientEnabled(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): boolean {
return (
isMatrixApprovalClientEnabled({
...params,
approvalKind: "exec",
}) ||
isMatrixApprovalClientEnabled({
...params,
approvalKind: "plugin",
})
);
}
export function shouldHandleMatrixApprovalRequest(params: {
cfg: OpenClawConfig;
accountId?: string | null;
request: ApprovalRequest;
}): boolean {
const approvalKind = resolveMatrixApprovalKind(params.request);
if (
!matchesMatrixRequestAccount({
...params,
approvalKind,
})
) {
return false;
}
const config = resolveMatrixExecApprovalConfig(params);
if (
!isChannelExecApprovalClientEnabledFromConfig({
enabled: config?.enabled,
approverCount: getMatrixApprovalApprovers({
...params,
approvalKind,
}).length,
})
) {
return false;
}
return matchesApprovalRequestFilters({
request: params.request.request,
agentFilter: config?.agentFilter,
sessionFilter: config?.sessionFilter,
});
}
function buildFilterCheckRequest(params: {
metadata: NonNullable<ReturnType<typeof getExecApprovalReplyMetadata>>;
}): ExecApprovalRequest {
}): ApprovalRequest {
if (params.metadata.approvalKind === "plugin") {
return {
id: params.metadata.approvalId,
request: {
title: "Plugin Approval Required",
description: "",
agentId: params.metadata.agentId ?? null,
sessionKey: params.metadata.sessionKey ?? null,
},
createdAtMs: 0,
expiresAtMs: 0,
};
}
return {
id: params.metadata.approvalId,
request: {
@@ -173,13 +279,10 @@ export function shouldSuppressLocalMatrixExecApprovalPrompt(params: {
if (!metadata) {
return false;
}
if (metadata.approvalKind !== "exec") {
return false;
}
const request = buildFilterCheckRequest({
metadata,
});
return shouldHandleMatrixExecApprovalRequest({
return shouldHandleMatrixApprovalRequest({
cfg: params.cfg,
accountId: params.accountId,
request,

View File

@@ -1,5 +1,6 @@
import { format } from "node:util";
import { MatrixExecApprovalHandler } from "../../exec-approvals-handler.js";
import { CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY } from "openclaw/plugin-sdk/approval-handler-runtime";
import { registerChannelRuntimeContext } from "openclaw/plugin-sdk/channel-runtime-context";
import {
GROUP_POLICY_BLOCKED_LABEL,
resolveThreadBindingIdleTimeoutMsForChannel,
@@ -35,6 +36,7 @@ import { runMatrixStartupMaintenance } from "./startup.js";
export type MonitorMatrixOpts = {
runtime?: RuntimeEnv;
channelRuntime?: import("openclaw/plugin-sdk/channel-core").PluginRuntime["channel"];
abortSignal?: AbortSignal;
mediaMaxMb?: number;
initialSyncLimit?: number;
@@ -147,7 +149,6 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
setActiveMatrixClient(client, auth.accountId);
let cleanedUp = false;
let threadBindingManager: { accountId: string; stop: () => void } | null = null;
let execApprovalsHandler: MatrixExecApprovalHandler | null = null;
const inboundDeduper = await createMatrixInboundEventDeduper({
auth,
env: process.env,
@@ -167,7 +168,6 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
client.stopSyncWithoutPersist();
await client.drainPendingDecryptions("matrix monitor shutdown");
await waitForInFlightRoomMessages();
await execApprovalsHandler?.stop();
threadBindingManager?.stop();
await inboundDeduper.stop();
await releaseSharedClientInstance(client, "persist");
@@ -360,12 +360,16 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
logVerboseMessage(`matrix: failed to backfill deviceId after startup (${String(err)})`);
});
execApprovalsHandler = new MatrixExecApprovalHandler({
client,
registerChannelRuntimeContext({
channelRuntime: opts.channelRuntime,
channelId: "matrix",
accountId: effectiveAccountId,
cfg,
capability: CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY,
context: {
client,
},
abortSignal: opts.abortSignal,
});
await execApprovalsHandler.start();
await runMatrixStartupMaintenance({
client,

View File

@@ -7,16 +7,16 @@ import {
import type { CoreConfig } from "../../types.js";
import { handleInboundMatrixReaction } from "./reaction-events.js";
const resolveMatrixExecApproval = vi.fn();
const resolveMatrixApproval = vi.fn();
vi.mock("../../exec-approval-resolver.js", () => ({
isApprovalNotFoundError: (err: unknown) =>
err instanceof Error && /unknown or expired approval id/i.test(err.message),
resolveMatrixExecApproval: (...args: unknown[]) => resolveMatrixExecApproval(...args),
resolveMatrixApproval: (...args: unknown[]) => resolveMatrixApproval(...args),
}));
beforeEach(() => {
resolveMatrixExecApproval.mockReset();
resolveMatrixApproval.mockReset();
clearMatrixApprovalReactionTargetsForTest();
});
@@ -97,7 +97,7 @@ describe("matrix approval reactions", () => {
logVerboseMessage: vi.fn(),
});
expect(resolveMatrixExecApproval).toHaveBeenCalledWith({
expect(resolveMatrixApproval).toHaveBeenCalledWith({
cfg: buildConfig(),
approvalId: "req-123",
decision: "allow-once",
@@ -142,7 +142,7 @@ describe("matrix approval reactions", () => {
logVerboseMessage: vi.fn(),
});
expect(resolveMatrixExecApproval).not.toHaveBeenCalled();
expect(resolveMatrixApproval).not.toHaveBeenCalled();
expect(core.system.enqueueSystemEvent).toHaveBeenCalledWith(
"Matrix reaction added: 👍 by Owner on msg $msg-1",
expect.objectContaining({
@@ -197,7 +197,7 @@ describe("matrix approval reactions", () => {
logVerboseMessage: vi.fn(),
});
expect(resolveMatrixExecApproval).toHaveBeenCalledWith({
expect(resolveMatrixApproval).toHaveBeenCalledWith({
cfg,
approvalId: "req-123",
decision: "deny",
@@ -243,7 +243,7 @@ describe("matrix approval reactions", () => {
});
expect(client.getEvent).not.toHaveBeenCalled();
expect(resolveMatrixExecApproval).toHaveBeenCalledWith({
expect(resolveMatrixApproval).toHaveBeenCalledWith({
cfg: buildConfig(),
approvalId: "req-123",
decision: "allow-once",
@@ -252,9 +252,61 @@ describe("matrix approval reactions", () => {
expect(core.system.enqueueSystemEvent).not.toHaveBeenCalled();
});
it("resolves plugin approval reactions through the same Matrix reaction path", async () => {
const core = buildCore();
const cfg = buildConfig();
const matrixCfg = cfg.channels?.matrix;
if (!matrixCfg) {
throw new Error("matrix config missing");
}
matrixCfg.dm = { allowFrom: ["@owner:example.org"] };
registerMatrixApprovalReactionTarget({
roomId: "!ops:example.org",
eventId: "$plugin-approval-msg",
approvalId: "plugin:req-123",
allowedDecisions: ["allow-once", "deny"],
});
const client = {
getEvent: vi.fn(),
} as unknown as Parameters<typeof handleInboundMatrixReaction>[0]["client"];
await handleInboundMatrixReaction({
client,
core,
cfg,
accountId: "default",
roomId: "!ops:example.org",
event: {
event_id: "$reaction-1",
origin_server_ts: 123,
content: {
"m.relates_to": {
rel_type: "m.annotation",
event_id: "$plugin-approval-msg",
key: "✅",
},
},
} as never,
senderId: "@owner:example.org",
senderLabel: "Owner",
selfUserId: "@bot:example.org",
isDirectMessage: false,
logVerboseMessage: vi.fn(),
});
expect(client.getEvent).not.toHaveBeenCalled();
expect(resolveMatrixApproval).toHaveBeenCalledWith({
cfg,
approvalId: "plugin:req-123",
decision: "allow-once",
senderId: "@owner:example.org",
});
expect(core.system.enqueueSystemEvent).not.toHaveBeenCalled();
});
it("unregisters stale approval anchors after not-found resolution", async () => {
const core = buildCore();
resolveMatrixExecApproval.mockRejectedValueOnce(
resolveMatrixApproval.mockRejectedValueOnce(
new Error("unknown or expired approval id req-123"),
);
registerMatrixApprovalReactionTarget({
@@ -338,7 +390,7 @@ describe("matrix approval reactions", () => {
});
expect(client.getEvent).not.toHaveBeenCalled();
expect(resolveMatrixExecApproval).not.toHaveBeenCalled();
expect(resolveMatrixApproval).not.toHaveBeenCalled();
expect(core.system.enqueueSystemEvent).not.toHaveBeenCalled();
});
});

View File

@@ -1,13 +1,10 @@
import { getSessionBindingService } from "openclaw/plugin-sdk/conversation-runtime";
import { matrixApprovalCapability } from "../../approval-native.js";
import {
resolveMatrixApprovalReactionTarget,
unregisterMatrixApprovalReactionTarget,
} from "../../approval-reactions.js";
import {
isApprovalNotFoundError,
resolveMatrixExecApproval,
} from "../../exec-approval-resolver.js";
import { isMatrixExecApprovalAuthorizedSender } from "../../exec-approvals.js";
import { isApprovalNotFoundError, resolveMatrixApproval } from "../../exec-approval-resolver.js";
import type { CoreConfig } from "../../types.js";
import { resolveMatrixAccountConfig } from "../account-config.js";
import { extractMatrixReactionAnnotation } from "../reaction-common.js";
@@ -44,16 +41,18 @@ async function maybeResolveMatrixApprovalReaction(params: {
return false;
}
if (
!isMatrixExecApprovalAuthorizedSender({
!matrixApprovalCapability.authorizeActorAction?.({
cfg: params.cfg,
accountId: params.accountId,
senderId: params.senderId,
})
action: "approve",
approvalKind: params.target.approvalId.startsWith("plugin:") ? "plugin" : "exec",
})?.authorized
) {
return false;
}
try {
await resolveMatrixExecApproval({
await resolveMatrixApproval({
cfg: params.cfg,
approvalId: params.target.approvalId,
decision: params.target.decision,