Skip to content

Commit 7f49964

Browse files
authored
enhance(slack): deliver native plugin approvals (#85062)
* fix(slack): deliver native plugin approvals * fix(slack): deliver plugin approvals with native UI * docs: defer slack plugin approval docs
1 parent 777a113 commit 7f49964

14 files changed

Lines changed: 1186 additions & 66 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ Docs: https://docs.openclaw.ai
5757
- Trajectory/support: tolerate partial skill snapshot entries when building support metadata so rejected skill path scans no longer abort trajectory capture. (#71185) Thanks @lukeboyett.
5858
- Telegram: honor `channels.telegram.pollingStallThresholdMs` in the default isolated polling path, restarting silent workers instead of leaving inbound updates wedged. Fixes #83950. (#84861) Thanks @joshavant.
5959
- Slack: suppress reasoning payloads before reply delivery and dispatch accounting, so Slack monitor, slash-command, fallback, and direct reply paths do not leak model reasoning. Fixes #84319. (#84322) Thanks @ffluk3 and @joshavant.
60+
- Slack: deliver native plugin approval prompts and updates when Slack native approvals are enabled, while keeping plugin approval authorization separate from exec approvers.
6061
- Agents/Pi: disable the embedded pi-coding-agent runtime auto-retry so OpenClaw's own retry and failover loop does not replay failed tool calls through a nested SDK retry. Fixes #73781. (#74434) Thanks @yelog.
6162
- CLI/perf: keep `setup --help`, `onboard --help`, and `configure --help` out of the full wizard runtime while preserving the existing help output. (#84488) Thanks @frankekn.
6263
- CLI/perf: keep `agents --help` out of agents action/runtime imports so help, completion, and command discovery paths avoid loading the full agents runtime. (#84483) Thanks @frankekn.

docs/channels/slack.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1288,13 +1288,15 @@ compact, redacted `Slack interaction: ...` system event. If the handler returns
12881288
fields are included in that compact event so the agent can reference
12891289
plugin-owned storage without seeing the complete form payload.
12901290

1291-
## Exec approvals in Slack
1291+
## Native approvals in Slack
12921292

12931293
Slack can act as a native approval client with interactive buttons and interactions, instead of falling back to the Web UI or terminal.
12941294

1295-
- Exec approvals use `channels.slack.execApprovals.*` for native DM/channel routing.
1296-
- Plugin approvals can still resolve through the same Slack-native button surface when the request already lands in Slack and the approval id kind is `plugin:`.
1297-
- Approver authorization is still enforced: only users identified as approvers can approve or deny requests through Slack.
1295+
- Exec and plugin approvals can render as Slack-native Block Kit prompts.
1296+
- `channels.slack.execApprovals.*` remains the native approval client enablement and DM/channel routing config.
1297+
- Exec approval DMs use `channels.slack.execApprovals.approvers` or `commands.ownerAllowFrom`.
1298+
- Plugin approval DMs use Slack plugin approvers from `channels.slack.allowFrom`, named-account `allowFrom`, or the account default route.
1299+
- Approver authorization is still enforced: exec-only approvers cannot approve plugin requests unless they are also plugin approvers.
12981300

12991301
This uses the same shared approval button surface as other channels. When `interactivity` is enabled in your Slack app settings, approval prompts render as Block Kit buttons directly in the conversation.
13001302
When those buttons are present, they are the primary approval UX; OpenClaw
@@ -1341,8 +1343,8 @@ opt into origin-chat delivery:
13411343

13421344
Shared `approvals.exec` forwarding is separate. Use it only when exec approval prompts must also
13431345
route to other chats or explicit out-of-band targets. Shared `approvals.plugin` forwarding is also
1344-
separate; Slack-native buttons can still resolve plugin approvals when those requests already land
1345-
in Slack.
1346+
separate; Slack native delivery suppresses that fallback only when Slack can handle the plugin
1347+
approval request natively.
13461348

13471349
Same-chat `/approve` also works in Slack channels and DMs that already support commands. See [Exec approvals](/tools/exec-approvals) for the full approval forwarding model.
13481350

extensions/slack/src/approval-auth.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
66
import { resolveSlackAccount, resolveSlackAccountAllowFrom } from "./accounts.js";
77
import { normalizeSlackApproverId } from "./exec-approvals.js";
88

9-
function getSlackApprovalApprovers(params: {
9+
export function getSlackApprovalApprovers(params: {
1010
cfg: OpenClawConfig;
1111
accountId?: string | null;
1212
}): string[] {

extensions/slack/src/approval-handler.runtime.test.ts

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ function readChatUpdatePayload(
3333
}
3434

3535
describe("slackApprovalNativeRuntime", () => {
36+
it("subscribes to plugin approval events", () => {
37+
expect(slackApprovalNativeRuntime.eventKinds).toEqual(["exec", "plugin"]);
38+
});
39+
3640
it("renders only the allowed pending actions", async () => {
3741
const payload = (await slackApprovalNativeRuntime.presentation.buildPendingPayload({
3842
cfg: {} as never,
@@ -89,6 +93,81 @@ describe("slackApprovalNativeRuntime", () => {
8993
expect(JSON.stringify(payload.blocks)).not.toContain("Allow Always");
9094
});
9195

96+
it("renders plugin pending approvals with plugin approval actions", async () => {
97+
const payload = (await slackApprovalNativeRuntime.presentation.buildPendingPayload({
98+
cfg: {} as never,
99+
accountId: "default",
100+
context: {
101+
app: {} as never,
102+
config: {} as never,
103+
},
104+
request: {
105+
id: "plugin:req-1",
106+
request: {
107+
title: "Share screen with Computer Use",
108+
description: "Computer Use wants to inspect the desktop.",
109+
},
110+
createdAtMs: 0,
111+
expiresAtMs: 60_000,
112+
},
113+
approvalKind: "plugin",
114+
nowMs: 0,
115+
view: {
116+
approvalKind: "plugin",
117+
phase: "pending",
118+
approvalId: "plugin:req-1",
119+
title: "Share screen with Computer Use",
120+
description: "Computer Use wants to inspect the desktop.",
121+
severity: "warning",
122+
pluginId: "computer-use",
123+
toolName: "screenshot",
124+
metadata: [
125+
{ label: "Severity", value: "Warning" },
126+
{ label: "Plugin", value: "computer-use" },
127+
],
128+
actions: [
129+
{
130+
decision: "allow-once",
131+
label: "Allow Once",
132+
command: "/approve plugin:req-1 allow-once",
133+
style: "success",
134+
},
135+
{
136+
decision: "allow-always",
137+
label: "Allow Always",
138+
command: "/approve plugin:req-1 allow-always",
139+
style: "success",
140+
},
141+
{
142+
decision: "deny",
143+
label: "Deny",
144+
command: "/approve plugin:req-1 deny",
145+
style: "danger",
146+
},
147+
],
148+
expiresAtMs: 60_000,
149+
},
150+
})) as SlackPayload;
151+
152+
expect(payload.text).toContain("*Plugin approval required*");
153+
expect(payload.text).toContain("Share screen with Computer Use");
154+
expect(payload.text).toContain("*Approval ID:* plugin:req-1");
155+
expect(payload.text).not.toContain("*Command*");
156+
const actionsBlock = findSlackActionsBlock(
157+
payload.blocks as Array<{ type?: string; elements?: unknown[] }>,
158+
);
159+
const labels = (actionsBlock?.elements ?? []).map((element) =>
160+
typeof element === "object" &&
161+
element &&
162+
typeof (element as { text?: { text?: unknown } }).text?.text === "string"
163+
? (element as { text: { text: string } }).text.text
164+
: "",
165+
);
166+
167+
expect(labels).toEqual(["Allow Once", "Allow Always", "Deny"]);
168+
expect(JSON.stringify(payload.blocks)).toContain("plugin:req-1");
169+
});
170+
92171
it("renders resolved updates without interactive blocks", async () => {
93172
const result = await slackApprovalNativeRuntime.presentation.buildResolvedResult({
94173
cfg: {} as never,
@@ -136,6 +215,101 @@ describe("slackApprovalNativeRuntime", () => {
136215
).toBe(false);
137216
});
138217

218+
it("renders plugin resolved and expired updates without command text", async () => {
219+
const resolved = await slackApprovalNativeRuntime.presentation.buildResolvedResult({
220+
cfg: {} as never,
221+
accountId: "default",
222+
context: {
223+
app: {} as never,
224+
config: {} as never,
225+
},
226+
request: {
227+
id: "plugin:req-1",
228+
request: {
229+
title: "Share screen with Computer Use",
230+
description: "Computer Use wants to inspect the desktop.",
231+
},
232+
createdAtMs: 0,
233+
expiresAtMs: 60_000,
234+
},
235+
resolved: {
236+
id: "plugin:req-1",
237+
decision: "allow-once",
238+
resolvedBy: "U123APPROVER",
239+
ts: 0,
240+
} as never,
241+
view: {
242+
approvalKind: "plugin",
243+
phase: "resolved",
244+
approvalId: "plugin:req-1",
245+
title: "Share screen with Computer Use",
246+
description: "Computer Use wants to inspect the desktop.",
247+
severity: "warning",
248+
pluginId: "computer-use",
249+
toolName: "screenshot",
250+
metadata: [{ label: "Plugin", value: "computer-use" }],
251+
decision: "allow-once",
252+
resolvedBy: "U123APPROVER",
253+
},
254+
entry: {
255+
channelId: "D123APPROVER",
256+
messageTs: "1712345678.999999",
257+
},
258+
});
259+
const expired = await slackApprovalNativeRuntime.presentation.buildExpiredResult({
260+
cfg: {} as never,
261+
accountId: "default",
262+
context: {
263+
app: {} as never,
264+
config: {} as never,
265+
},
266+
request: {
267+
id: "plugin:req-1",
268+
request: {
269+
title: "Share screen with Computer Use",
270+
description: "Computer Use wants to inspect the desktop.",
271+
},
272+
createdAtMs: 0,
273+
expiresAtMs: 60_000,
274+
},
275+
view: {
276+
approvalKind: "plugin",
277+
phase: "expired",
278+
approvalId: "plugin:req-1",
279+
title: "Share screen with Computer Use",
280+
description: "Computer Use wants to inspect the desktop.",
281+
severity: "warning",
282+
pluginId: "computer-use",
283+
toolName: "screenshot",
284+
metadata: [{ label: "Plugin", value: "computer-use" }],
285+
},
286+
entry: {
287+
channelId: "D123APPROVER",
288+
messageTs: "1712345678.999999",
289+
},
290+
});
291+
292+
expect(resolved.kind).toBe("update");
293+
expect(expired.kind).toBe("update");
294+
if (resolved.kind !== "update" || expired.kind !== "update") {
295+
throw new Error("expected Slack update payloads");
296+
}
297+
const resolvedPayload = resolved.payload as SlackPayload;
298+
const expiredPayload = expired.payload as SlackPayload;
299+
expect(resolvedPayload.text).toContain("*Plugin approval: Allowed once*");
300+
expect(resolvedPayload.text).toContain("Resolved by <@U123APPROVER>.");
301+
expect(resolvedPayload.text).toContain("Share screen with Computer Use");
302+
expect(resolvedPayload.text).not.toContain("*Command*");
303+
expect(expiredPayload.text).toContain("*Plugin approval expired*");
304+
expect(expiredPayload.text).toContain("Share screen with Computer Use");
305+
expect(expiredPayload.text).not.toContain("*Command*");
306+
expect(
307+
(resolvedPayload.blocks as Array<{ type?: string }>).some(
308+
(block) => block.type === "actions",
309+
),
310+
).toBe(false);
311+
});
312+
139313
it("caps resolved update fallback text to Slack chat.update limits while preserving blocks", async () => {
140314
const blocks = [
141315
{

0 commit comments

Comments
 (0)