Matrix: add native exec approvals (#58635)

Merged via squash.

Prepared head SHA: d9f048e827
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Gustavo Madeira Santana
2026-04-02 21:08:54 -04:00
committed by GitHub
parent 9e2cbf9a30
commit 1efa923ab8
24 changed files with 1593 additions and 18 deletions

View File

@@ -512,6 +512,7 @@ export type ChannelApprovalDeliveryAdapter = {
hasConfiguredDmRoute?: (params: { cfg: OpenClawConfig }) => boolean;
shouldSuppressForwardingFallback?: (params: {
cfg: OpenClawConfig;
approvalKind: ChannelApprovalKind;
target: ChannelApprovalForwardTarget;
request: ExecApprovalRequest;
}) => boolean;

View File

@@ -483,6 +483,31 @@ describe("exec approval forwarder", () => {
);
});
it("stores exec metadata on generic forwarded fallback payloads", async () => {
vi.useFakeTimers();
const { deliver, forwarder } = createForwarder({ cfg: TARGETS_CFG });
await expect(forwarder.handleRequested(baseRequest)).resolves.toBe(true);
expect(deliver).toHaveBeenCalledTimes(1);
expect(deliver.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({
payloads: [
expect.objectContaining({
channelData: expect.objectContaining({
execApproval: expect.objectContaining({
approvalId: "req-1",
approvalKind: "exec",
agentId: "main",
sessionKey: "agent:main:main",
}),
}),
}),
],
}),
);
});
it("formats single-line commands as inline code", async () => {
vi.useFakeTimers();
const { deliver, forwarder } = createForwarder({ cfg: TARGETS_CFG });

View File

@@ -169,6 +169,7 @@ function buildSyntheticApprovalRequest(routeRequest: ApprovalRouteRequest): Exec
}
function shouldSkipForwardingFallback(params: {
approvalKind: "exec" | "plugin";
target: ExecApprovalForwardTarget;
cfg: OpenClawConfig;
routeRequest: ApprovalRouteRequest;
@@ -181,6 +182,7 @@ function shouldSkipForwardingFallback(params: {
return (
adapter?.delivery?.shouldSuppressForwardingFallback?.({
cfg: params.cfg,
approvalKind: params.approvalKind,
target: params.target,
request: buildSyntheticApprovalRequest(params.routeRequest),
}) ?? false
@@ -351,7 +353,9 @@ function buildExecPendingPayload(params: {
approvalId: params.request.id,
approvalSlug: params.request.id.slice(0, 8),
text: buildRequestMessage(params.request, params.nowMs),
agentId: params.request.request.agentId ?? null,
allowedDecisions: resolveExecApprovalRequestAllowedDecisions(params.request.request),
sessionKey: params.request.request.sessionKey ?? null,
});
}
@@ -502,8 +506,15 @@ function createApprovalHandlers<
resolveSessionTarget: params.resolveSessionTarget,
})
: []),
].filter((target) => !shouldSkipForwardingFallback({ target, cfg, routeRequest }));
].filter(
(target) =>
!shouldSkipForwardingFallback({
approvalKind: params.strategy.kind,
target,
cfg,
routeRequest,
}),
);
if (filteredTargets.length === 0) {
return false;
}
@@ -598,7 +609,15 @@ function createApprovalHandlers<
resolveSessionTarget: params.resolveSessionTarget,
})
: []),
].filter((target) => !shouldSkipForwardingFallback({ target, cfg, routeRequest }));
].filter(
(target) =>
!shouldSkipForwardingFallback({
approvalKind: params.strategy.kind,
target,
cfg,
routeRequest,
}),
);
}
}
if (!targets?.length) {

View File

@@ -76,14 +76,19 @@ describe("exec approval reply helpers", () => {
execApproval: {
approvalId: " req-1 ",
approvalSlug: " slug-1 ",
agentId: " agent-1 ",
allowedDecisions: ["allow-once", "bad", "deny", "allow-always", 3],
sessionKey: " session-1 ",
},
},
}),
).toEqual({
approvalId: "req-1",
approvalSlug: "slug-1",
approvalKind: "exec",
agentId: "agent-1",
allowedDecisions: ["allow-once", "deny", "allow-always"],
sessionKey: "session-1",
});
});
@@ -104,7 +109,10 @@ describe("exec approval reply helpers", () => {
execApproval: {
approvalId: "req-1",
approvalSlug: "slug-1",
approvalKind: "exec",
agentId: undefined,
allowedDecisions: ["allow-once", "allow-always", "deny"],
sessionKey: undefined,
},
});
expect(payload.interactive).toEqual({
@@ -151,6 +159,7 @@ describe("exec approval reply helpers", () => {
execApproval: {
approvalId: "req-ask-always",
approvalSlug: "slug-always",
approvalKind: "exec",
allowedDecisions: ["allow-once", "deny"],
},
});
@@ -180,6 +189,28 @@ describe("exec approval reply helpers", () => {
});
});
it("stores agent and session metadata for downstream suppression checks", () => {
const payload = buildExecApprovalPendingReplyPayload({
approvalId: "req-meta",
approvalSlug: "slug-meta",
agentId: "ops-agent",
sessionKey: "agent:ops-agent:matrix:channel:!room:example.org",
command: "echo ok",
host: "gateway",
});
expect(payload.channelData).toEqual({
execApproval: {
approvalId: "req-meta",
approvalSlug: "slug-meta",
approvalKind: "exec",
agentId: "ops-agent",
allowedDecisions: ["allow-once", "allow-always", "deny"],
sessionKey: "agent:ops-agent:matrix:channel:!room:example.org",
},
});
});
it("uses a longer fence for commands containing triple backticks", () => {
const payload = buildExecApprovalPendingReplyPayload({
approvalId: "req-2",

View File

@@ -15,7 +15,10 @@ export type ExecApprovalUnavailableReason =
export type ExecApprovalReplyMetadata = {
approvalId: string;
approvalSlug: string;
approvalKind: "exec" | "plugin";
agentId?: string;
allowedDecisions?: readonly ExecApprovalReplyDecision[];
sessionKey?: string;
};
export type ExecApprovalActionDescriptor = {
@@ -31,11 +34,13 @@ export type ExecApprovalPendingReplyParams = {
approvalSlug: string;
approvalCommandId?: string;
ask?: string | null;
agentId?: string | null;
allowedDecisions?: readonly ExecApprovalReplyDecision[];
command: string;
cwd?: string;
host: ExecHost;
nodeId?: string;
sessionKey?: string | null;
expiresAtMs?: number;
nowMs?: number;
};
@@ -225,16 +230,24 @@ export function getExecApprovalReplyMetadata(
if (!approvalId || !approvalSlug) {
return null;
}
const approvalKind = record.approvalKind === "plugin" ? "plugin" : "exec";
const allowedDecisions = Array.isArray(record.allowedDecisions)
? record.allowedDecisions.filter(
(value): value is ExecApprovalReplyDecision =>
value === "allow-once" || value === "allow-always" || value === "deny",
)
: undefined;
const agentId =
typeof record.agentId === "string" ? record.agentId.trim() || undefined : undefined;
const sessionKey =
typeof record.sessionKey === "string" ? record.sessionKey.trim() || undefined : undefined;
return {
approvalId,
approvalSlug,
approvalKind,
agentId,
allowedDecisions,
sessionKey,
};
}
@@ -297,7 +310,10 @@ export function buildExecApprovalPendingReplyPayload(
execApproval: {
approvalId: params.approvalId,
approvalSlug: params.approvalSlug,
approvalKind: "exec",
agentId: params.agentId?.trim() || undefined,
allowedDecisions,
sessionKey: params.sessionKey?.trim() || undefined,
},
},
};

View File

@@ -165,6 +165,7 @@ describe("createApproverRestrictedNativeApprovalAdapter", () => {
expect(
shouldSuppressForwardingFallback({
cfg: {} as never,
approvalKind: "exec",
target: { channel: "telegram", to: "target-1" },
request: {
request: {
@@ -179,6 +180,7 @@ describe("createApproverRestrictedNativeApprovalAdapter", () => {
expect(
shouldSuppressForwardingFallback({
cfg: {} as never,
approvalKind: "exec",
target: { channel: "telegram", to: "target-1" },
request: {
request: {
@@ -193,6 +195,7 @@ describe("createApproverRestrictedNativeApprovalAdapter", () => {
expect(
shouldSuppressForwardingFallback({
cfg: {} as never,
approvalKind: "exec",
target: { channel: "slack", to: "target-1" },
request: {
request: {
@@ -208,6 +211,21 @@ describe("createApproverRestrictedNativeApprovalAdapter", () => {
cfg: {} as never,
accountId: "topic-1",
});
expect(
shouldSuppressForwardingFallback({
cfg: {} as never,
approvalKind: "plugin",
target: { channel: "telegram", to: "target-1" },
request: {
request: {
command: "pwd",
turnSourceChannel: "telegram",
turnSourceAccountId: "topic-1",
},
} as never,
}),
).toBe(true);
});
});

View File

@@ -18,6 +18,7 @@ type ApprovalAdapterParams = {
type DeliverySuppressionParams = {
cfg: OpenClawConfig;
approvalKind: ApprovalKind;
target: { channel: string; accountId?: string | null };
request: { request: { turnSourceChannel?: string | null; turnSourceAccountId?: string | null } };
};

View File

@@ -89,9 +89,12 @@ describe("plugin-sdk/approval-renderers", () => {
},
channelDataExpected: {
execApproval: {
agentId: undefined,
approvalId: "plugin-approval-123",
approvalKind: "plugin",
approvalSlug: "custom-slug",
allowedDecisions: ["allow-once", "allow-always", "deny"],
sessionKey: undefined,
state: "pending",
},
telegram: {

View File

@@ -13,10 +13,13 @@ import {
const DEFAULT_ALLOWED_DECISIONS = ["allow-once", "allow-always", "deny"] as const;
export function buildApprovalPendingReplyPayload(params: {
approvalKind?: "exec" | "plugin";
approvalId: string;
approvalSlug: string;
text: string;
agentId?: string | null;
allowedDecisions?: readonly ExecApprovalReplyDecision[];
sessionKey?: string | null;
channelData?: Record<string, unknown>;
}): ReplyPayload {
const allowedDecisions = params.allowedDecisions ?? DEFAULT_ALLOWED_DECISIONS;
@@ -30,7 +33,10 @@ export function buildApprovalPendingReplyPayload(params: {
execApproval: {
approvalId: params.approvalId,
approvalSlug: params.approvalSlug,
approvalKind: params.approvalKind ?? "exec",
agentId: params.agentId?.trim() || undefined,
allowedDecisions,
sessionKey: params.sessionKey?.trim() || undefined,
state: "pending",
},
...params.channelData,
@@ -66,6 +72,7 @@ export function buildPluginApprovalPendingReplyPayload(params: {
channelData?: Record<string, unknown>;
}): ReplyPayload {
return buildApprovalPendingReplyPayload({
approvalKind: "plugin",
approvalId: params.request.id,
approvalSlug: params.approvalSlug ?? params.request.id.slice(0, 8),
text: params.text ?? buildPluginApprovalRequestMessage(params.request, params.nowMs),