mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 17:10:20 +00:00
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:
committed by
GitHub
parent
4108901932
commit
d78512b09d
@@ -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";
|
||||
|
||||
|
||||
46
extensions/matrix/src/approval-handler.runtime.test.ts
Normal file
46
extensions/matrix/src/approval-handler.runtime.test.ts
Normal 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"),
|
||||
});
|
||||
});
|
||||
});
|
||||
393
extensions/matrix/src/approval-handler.runtime.ts
Normal file
393
extensions/matrix/src/approval-handler.runtime.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
});
|
||||
6
extensions/matrix/src/approval-ids.ts
Normal file
6
extensions/matrix/src/approval-ids.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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\./,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user