mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-21 14:11:26 +00:00
Add Matrix native exec approvals
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
141
extensions/matrix/src/approval-native.test.ts
Normal file
141
extensions/matrix/src/approval-native.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
156
extensions/matrix/src/approval-native.ts
Normal file
156
extensions/matrix/src/approval-native.ts
Normal 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);
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
271
extensions/matrix/src/exec-approvals-handler.test.ts
Normal file
271
extensions/matrix/src/exec-approvals-handler.test.ts
Normal 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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
282
extensions/matrix/src/exec-approvals-handler.ts
Normal file
282
extensions/matrix/src/exec-approvals-handler.ts
Normal 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",
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
169
extensions/matrix/src/exec-approvals.test.ts
Normal file
169
extensions/matrix/src/exec-approvals.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
72
extensions/matrix/src/exec-approvals.ts
Normal file
72
extensions/matrix/src/exec-approvals.ts
Normal 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;
|
||||
@@ -142,6 +142,6 @@ export function resolveMatrixAccountConfig(params: {
|
||||
| undefined,
|
||||
accountId,
|
||||
normalizeAccountId,
|
||||
nestedObjectKeys: ["dm", "actions"],
|
||||
nestedObjectKeys: ["dm", "actions", "execApprovals"],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user