Add Matrix native exec approvals

This commit is contained in:
Gustavo Madeira Santana
2026-03-31 20:54:22 -04:00
parent aa064c824d
commit 7d267b045d
13 changed files with 1184 additions and 14 deletions

View File

@@ -625,6 +625,36 @@ If an unapproved Matrix user keeps messaging you before approval, OpenClaw reuse
See [Pairing](/channels/pairing) for the shared DM pairing flow and storage layout.
## Exec approvals
Matrix can act as an exec approval client for a Matrix account.
- `channels.matrix.execApprovals.enabled`
- `channels.matrix.execApprovals.approvers` (optional; falls back to `channels.matrix.dm.allowFrom`)
- `channels.matrix.execApprovals.target` (`dm` | `channel` | `both`, default: `dm`)
- `channels.matrix.execApprovals.agentFilter`
- `channels.matrix.execApprovals.sessionFilter`
Matrix becomes an exec approval client when `enabled` is true and at least one approver can be resolved. Approvers must be Matrix user IDs such as `@owner:example.org`.
Delivery rules:
- `target: "dm"` sends approval prompts to approver DMs
- `target: "channel"` sends the prompt back to the originating Matrix room or DM
- `target: "both"` sends to approver DMs and the originating Matrix room or DM
Matrix uses text approval prompts today. Approvers resolve them with `/approve <id> allow-once`, `/approve <id> allow-always`, or `/approve <id> deny`.
Only resolved approvers can approve or deny. Channel delivery includes the command text, so only enable `channel` or `both` in trusted rooms.
Matrix approval prompts reuse the shared core approval planner. The Matrix-specific surface is transport only: room/DM routing and message send/update/delete behavior.
Per-account override:
- `channels.matrix.accounts.<account>.execApprovals`
Related docs: [Exec approvals](/tools/exec-approvals)
## Multi-account example
```json5
@@ -765,6 +795,9 @@ Live directory lookup uses the logged-in Matrix account:
- `dm`: DM policy block (`enabled`, `policy`, `allowFrom`, `threadReplies`).
- `dm.allowFrom` entries should be full Matrix user IDs unless you already resolved them through live directory lookup.
- `dm.threadReplies`: DM-only thread policy override (`off`, `inbound`, `always`). It overrides the top-level `threadReplies` setting for both reply placement and session isolation in DMs.
- `execApprovals`: Matrix-native exec approval delivery (`enabled`, `approvers`, `target`, `agentFilter`, `sessionFilter`).
- `execApprovals.approvers`: Matrix user IDs allowed to approve exec requests. Optional when `dm.allowFrom` already identifies the approvers.
- `execApprovals.target`: `dm | channel | both` (default: `dm`).
- `accounts`: named per-account overrides. Top-level `channels.matrix` values act as defaults for these entries.
- `groups`: per-room policy map. Prefer room IDs or aliases; unresolved room names are ignored at runtime. Session/group identity uses the stable room ID after resolution, while human-readable labels still come from room names.
- `rooms`: legacy alias for `groups`.

View File

@@ -2,23 +2,24 @@ import {
createResolvedApproverActionAuthAdapter,
resolveApprovalApprovers,
} from "openclaw/plugin-sdk/approval-runtime";
import { normalizeMatrixApproverId } from "./exec-approvals.js";
import { resolveMatrixAccount } from "./matrix/accounts.js";
import { normalizeMatrixUserId } from "./matrix/monitor/allowlist.js";
import type { CoreConfig } from "./types.js";
function normalizeMatrixApproverId(value: string | number): string | undefined {
const normalized = normalizeMatrixUserId(String(value));
return normalized || undefined;
export function getMatrixApprovalAuthApprovers(params: {
cfg: CoreConfig;
accountId?: string | null;
}): string[] {
const account = resolveMatrixAccount(params);
return resolveApprovalApprovers({
allowFrom: account.config.dm?.allowFrom,
normalizeApprover: normalizeMatrixApproverId,
});
}
export const matrixApprovalAuth = createResolvedApproverActionAuthAdapter({
channelLabel: "Matrix",
resolveApprovers: ({ cfg, accountId }) => {
const account = resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId });
return resolveApprovalApprovers({
allowFrom: account.config.dm?.allowFrom,
normalizeApprover: normalizeMatrixApproverId,
});
},
resolveApprovers: ({ cfg, accountId }) =>
getMatrixApprovalAuthApprovers({ cfg: cfg as CoreConfig, accountId }),
normalizeSenderId: (value) => normalizeMatrixApproverId(value),
});

View File

@@ -0,0 +1,141 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { describe, expect, it } from "vitest";
import { matrixApprovalCapability, matrixNativeApprovalAdapter } from "./approval-native.js";
function buildConfig(
overrides?: Partial<NonNullable<NonNullable<OpenClawConfig["channels"]>["matrix"]>>,
): OpenClawConfig {
return {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok",
execApprovals: {
enabled: true,
approvers: ["@owner:example.org"],
target: "both",
},
...overrides,
},
},
} as OpenClawConfig;
}
describe("matrix native approval adapter", () => {
it("describes native matrix approval delivery capabilities", () => {
const capabilities = matrixNativeApprovalAdapter.native?.describeDeliveryCapabilities({
cfg: buildConfig(),
accountId: "default",
approvalKind: "exec",
request: {
id: "req-1",
request: {
command: "echo hi",
turnSourceChannel: "matrix",
turnSourceTo: "room:!ops:example.org",
turnSourceAccountId: "default",
sessionKey: "agent:main:matrix:channel:!ops:example.org",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
});
expect(capabilities).toEqual({
enabled: true,
preferredSurface: "both",
supportsOriginSurface: true,
supportsApproverDmSurface: true,
notifyOriginWhenDmOnly: false,
});
});
it("resolves origin targets from matrix turn source", async () => {
const target = await matrixNativeApprovalAdapter.native?.resolveOriginTarget?.({
cfg: buildConfig(),
accountId: "default",
approvalKind: "exec",
request: {
id: "req-1",
request: {
command: "echo hi",
turnSourceChannel: "matrix",
turnSourceTo: "room:!ops:example.org",
turnSourceThreadId: "$thread",
turnSourceAccountId: "default",
sessionKey: "agent:main:matrix:channel:!ops:example.org",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
});
expect(target).toEqual({
to: "room:!ops:example.org",
threadId: "$thread",
});
});
it("resolves approver dm targets", async () => {
const targets = await matrixNativeApprovalAdapter.native?.resolveApproverDmTargets?.({
cfg: buildConfig(),
accountId: "default",
approvalKind: "exec",
request: {
id: "req-1",
request: {
command: "echo hi",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
});
expect(targets).toEqual([{ to: "user:@owner:example.org" }]);
});
it("keeps plugin approval auth independent from exec approvers", () => {
const cfg = buildConfig({
dm: { allowFrom: ["@owner:example.org"] },
execApprovals: {
enabled: true,
approvers: ["@exec:example.org"],
target: "both",
},
});
expect(
matrixApprovalCapability.authorizeActorAction?.({
cfg,
accountId: "default",
senderId: "@owner:example.org",
action: "approve",
approvalKind: "plugin",
}),
).toEqual({ authorized: true });
expect(
matrixApprovalCapability.authorizeActorAction?.({
cfg,
accountId: "default",
senderId: "@exec:example.org",
action: "approve",
approvalKind: "plugin",
}),
).toEqual({
authorized: false,
reason: "❌ You are not authorized to approve plugin requests on Matrix.",
});
expect(
matrixApprovalCapability.authorizeActorAction?.({
cfg,
accountId: "default",
senderId: "@exec:example.org",
action: "approve",
approvalKind: "exec",
}),
).toEqual({ authorized: true });
});
});

View File

@@ -0,0 +1,156 @@
import {
createChannelApprovalCapability,
createChannelApproverDmTargetResolver,
createChannelNativeOriginTargetResolver,
createApproverRestrictedNativeApprovalCapability,
splitChannelApprovalCapability,
} from "openclaw/plugin-sdk/approval-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { ExecApprovalRequest, PluginApprovalRequest } from "openclaw/plugin-sdk/infra-runtime";
import { getMatrixApprovalAuthApprovers, matrixApprovalAuth } from "./approval-auth.js";
import {
getMatrixExecApprovalApprovers,
isMatrixExecApprovalApprover,
isMatrixExecApprovalAuthorizedSender,
isMatrixExecApprovalClientEnabled,
resolveMatrixExecApprovalTarget,
shouldHandleMatrixExecApprovalRequest,
} from "./exec-approvals.js";
import { listMatrixAccountIds } from "./matrix/accounts.js";
import { normalizeMatrixUserId } from "./matrix/monitor/allowlist.js";
import { resolveMatrixTargetIdentity } from "./matrix/target-ids.js";
import type { CoreConfig } from "./types.js";
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
type MatrixOriginTarget = { to: string; threadId?: string };
function normalizeComparableTarget(value: string): string {
const target = resolveMatrixTargetIdentity(value);
if (!target) {
return value.trim().toLowerCase();
}
return `${target.kind}:${target.id}`.toLowerCase();
}
function resolveMatrixNativeTarget(raw: string): string | null {
const target = resolveMatrixTargetIdentity(raw);
if (!target) {
return null;
}
return target.kind === "user" ? `user:${target.id}` : `room:${target.id}`;
}
function normalizeThreadId(value?: string | number | null): string | undefined {
const trimmed = value == null ? "" : String(value).trim();
return trimmed || undefined;
}
function resolveTurnSourceMatrixOriginTarget(request: ApprovalRequest): MatrixOriginTarget | null {
const turnSourceChannel = request.request.turnSourceChannel?.trim().toLowerCase() || "";
const turnSourceTo = request.request.turnSourceTo?.trim() || "";
const target = resolveMatrixNativeTarget(turnSourceTo);
if (turnSourceChannel !== "matrix" || !target) {
return null;
}
return {
to: target,
threadId: normalizeThreadId(request.request.turnSourceThreadId),
};
}
function resolveSessionMatrixOriginTarget(sessionTarget: {
to: string;
threadId?: string | number | null;
}): MatrixOriginTarget | null {
const target = resolveMatrixNativeTarget(sessionTarget.to);
if (!target) {
return null;
}
return {
to: target,
threadId: normalizeThreadId(sessionTarget.threadId),
};
}
function matrixTargetsMatch(a: MatrixOriginTarget, b: MatrixOriginTarget): boolean {
return (
normalizeComparableTarget(a.to) === normalizeComparableTarget(b.to) &&
(a.threadId ?? "") === (b.threadId ?? "")
);
}
const resolveMatrixOriginTarget = createChannelNativeOriginTargetResolver({
channel: "matrix",
shouldHandleRequest: ({ cfg, accountId, request }) =>
shouldHandleMatrixExecApprovalRequest({
cfg,
accountId,
request,
}),
resolveTurnSourceTarget: resolveTurnSourceMatrixOriginTarget,
resolveSessionTarget: resolveSessionMatrixOriginTarget,
targetsMatch: matrixTargetsMatch,
});
const resolveMatrixApproverDmTargets = createChannelApproverDmTargetResolver({
shouldHandleRequest: ({ cfg, accountId, request }) =>
shouldHandleMatrixExecApprovalRequest({
cfg,
accountId,
request,
}),
resolveApprovers: getMatrixExecApprovalApprovers,
mapApprover: (approver) => {
const normalized = normalizeMatrixUserId(approver);
return normalized ? { to: `user:${normalized}` } : null;
},
});
const matrixNativeApprovalCapability = createApproverRestrictedNativeApprovalCapability({
channel: "matrix",
channelLabel: "Matrix",
listAccountIds: listMatrixAccountIds,
hasApprovers: ({ cfg, accountId }) =>
getMatrixExecApprovalApprovers({ cfg, accountId }).length > 0,
isExecAuthorizedSender: ({ cfg, accountId, senderId }) =>
isMatrixExecApprovalAuthorizedSender({ cfg, accountId, senderId }),
isPluginAuthorizedSender: ({ cfg, accountId, senderId }) =>
isMatrixExecApprovalApprover({ cfg, accountId, senderId }),
isNativeDeliveryEnabled: ({ cfg, accountId }) =>
isMatrixExecApprovalClientEnabled({ cfg, accountId }),
resolveNativeDeliveryMode: ({ cfg, accountId }) =>
resolveMatrixExecApprovalTarget({ cfg, accountId }),
requireMatchingTurnSourceChannel: true,
resolveSuppressionAccountId: ({ target, request }) =>
target.accountId?.trim() || request.request.turnSourceAccountId?.trim() || undefined,
resolveOriginTarget: resolveMatrixOriginTarget,
resolveApproverDmTargets: resolveMatrixApproverDmTargets,
});
export const matrixApprovalCapability = createChannelApprovalCapability({
authorizeActorAction: (params) =>
params.approvalKind === "plugin"
? matrixApprovalAuth.authorizeActorAction(params)
: (matrixNativeApprovalCapability.authorizeActorAction?.(params) ?? { authorized: true }),
getActionAvailabilityState: (params) => {
if (
getMatrixApprovalAuthApprovers({
cfg: params.cfg as CoreConfig,
accountId: params.accountId,
}).length > 0
) {
return { kind: "enabled" } as const;
}
return (
matrixNativeApprovalCapability.getActionAvailabilityState?.(params) ??
({ kind: "disabled" } as const)
);
},
approvals: {
delivery: matrixNativeApprovalCapability.delivery,
native: matrixNativeApprovalCapability.native,
render: matrixNativeApprovalCapability.render,
},
});
export const matrixNativeApprovalAdapter = splitChannelApprovalCapability(matrixApprovalCapability);

View File

@@ -35,8 +35,9 @@ import {
createDefaultChannelRuntimeState,
} from "openclaw/plugin-sdk/status-helpers";
import { matrixMessageActions } from "./actions.js";
import { matrixApprovalAuth } from "./approval-auth.js";
import { matrixApprovalCapability } from "./approval-native.js";
import { MatrixConfigSchema } from "./config-schema.js";
import { shouldSuppressLocalMatrixExecApprovalPrompt } from "./exec-approvals.js";
import {
resolveMatrixGroupRequireMention,
resolveMatrixGroupToolPolicy,
@@ -310,7 +311,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount, MatrixProbe> =
},
}),
},
auth: matrixApprovalAuth,
approvalCapability: matrixApprovalCapability,
groups: {
resolveRequireMention: resolveMatrixGroupRequireMention,
resolveToolPolicy: resolveMatrixGroupToolPolicy,
@@ -556,6 +557,12 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount, MatrixProbe> =
chunker: chunkTextForOutbound,
chunkerMode: "markdown",
textChunkLimit: 4000,
shouldSuppressLocalPayloadPrompt: ({ cfg, accountId, payload }) =>
shouldSuppressLocalMatrixExecApprovalPrompt({
cfg,
accountId,
payload,
}),
...createRuntimeOutboundDelegates({
getRuntime: loadMatrixChannelRuntime,
sendText: {

View File

@@ -30,6 +30,16 @@ const matrixThreadBindingsSchema = z
})
.optional();
const matrixExecApprovalsSchema = z
.object({
enabled: z.boolean().optional(),
approvers: AllowFromListSchema,
agentFilter: z.array(z.string()).optional(),
sessionFilter: z.array(z.string()).optional(),
target: z.enum(["dm", "channel", "both"]).optional(),
})
.optional();
const matrixRoomSchema = z
.object({
enabled: z.boolean().optional(),
@@ -86,6 +96,7 @@ export const MatrixConfigSchema = z.object({
dm: buildNestedDmConfigSchema({
threadReplies: z.enum(["off", "inbound", "always"]).optional(),
}),
execApprovals: matrixExecApprovalsSchema,
groups: z.object({}).catchall(matrixRoomSchema).optional(),
rooms: z.object({}).catchall(matrixRoomSchema).optional(),
actions: matrixActionSchema,

View File

@@ -0,0 +1,271 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
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,
};
const pluginRequest = {
id: "plugin:9f1c7d5d-b1fb-46ef-ac45-662723b65bb7",
request: {
title: "Plugin Approval Required",
description: "Allow plugin access",
pluginId: "git-tools",
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 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,
editMessage,
deleteMessage,
repairDirectRooms,
},
);
return { client, handler, sendMessage, editMessage, deleteMessage, repairDirectRooms };
}
afterEach(() => {
vi.useRealTimers();
});
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("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 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("delivers plugin approvals through the shared native delivery planner", 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(pluginRequest);
expect(sendMessage).toHaveBeenCalledWith(
"room:!dm-owner:example.org",
expect.stringContaining("Plugin Approval Required"),
expect.objectContaining({
accountId: "default",
}),
);
});
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",
}),
);
});
});

View File

@@ -0,0 +1,282 @@
import {
buildExecApprovalPendingReplyPayload,
buildPluginApprovalPendingReplyPayload,
buildPluginApprovalResolvedMessage,
getExecApprovalApproverDmNoticeText,
resolveExecApprovalCommandDisplay,
} from "openclaw/plugin-sdk/approval-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 type {
PluginApprovalRequest,
PluginApprovalResolved,
} from "openclaw/plugin-sdk/infra-runtime";
import { matrixNativeApprovalAdapter } from "./approval-native.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 { sendMessageMatrix } from "./matrix/send.js";
import { resolveMatrixTargetIdentity } from "./matrix/target-ids.js";
import type { CoreConfig } from "./types.js";
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
type ApprovalResolved = ExecApprovalResolved | PluginApprovalResolved;
type ApprovalKind = "exec" | "plugin";
type PendingMessage = {
roomId: string;
messageId: string;
};
type PreparedMatrixTarget = {
to: string;
roomId: string;
threadId?: string;
};
export type MatrixExecApprovalHandlerOpts = {
client: MatrixClient;
accountId: string;
cfg: OpenClawConfig;
gatewayUrl?: string;
};
export type MatrixExecApprovalHandlerDeps = {
nowMs?: () => number;
sendMessage?: typeof sendMessageMatrix;
editMessage?: typeof editMatrixMessage;
deleteMessage?: typeof deleteMatrixMessage;
repairDirectRooms?: typeof repairMatrixDirectRooms;
};
function isHandlerConfigured(params: { cfg: OpenClawConfig; accountId: string }): boolean {
return isMatrixExecApprovalClientEnabled(params);
}
function normalizeThreadId(value?: string | number | null): string | undefined {
const trimmed = value == null ? "" : String(value).trim();
return trimmed || undefined;
}
function resolveApprovalKind(request: ApprovalRequest): ApprovalKind {
return request.id.startsWith("plugin:") ? "plugin" : "exec";
}
function buildPendingApprovalText(params: { request: ApprovalRequest; nowMs: number }): string {
if (resolveApprovalKind(params.request) === "plugin") {
return buildPluginApprovalPendingReplyPayload({
request: params.request as PluginApprovalRequest,
nowMs: params.nowMs,
}).text!;
}
return buildExecApprovalPendingReplyPayload({
approvalId: params.request.id,
approvalSlug: params.request.id.slice(0, 8),
approvalCommandId: params.request.id,
command: resolveExecApprovalCommandDisplay((params.request as ExecApprovalRequest).request)
.commandText,
cwd: (params.request as ExecApprovalRequest).request.cwd ?? undefined,
host: (params.request as ExecApprovalRequest).request.host === "node" ? "node" : "gateway",
nodeId: (params.request as ExecApprovalRequest).request.nodeId ?? undefined,
expiresAtMs: params.request.expiresAtMs,
nowMs: params.nowMs,
}).text!;
}
function buildResolvedApprovalText(params: {
request: ApprovalRequest;
resolved: ApprovalResolved;
}): string {
if (resolveApprovalKind(params.request) === "plugin") {
return buildPluginApprovalResolvedMessage(params.resolved as PluginApprovalResolved);
}
const command = resolveExecApprovalCommandDisplay(
(params.request as ExecApprovalRequest).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<ApprovalRequest, ApprovalResolved>;
private readonly nowMs: () => number;
private readonly sendMessage: typeof sendMessageMatrix;
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.editMessage = deps.editMessage ?? editMatrixMessage;
this.deleteMessage = deps.deleteMessage ?? deleteMatrixMessage;
this.repairDirectRooms = deps.repairDirectRooms ?? repairMatrixDirectRooms;
this.runtime = createChannelNativeApprovalRuntime<
PendingMessage,
PreparedMatrixTarget,
string,
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", "plugin"],
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 }) =>
buildPendingApprovalText({
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, {
cfg: this.opts.cfg as CoreConfig,
accountId: this.opts.accountId,
client: this.opts.client,
threadId: preparedTarget.threadId,
});
return {
roomId: result.roomId,
messageId: result.messageId,
};
},
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();
}
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 = normalizeThreadId(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) => {
await this.editMessage(entry.roomId, entry.messageId, text, {
cfg: this.opts.cfg as CoreConfig,
accountId: this.opts.accountId,
client: this.opts.client,
});
}),
);
}
private async clearPending(entries: PendingMessage[]): Promise<void> {
await Promise.allSettled(
entries.map(async (entry) => {
await this.deleteMessage(entry.roomId, entry.messageId, {
cfg: this.opts.cfg as CoreConfig,
accountId: this.opts.accountId,
client: this.opts.client,
reason: "approval expired",
});
}),
);
}
}

View File

@@ -0,0 +1,169 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { describe, expect, it } from "vitest";
import {
getMatrixExecApprovalApprovers,
isMatrixExecApprovalApprover,
isMatrixExecApprovalAuthorizedSender,
isMatrixExecApprovalClientEnabled,
isMatrixExecApprovalTargetRecipient,
normalizeMatrixApproverId,
resolveMatrixExecApprovalTarget,
shouldHandleMatrixExecApprovalRequest,
shouldSuppressLocalMatrixExecApprovalPrompt,
} from "./exec-approvals.js";
function buildConfig(
execApprovals?: NonNullable<NonNullable<OpenClawConfig["channels"]>["matrix"]>["execApprovals"],
channelOverrides?: Partial<NonNullable<NonNullable<OpenClawConfig["channels"]>["matrix"]>>,
): OpenClawConfig {
return {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok",
...channelOverrides,
execApprovals,
},
},
} as OpenClawConfig;
}
describe("matrix exec approvals", () => {
it("requires enablement and an explicit or inferred approver", () => {
expect(isMatrixExecApprovalClientEnabled({ cfg: buildConfig() })).toBe(false);
expect(isMatrixExecApprovalClientEnabled({ cfg: buildConfig({ enabled: true }) })).toBe(false);
expect(
isMatrixExecApprovalClientEnabled({
cfg: buildConfig({ enabled: true }, { dm: { allowFrom: ["@owner:example.org"] } }),
}),
).toBe(true);
expect(
isMatrixExecApprovalClientEnabled({
cfg: buildConfig({ enabled: true, approvers: ["@owner:example.org"] }),
}),
).toBe(true);
});
it("prefers explicit approvers when configured", () => {
const cfg = buildConfig(
{ enabled: true, approvers: ["user:@override:example.org"] },
{ dm: { allowFrom: ["@owner:example.org"] } },
);
expect(getMatrixExecApprovalApprovers({ cfg })).toEqual(["@override:example.org"]);
expect(isMatrixExecApprovalApprover({ cfg, senderId: "@override:example.org" })).toBe(true);
expect(isMatrixExecApprovalApprover({ cfg, senderId: "@owner:example.org" })).toBe(false);
});
it("defaults target to dm", () => {
expect(
resolveMatrixExecApprovalTarget({
cfg: buildConfig({ enabled: true, approvers: ["@owner:example.org"] }),
}),
).toBe("dm");
});
it("matches matrix target recipients from generic approval forwarding targets", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok",
},
},
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [
{ channel: "matrix", to: "user:@target:example.org" },
{ channel: "matrix", to: "room:!ops:example.org" },
],
},
},
} as OpenClawConfig;
expect(isMatrixExecApprovalTargetRecipient({ cfg, senderId: "@target:example.org" })).toBe(
true,
);
expect(isMatrixExecApprovalTargetRecipient({ cfg, senderId: "@other:example.org" })).toBe(
false,
);
expect(isMatrixExecApprovalAuthorizedSender({ cfg, senderId: "@target:example.org" })).toBe(
true,
);
});
it("suppresses local prompts only when the native client is enabled", () => {
const payload = {
channelData: {
execApproval: {
approvalId: "req-1",
approvalSlug: "req-1",
},
},
};
expect(
shouldSuppressLocalMatrixExecApprovalPrompt({
cfg: buildConfig({ enabled: true, approvers: ["@owner:example.org"] }),
payload,
}),
).toBe(true);
expect(
shouldSuppressLocalMatrixExecApprovalPrompt({
cfg: buildConfig(),
payload,
}),
).toBe(false);
});
it("normalizes prefixed approver ids", () => {
expect(normalizeMatrixApproverId("matrix:@owner:example.org")).toBe("@owner:example.org");
expect(normalizeMatrixApproverId("user:@owner:example.org")).toBe("@owner:example.org");
});
it("applies agent and session filters to request handling", () => {
const cfg = buildConfig({
enabled: true,
approvers: ["@owner:example.org"],
agentFilter: ["ops-agent"],
sessionFilter: ["matrix:channel:", "ops$"],
});
expect(
shouldHandleMatrixExecApprovalRequest({
cfg,
request: {
id: "req-1",
request: {
command: "echo hi",
agentId: "ops-agent",
sessionKey: "agent:ops-agent:matrix:channel:!room:example.org:ops",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
}),
).toBe(true);
expect(
shouldHandleMatrixExecApprovalRequest({
cfg,
request: {
id: "req-2",
request: {
command: "echo hi",
agentId: "other-agent",
sessionKey: "agent:other-agent:matrix:channel:!room:example.org:ops",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
}),
).toBe(false);
});
});

View File

@@ -0,0 +1,72 @@
import {
createChannelExecApprovalProfile,
isChannelExecApprovalTargetRecipient,
resolveApprovalRequestAccountId,
resolveApprovalApprovers,
} from "openclaw/plugin-sdk/approval-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { ExecApprovalRequest, PluginApprovalRequest } from "openclaw/plugin-sdk/infra-runtime";
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
import { resolveMatrixAccount } from "./matrix/accounts.js";
import { normalizeMatrixUserId } from "./matrix/monitor/allowlist.js";
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
export function normalizeMatrixApproverId(value: string | number): string | undefined {
const normalized = normalizeMatrixUserId(String(value));
return normalized || undefined;
}
export function getMatrixExecApprovalApprovers(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): string[] {
const account = resolveMatrixAccount(params).config;
return resolveApprovalApprovers({
explicit: account.execApprovals?.approvers,
allowFrom: account.dm?.allowFrom,
normalizeApprover: normalizeMatrixApproverId,
});
}
export function isMatrixExecApprovalTargetRecipient(params: {
cfg: OpenClawConfig;
senderId?: string | null;
accountId?: string | null;
}): boolean {
return isChannelExecApprovalTargetRecipient({
...params,
channel: "matrix",
normalizeSenderId: normalizeMatrixApproverId,
matchTarget: ({ target, normalizedSenderId }) =>
normalizeMatrixApproverId(target.to) === normalizedSenderId,
});
}
const matrixExecApprovalProfile = createChannelExecApprovalProfile({
resolveConfig: (params) => resolveMatrixAccount(params).config.execApprovals,
resolveApprovers: getMatrixExecApprovalApprovers,
normalizeSenderId: normalizeMatrixApproverId,
isTargetRecipient: isMatrixExecApprovalTargetRecipient,
matchesRequestAccount: (params) => {
const turnSourceChannel = params.request.request.turnSourceChannel?.trim().toLowerCase() || "";
const boundAccountId = resolveApprovalRequestAccountId({
cfg: params.cfg,
request: params.request,
channel: turnSourceChannel === "matrix" ? null : "matrix",
});
return (
!boundAccountId ||
!params.accountId ||
normalizeAccountId(boundAccountId) === normalizeAccountId(params.accountId)
);
},
});
export const isMatrixExecApprovalClientEnabled = matrixExecApprovalProfile.isClientEnabled;
export const isMatrixExecApprovalApprover = matrixExecApprovalProfile.isApprover;
export const isMatrixExecApprovalAuthorizedSender = matrixExecApprovalProfile.isAuthorizedSender;
export const resolveMatrixExecApprovalTarget = matrixExecApprovalProfile.resolveTarget;
export const shouldHandleMatrixExecApprovalRequest = matrixExecApprovalProfile.shouldHandleRequest;
export const shouldSuppressLocalMatrixExecApprovalPrompt =
matrixExecApprovalProfile.shouldSuppressLocalPrompt;

View File

@@ -142,6 +142,6 @@ export function resolveMatrixAccountConfig(params: {
| undefined,
accountId,
normalizeAccountId,
nestedObjectKeys: ["dm", "actions"],
nestedObjectKeys: ["dm", "actions", "execApprovals"],
});
}

View File

@@ -1,4 +1,5 @@
import { format } from "node:util";
import { MatrixExecApprovalHandler } from "../../exec-approvals-handler.js";
import {
GROUP_POLICY_BLOCKED_LABEL,
resolveThreadBindingIdleTimeoutMsForChannel,
@@ -142,6 +143,7 @@ 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,
@@ -161,6 +163,7 @@ 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");
@@ -338,6 +341,13 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
// Shared client is already started via resolveSharedMatrixClient.
logger.info(`matrix: logged in as ${auth.userId}`);
execApprovalsHandler = new MatrixExecApprovalHandler({
client,
accountId: account.accountId,
cfg,
});
await execApprovalsHandler.start();
await runMatrixStartupMaintenance({
client,
auth,

View File

@@ -56,6 +56,21 @@ export type MatrixThreadBindingsConfig = {
spawnAcpSessions?: boolean;
};
export type MatrixExecApprovalTarget = "dm" | "channel" | "both";
export type MatrixExecApprovalConfig = {
/** If true, deliver exec approvals through Matrix-native prompts. */
enabled?: boolean;
/** Optional approver Matrix user IDs. Falls back to dm.allowFrom. */
approvers?: Array<string | number>;
/** Optional agent allowlist for approval delivery. */
agentFilter?: string[];
/** Optional session allowlist for approval delivery. */
sessionFilter?: string[];
/** Where approval prompts should go. Default: dm. */
target?: MatrixExecApprovalTarget;
};
/** Per-account Matrix config (excludes the accounts field to prevent recursion). */
export type MatrixAccountConfig = Omit<MatrixConfig, "accounts">;
@@ -137,6 +152,8 @@ export type MatrixConfig = {
autoJoinAllowlist?: Array<string | number>;
/** Direct message policy + allowlist overrides. */
dm?: MatrixDmConfig;
/** Matrix-native exec approval delivery config. */
execApprovals?: MatrixExecApprovalConfig;
/** Room config allowlist keyed by room ID or alias (names resolved to IDs when possible). */
groups?: Record<string, MatrixRoomConfig>;
/** Room config allowlist keyed by room ID or alias. Legacy; use groups. */