Skip to content

Commit 0ef9383

Browse files
authored
fix(approvals): make exec approval fallback guidance channel-specific (#61424)
Merged via squash. Prepared head SHA: cb5d3c2 Co-authored-by: gumadeiras <[email protected]> Co-authored-by: gumadeiras <[email protected]> Reviewed-by: @gumadeiras
1 parent 84e76f7 commit 0ef9383

23 files changed

Lines changed: 462 additions & 30 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ Docs: https://docs.openclaw.ai
4545
- Memory/dreaming: add configurable aging controls (`recencyHalfLifeDays`, `maxAgeDays`) plus optional verbose logging so operators can tune recall decay and inspect promotion decisions more easily.
4646
- Plugins/reply dispatch: add a generic `reply_dispatch` hook so bundled plugins like ACPX can own reply interception without hardcoded ACP paths in core auto-reply routing.
4747
- Memory/dreaming: refactor dreaming from competing modes (`off|core|rem|deep`) to three cooperative phases (light, deep, REM) with independent schedules, per-phase enable/disable, deep-only `MEMORY.md` writes, light/REM daily-note staging, deep recovery, and per-phase execution overrides.
48+
- Matrix/exec approvals: clarify unavailable-approval replies so Matrix no longer claims chat approvals are unsupported when native exec approvals are merely unconfigured. (#61424) Thanks @gumadeiras.
4849

4950
### Fixes
5051

docs/plugins/sdk-channel-plugins.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,11 @@ Most channel plugins do not need approval-specific code.
6161
- Core owns same-chat `/approve`, shared approval button payloads, and generic fallback delivery.
6262
- Prefer one `approvalCapability` object on the channel plugin when the channel needs approval-specific behavior.
6363
- `approvalCapability.authorizeActorAction` and `approvalCapability.getActionAvailabilityState` are the canonical approval-auth seam.
64+
- If your channel exposes native exec approvals, implement `approvalCapability.getActionAvailabilityState` even when the native transport lives entirely under `approvalCapability.native`. Core uses that availability hook to distinguish `enabled` vs `disabled`, decide whether the initiating channel supports native approvals, and include the channel in native-client fallback guidance.
6465
- Use `outbound.shouldSuppressLocalPayloadPrompt` or `outbound.beforeDeliverPayload` for channel-specific payload lifecycle behavior such as hiding duplicate local approval prompts or sending typing indicators before delivery.
6566
- Use `approvalCapability.delivery` only for native approval routing or fallback suppression.
6667
- Use `approvalCapability.render` only when a channel truly needs custom approval payloads instead of the shared renderer.
68+
- Use `approvalCapability.describeExecApprovalSetup` when the channel wants the disabled-path reply to explain the exact config knobs needed to enable native exec approvals. The hook receives `{ channel, channelLabel, accountId }`; named-account channels should render account-scoped paths such as `channels.<channel>.accounts.<id>.execApprovals.*` instead of top-level defaults.
6769
- If a channel can infer stable owner-like DM identities from existing config, use `createResolvedApproverActionAuthAdapter` from `openclaw/plugin-sdk/approval-runtime` to restrict same-chat `/approve` without adding approval-specific core logic.
6870
- If a channel needs native approval delivery, keep channel code focused on target normalization and transport hooks. Use `createChannelExecApprovalProfile`, `createChannelNativeOriginTargetResolver`, `createChannelApproverDmTargetResolver`, `createApproverRestrictedNativeApprovalCapability`, and `createChannelNativeApprovalRuntime` from `openclaw/plugin-sdk/approval-runtime` so core owns request filtering, routing, dedupe, expiry, and gateway subscription.
6971
- Native approval channels must route both `accountId` and `approvalKind` through those helpers. `accountId` keeps multi-account approval policy scoped to the right bot account, and `approvalKind` keeps exec vs plugin approval behavior available to the channel without hardcoded branches in core.

extensions/discord/src/approval-native.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest";
55
import { clearSessionStoreCacheForTest } from "../../../src/config/sessions.js";
66
import {
77
createDiscordNativeApprovalAdapter,
8+
getDiscordApprovalCapability,
89
shouldHandleDiscordApprovalRequest,
910
} from "./approval-native.js";
1011

@@ -87,6 +88,29 @@ describe("createDiscordNativeApprovalAdapter", () => {
8788
).toBe(true);
8889
});
8990

91+
it("describes the correct Discord exec-approval setup path", () => {
92+
const text = getDiscordApprovalCapability().describeExecApprovalSetup?.({
93+
channel: "discord",
94+
channelLabel: "Discord",
95+
});
96+
97+
expect(text).toContain("`channels.discord.execApprovals.approvers`");
98+
expect(text).toContain("`commands.ownerAllowFrom`");
99+
expect(text).not.toContain("`channels.discord.dm.allowFrom`");
100+
});
101+
102+
it("describes the named-account Discord exec-approval setup path", () => {
103+
const text = getDiscordApprovalCapability().describeExecApprovalSetup?.({
104+
channel: "discord",
105+
channelLabel: "Discord",
106+
accountId: "work",
107+
});
108+
109+
expect(text).toContain("`channels.discord.accounts.work.execApprovals.approvers`");
110+
expect(text).toContain("`commands.ownerAllowFrom`");
111+
expect(text).not.toContain("`channels.discord.execApprovals.approvers`");
112+
});
113+
90114
it("normalizes prefixed turn-source channel ids", async () => {
91115
const adapter = createDiscordNativeApprovalAdapter();
92116

extensions/discord/src/approval-native.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,13 @@ export function createDiscordApprovalCapability(configOverride?: DiscordExecAppr
153153
return createApproverRestrictedNativeApprovalCapability({
154154
channel: "discord",
155155
channelLabel: "Discord",
156+
describeExecApprovalSetup: ({ accountId }) => {
157+
const prefix =
158+
accountId && accountId !== "default"
159+
? `channels.discord.accounts.${accountId}`
160+
: "channels.discord";
161+
return `Approve it from the Web UI or terminal UI for now. Discord supports native exec approvals for this account. Configure \`${prefix}.execApprovals.approvers\` or \`commands.ownerAllowFrom\`; leave \`${prefix}.execApprovals.enabled\` unset/\`auto\` or set it to \`true\`.`;
162+
},
156163
listAccountIds: listDiscordAccountIds,
157164
hasApprovers: ({ cfg, accountId }) =>
158165
getDiscordExecApprovalApprovers({ cfg, accountId, configOverride }).length > 0,

extensions/matrix/src/approval-native.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,28 @@ function buildConfig(
2323
}
2424

2525
describe("matrix native approval adapter", () => {
26+
it("describes the correct Matrix exec-approval setup path", () => {
27+
const text = matrixApprovalCapability.describeExecApprovalSetup?.({
28+
channel: "matrix",
29+
channelLabel: "Matrix",
30+
});
31+
32+
expect(text).toContain("`channels.matrix.execApprovals.approvers`");
33+
expect(text).toContain("`channels.matrix.dm.allowFrom`");
34+
});
35+
36+
it("describes the named-account Matrix exec-approval setup path", () => {
37+
const text = matrixApprovalCapability.describeExecApprovalSetup?.({
38+
channel: "matrix",
39+
channelLabel: "Matrix",
40+
accountId: "work",
41+
});
42+
43+
expect(text).toContain("`channels.matrix.accounts.work.execApprovals.approvers`");
44+
expect(text).toContain("`channels.matrix.accounts.work.dm.allowFrom`");
45+
expect(text).not.toContain("`channels.matrix.execApprovals.approvers`");
46+
});
47+
2648
it("describes native matrix approval delivery capabilities", () => {
2749
const capabilities = matrixNativeApprovalAdapter.native?.describeDeliveryCapabilities({
2850
cfg: buildConfig(),

extensions/matrix/src/approval-native.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,13 @@ const resolveMatrixApproverDmTargets = createChannelApproverDmTargetResolver({
123123
const matrixNativeApprovalCapability = createApproverRestrictedNativeApprovalCapability({
124124
channel: "matrix",
125125
channelLabel: "Matrix",
126+
describeExecApprovalSetup: ({ accountId }) => {
127+
const prefix =
128+
accountId && accountId !== "default"
129+
? `channels.matrix.accounts.${accountId}`
130+
: "channels.matrix";
131+
return `Approve it from the Web UI or terminal UI for now. Matrix supports native exec approvals for this account. Configure \`${prefix}.execApprovals.approvers\` or \`${prefix}.dm.allowFrom\`; leave \`${prefix}.execApprovals.enabled\` unset/\`auto\` or set it to \`true\`.`;
132+
},
126133
listAccountIds: listMatrixAccountIds,
127134
hasApprovers: ({ cfg, accountId }) =>
128135
getMatrixExecApprovalApprovers({ cfg, accountId }).length > 0,
@@ -203,6 +210,7 @@ export const matrixApprovalCapability = createChannelApprovalCapability({
203210
? ({ kind: "enabled" } as const)
204211
: (matrixNativeApprovalCapability.getActionAvailabilityState?.(params) ??
205212
({ kind: "disabled" } as const)),
213+
describeExecApprovalSetup: matrixNativeApprovalCapability.describeExecApprovalSetup,
206214
approvals: {
207215
delivery: matrixDeliveryAdapter,
208216
native: matrixExecOnlyNativeApprovalAdapter,

extensions/slack/src/approval-native.test.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import path from "node:path";
44
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
55
import { describe, expect, it } from "vitest";
66
import { clearSessionStoreCacheForTest } from "../../../src/config/sessions.js";
7-
import { slackNativeApprovalAdapter } from "./approval-native.js";
7+
import { slackApprovalCapability, slackNativeApprovalAdapter } from "./approval-native.js";
88

99
function buildConfig(
1010
overrides?: Partial<NonNullable<NonNullable<OpenClawConfig["channels"]>["slack"]>>,
@@ -104,6 +104,29 @@ describe("slack native approval adapter", () => {
104104
});
105105
});
106106

107+
it("describes the correct Slack exec-approval setup path", () => {
108+
const text = slackApprovalCapability.describeExecApprovalSetup?.({
109+
channel: "slack",
110+
channelLabel: "Slack",
111+
});
112+
113+
expect(text).toContain("`channels.slack.execApprovals.approvers`");
114+
expect(text).toContain("`commands.ownerAllowFrom`");
115+
expect(text).not.toContain("`channels.slack.dm.allowFrom`");
116+
});
117+
118+
it("describes the named-account Slack exec-approval setup path", () => {
119+
const text = slackApprovalCapability.describeExecApprovalSetup?.({
120+
channel: "slack",
121+
channelLabel: "Slack",
122+
accountId: "work",
123+
});
124+
125+
expect(text).toContain("`channels.slack.accounts.work.execApprovals.approvers`");
126+
expect(text).toContain("`commands.ownerAllowFrom`");
127+
expect(text).not.toContain("`channels.slack.execApprovals.approvers`");
128+
});
129+
107130
it("resolves origin targets from slack turn source", async () => {
108131
const target = await slackNativeApprovalAdapter.native?.resolveOriginTarget?.({
109132
cfg: buildConfig(),

extensions/slack/src/approval-native.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,13 @@ const resolveSlackApproverDmTargets = createChannelApproverDmTargetResolver({
119119
export const slackApprovalCapability = createApproverRestrictedNativeApprovalCapability({
120120
channel: "slack",
121121
channelLabel: "Slack",
122+
describeExecApprovalSetup: ({ accountId }) => {
123+
const prefix =
124+
accountId && accountId !== "default"
125+
? `channels.slack.accounts.${accountId}`
126+
: "channels.slack";
127+
return `Approve it from the Web UI or terminal UI for now. Slack supports native exec approvals for this account. Configure \`${prefix}.execApprovals.approvers\` or \`commands.ownerAllowFrom\`; leave \`${prefix}.execApprovals.enabled\` unset/\`auto\` or set it to \`true\`.`;
128+
},
122129
listAccountIds: listSlackAccountIds,
123130
hasApprovers: ({ cfg, accountId }) =>
124131
getSlackExecApprovalApprovers({ cfg, accountId }).length > 0,

extensions/telegram/src/approval-native.test.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import path from "node:path";
44
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
55
import { describe, expect, it } from "vitest";
66
import { clearSessionStoreCacheForTest } from "../../../src/config/sessions.js";
7-
import { telegramNativeApprovalAdapter } from "./approval-native.js";
7+
import { telegramApprovalCapability, telegramNativeApprovalAdapter } from "./approval-native.js";
88

99
function buildConfig(
1010
overrides?: Partial<NonNullable<NonNullable<OpenClawConfig["channels"]>["telegram"]>>,
@@ -32,6 +32,31 @@ function writeStore(store: Record<string, unknown>) {
3232
}
3333

3434
describe("telegram native approval adapter", () => {
35+
it("describes the correct Telegram exec-approval setup path", () => {
36+
const text = telegramApprovalCapability.describeExecApprovalSetup?.({
37+
channel: "telegram",
38+
channelLabel: "Telegram",
39+
});
40+
41+
expect(text).toContain("`channels.telegram.execApprovals.approvers`");
42+
expect(text).toContain("`channels.telegram.allowFrom`");
43+
expect(text).toContain("`channels.telegram.defaultTo`");
44+
expect(text).not.toContain("`channels.telegram.dm.allowFrom`");
45+
});
46+
47+
it("describes the named-account Telegram exec-approval setup path", () => {
48+
const text = telegramApprovalCapability.describeExecApprovalSetup?.({
49+
channel: "telegram",
50+
channelLabel: "Telegram",
51+
accountId: "work",
52+
});
53+
54+
expect(text).toContain("`channels.telegram.accounts.work.execApprovals.approvers`");
55+
expect(text).toContain("`channels.telegram.accounts.work.allowFrom`");
56+
expect(text).toContain("`channels.telegram.accounts.work.defaultTo`");
57+
expect(text).not.toContain("`channels.telegram.allowFrom`");
58+
});
59+
3560
it("normalizes direct-chat origin targets so DM dedupe can converge", async () => {
3661
const target = await telegramNativeApprovalAdapter.native?.resolveOriginTarget?.({
3762
cfg: buildConfig(),

extensions/telegram/src/approval-native.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,13 @@ const resolveTelegramApproverDmTargets = createChannelApproverDmTargetResolver({
9191
const telegramNativeApprovalCapability = createApproverRestrictedNativeApprovalCapability({
9292
channel: "telegram",
9393
channelLabel: "Telegram",
94+
describeExecApprovalSetup: ({ accountId }) => {
95+
const prefix =
96+
accountId && accountId !== "default"
97+
? `channels.telegram.accounts.${accountId}`
98+
: "channels.telegram";
99+
return `Approve it from the Web UI or terminal UI for now. Telegram supports native exec approvals for this account. Configure \`${prefix}.execApprovals.approvers\`; if you leave it unset, OpenClaw can infer numeric owner IDs from \`${prefix}.allowFrom\` or direct-message \`${prefix}.defaultTo\` when possible. Leave \`${prefix}.execApprovals.enabled\` unset/\`auto\` or set it to \`true\`.`;
100+
},
94101
listAccountIds: listTelegramAccountIds,
95102
hasApprovers: ({ cfg, accountId }) =>
96103
getTelegramExecApprovalApprovers({ cfg, accountId }).length > 0,

0 commit comments

Comments
 (0)