From 17f6626ffeba070fe4ee5461c14237ae9b71f10f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 2 Apr 2026 17:28:32 +0100 Subject: [PATCH] feat(approvals): auto-enable native chat approvals --- docs/.generated/config-baseline.json | 8 ++--- docs/.generated/config-baseline.jsonl | 8 ++--- extensions/discord/src/approval-native.ts | 15 +++++---- extensions/discord/src/exec-approvals.test.ts | 21 ++++++------ extensions/discord/src/exec-approvals.ts | 11 ++++--- .../discord/src/monitor/exec-approvals.ts | 12 +++++-- extensions/discord/src/monitor/provider.ts | 19 +++++++---- extensions/slack/src/config-ui-hints.ts | 4 +-- extensions/slack/src/exec-approvals.test.ts | 16 +++++---- .../slack/src/monitor/exec-approvals.ts | 12 +++---- extensions/slack/src/monitor/provider.ts | 8 +++-- extensions/telegram/src/config-ui-hints.ts | 4 +-- .../telegram/src/exec-approvals-handler.ts | 17 ++-------- .../telegram/src/exec-approvals.test.ts | 13 +++++--- extensions/telegram/src/exec-approvals.ts | 11 +++++++ extensions/telegram/src/monitor.ts | 33 +++++++++++-------- ...ndled-channel-config-metadata.generated.ts | 8 ++--- src/config/types.approvals.ts | 2 ++ src/config/types.discord.ts | 4 +-- src/config/types.slack.ts | 4 +-- src/config/types.telegram.ts | 4 +-- src/infra/exec-approval-reply.test.ts | 4 +-- src/infra/exec-approval-reply.ts | 6 ++-- .../approval-client-helpers.test.ts | 32 ++++++++++++++++++ src/plugin-sdk/approval-client-helpers.ts | 33 ++++++++++++++----- src/plugin-sdk/approval-runtime.ts | 1 + 26 files changed, 197 insertions(+), 113 deletions(-) diff --git a/docs/.generated/config-baseline.json b/docs/.generated/config-baseline.json index 3af85413351..f835f3e91f1 100644 --- a/docs/.generated/config-baseline.json +++ b/docs/.generated/config-baseline.json @@ -31477,7 +31477,7 @@ "network" ], "label": "Slack Exec Approvals", - "help": "Slack-native exec approval routing and approver authorization. Enable this only when Slack should act as an explicit exec-approval client for the selected workspace account.", + "help": "Slack-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for this workspace account.", "hasChildren": true }, { @@ -31545,7 +31545,7 @@ "network" ], "label": "Slack Exec Approvals Enabled", - "help": "Enable Slack exec approvals for this account. When false or unset, Slack messages/buttons cannot approve exec requests.", + "help": "Controls Slack native exec approvals for this account: unset or \"auto\" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them.", "hasChildren": false }, { @@ -35444,7 +35444,7 @@ "network" ], "label": "Telegram Exec Approvals", - "help": "Telegram-native exec approval routing and approver authorization. Enable this only when Telegram should act as an explicit exec-approval client for the selected bot account.", + "help": "Telegram-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for the selected bot account.", "hasChildren": true }, { @@ -35512,7 +35512,7 @@ "network" ], "label": "Telegram Exec Approvals Enabled", - "help": "Enable Telegram exec approvals for this account. When false or unset, Telegram messages/buttons cannot approve exec requests.", + "help": "Controls Telegram native exec approvals for this account: unset or \"auto\" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them.", "hasChildren": false }, { diff --git a/docs/.generated/config-baseline.jsonl b/docs/.generated/config-baseline.jsonl index 1c7379d15fb..6a257c01c6d 100644 --- a/docs/.generated/config-baseline.jsonl +++ b/docs/.generated/config-baseline.jsonl @@ -2830,12 +2830,12 @@ {"recordType":"path","path":"channels.slack.dms.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.slack.dms.*.historyLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.slack.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"channels.slack.execApprovals","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Slack Exec Approvals","help":"Slack-native exec approval routing and approver authorization. Enable this only when Slack should act as an explicit exec-approval client for the selected workspace account.","hasChildren":true} +{"recordType":"path","path":"channels.slack.execApprovals","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Slack Exec Approvals","help":"Slack-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for this workspace account.","hasChildren":true} {"recordType":"path","path":"channels.slack.execApprovals.agentFilter","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Slack Exec Approval Agent Filter","help":"Optional allowlist of agent IDs eligible for Slack exec approvals, for example `[\"main\", \"ops-agent\"]`. Use this to keep approval prompts scoped to the agents you actually operate from Slack.","hasChildren":true} {"recordType":"path","path":"channels.slack.execApprovals.agentFilter.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.slack.execApprovals.approvers","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Slack Exec Approval Approvers","help":"Slack user IDs allowed to approve exec requests for this workspace account. Use Slack user IDs or user targets such as `U123`, `user:U123`, or `<@U123>`. If you leave this unset, OpenClaw falls back to commands.ownerAllowFrom when possible.","hasChildren":true} {"recordType":"path","path":"channels.slack.execApprovals.approvers.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"channels.slack.execApprovals.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Slack Exec Approvals Enabled","help":"Enable Slack exec approvals for this account. When false or unset, Slack messages/buttons cannot approve exec requests.","hasChildren":false} +{"recordType":"path","path":"channels.slack.execApprovals.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Slack Exec Approvals Enabled","help":"Controls Slack native exec approvals for this account: unset or \"auto\" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them.","hasChildren":false} {"recordType":"path","path":"channels.slack.execApprovals.sessionFilter","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network","storage"],"label":"Slack Exec Approval Session Filter","help":"Optional session-key filters matched as substring or regex-style patterns before Slack approval routing is used. Use narrow patterns so Slack approvals only appear for intended sessions.","hasChildren":true} {"recordType":"path","path":"channels.slack.execApprovals.sessionFilter.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.slack.execApprovals.target","kind":"channel","type":"string","required":false,"enumValues":["dm","channel","both"],"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Slack Exec Approval Target","help":"Controls where Slack approval prompts are sent: \"dm\" sends to approver DMs (default), \"channel\" sends to the originating Slack chat/thread, and \"both\" sends to both. Channel delivery exposes the command text to the chat, so only use it in trusted channels.","hasChildren":false} @@ -3182,12 +3182,12 @@ {"recordType":"path","path":"channels.telegram.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.errorCooldownMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.errorPolicy","kind":"channel","type":"string","required":false,"enumValues":["always","once","silent"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"channels.telegram.execApprovals","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Telegram Exec Approvals","help":"Telegram-native exec approval routing and approver authorization. Enable this only when Telegram should act as an explicit exec-approval client for the selected bot account.","hasChildren":true} +{"recordType":"path","path":"channels.telegram.execApprovals","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Telegram Exec Approvals","help":"Telegram-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for the selected bot account.","hasChildren":true} {"recordType":"path","path":"channels.telegram.execApprovals.agentFilter","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Telegram Exec Approval Agent Filter","help":"Optional allowlist of agent IDs eligible for Telegram exec approvals, for example `[\"main\", \"ops-agent\"]`. Use this to keep approval prompts scoped to the agents you actually operate from Telegram.","hasChildren":true} {"recordType":"path","path":"channels.telegram.execApprovals.agentFilter.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.execApprovals.approvers","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Telegram Exec Approval Approvers","help":"Telegram user IDs allowed to approve exec requests for this bot account. Use numeric Telegram user IDs. If you leave this unset, OpenClaw falls back to numeric owner IDs inferred from channels.telegram.allowFrom and direct-message defaultTo when possible.","hasChildren":true} {"recordType":"path","path":"channels.telegram.execApprovals.approvers.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"channels.telegram.execApprovals.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Telegram Exec Approvals Enabled","help":"Enable Telegram exec approvals for this account. When false or unset, Telegram messages/buttons cannot approve exec requests.","hasChildren":false} +{"recordType":"path","path":"channels.telegram.execApprovals.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Telegram Exec Approvals Enabled","help":"Controls Telegram native exec approvals for this account: unset or \"auto\" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them.","hasChildren":false} {"recordType":"path","path":"channels.telegram.execApprovals.sessionFilter","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network","storage"],"label":"Telegram Exec Approval Session Filter","help":"Optional session-key filters matched as substring or regex-style patterns before Telegram approval routing is used. Use narrow patterns so Telegram approvals only appear for intended sessions.","hasChildren":true} {"recordType":"path","path":"channels.telegram.execApprovals.sessionFilter.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.execApprovals.target","kind":"channel","type":"string","required":false,"enumValues":["dm","channel","both"],"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Telegram Exec Approval Target","help":"Controls where Telegram approval prompts are sent: \"dm\" sends to approver DMs (default), \"channel\" sends to the originating Telegram chat/topic, and \"both\" sends to both. Channel delivery exposes the command text to the chat, so only use it in trusted groups/topics.","hasChildren":false} diff --git a/extensions/discord/src/approval-native.ts b/extensions/discord/src/approval-native.ts index d507e3f3e3e..992a0fe679e 100644 --- a/extensions/discord/src/approval-native.ts +++ b/extensions/discord/src/approval-native.ts @@ -4,6 +4,7 @@ import { createApproverRestrictedNativeApprovalCapability, splitChannelApprovalCapability, doesApprovalRequestMatchChannelAccount, + isChannelExecApprovalClientEnabledFromConfig, matchesApprovalRequestFilters, } from "openclaw/plugin-sdk/approval-runtime"; import type { DiscordExecApprovalConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; @@ -75,16 +76,18 @@ export function shouldHandleDiscordApprovalRequest(params: { ) { return false; } - if (!config) { - return true; - } - if (!config.enabled || approvers.length === 0) { + if ( + !isChannelExecApprovalClientEnabledFromConfig({ + enabled: config?.enabled, + approverCount: approvers.length, + }) + ) { return false; } return matchesApprovalRequestFilters({ request: params.request.request, - agentFilter: config.agentFilter, - sessionFilter: config.sessionFilter, + agentFilter: config?.agentFilter, + sessionFilter: config?.sessionFilter, }); } diff --git a/extensions/discord/src/exec-approvals.test.ts b/extensions/discord/src/exec-approvals.test.ts index ee9206ef3f1..2c5a169c4ce 100644 --- a/extensions/discord/src/exec-approvals.test.ts +++ b/extensions/discord/src/exec-approvals.test.ts @@ -22,34 +22,35 @@ function buildConfig( } describe("discord exec approvals", () => { - it("requires enablement and explicit or owner approvers", () => { + it("auto-enables when owner approvers resolve and disables only when forced off", () => { expect(isDiscordExecApprovalClientEnabled({ cfg: buildConfig() })).toBe(false); - expect(isDiscordExecApprovalClientEnabled({ cfg: buildConfig({ enabled: true }) })).toBe(false); expect( isDiscordExecApprovalClientEnabled({ - cfg: buildConfig({ enabled: true }, { allowFrom: ["123"] }), + cfg: buildConfig({ enabled: true }), }), ).toBe(false); expect( isDiscordExecApprovalClientEnabled({ - cfg: buildConfig({ enabled: true, approvers: ["123"] }), + cfg: buildConfig({ approvers: ["123"] }), }), ).toBe(true); expect( isDiscordExecApprovalClientEnabled({ cfg: { - ...buildConfig({ enabled: true }), + ...buildConfig(), commands: { ownerAllowFrom: ["discord:789"] }, } as OpenClawConfig, }), ).toBe(true); + expect( + isDiscordExecApprovalClientEnabled({ + cfg: buildConfig({ enabled: false, approvers: ["123"] }), + }), + ).toBe(false); }); it("prefers explicit approvers when configured", () => { - const cfg = buildConfig( - { enabled: true, approvers: ["456"] }, - { allowFrom: ["123"], defaultTo: "user:789" }, - ); + const cfg = buildConfig({ approvers: ["456"] }, { allowFrom: ["123"], defaultTo: "user:789" }); expect(getDiscordExecApprovalApprovers({ cfg })).toEqual(["456"]); expect(isDiscordExecApprovalApprover({ cfg, senderId: "456" })).toBe(true); @@ -72,7 +73,7 @@ describe("discord exec approvals", () => { it("falls back to commands.ownerAllowFrom for exec approvers", () => { const cfg = { - ...buildConfig({ enabled: true }), + ...buildConfig(), commands: { ownerAllowFrom: ["discord:123", "user:456", "789"] }, } as OpenClawConfig; diff --git a/extensions/discord/src/exec-approvals.ts b/extensions/discord/src/exec-approvals.ts index 557d9d4cdc2..53c982dee6b 100644 --- a/extensions/discord/src/exec-approvals.ts +++ b/extensions/discord/src/exec-approvals.ts @@ -1,4 +1,5 @@ import { getExecApprovalReplyMetadata } from "openclaw/plugin-sdk/approval-runtime"; +import { isChannelExecApprovalClientEnabledFromConfig } from "openclaw/plugin-sdk/approval-runtime"; import { resolveApprovalApprovers } from "openclaw/plugin-sdk/approval-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import type { DiscordExecApprovalConfig } from "openclaw/plugin-sdk/config-runtime"; @@ -53,14 +54,14 @@ export function isDiscordExecApprovalClientEnabled(params: { configOverride?: DiscordExecApprovalConfig | null; }): boolean { const config = params.configOverride ?? resolveDiscordAccount(params).config.execApprovals; - return Boolean( - config?.enabled && - getDiscordExecApprovalApprovers({ + return isChannelExecApprovalClientEnabledFromConfig({ + enabled: config?.enabled, + approverCount: getDiscordExecApprovalApprovers({ cfg: params.cfg, accountId: params.accountId, configOverride: params.configOverride, - }).length > 0, - ); + }).length, + }); } export function isDiscordExecApprovalApprover(params: { diff --git a/extensions/discord/src/monitor/exec-approvals.ts b/extensions/discord/src/monitor/exec-approvals.ts index 4b86207dcaf..69acc0c14cf 100644 --- a/extensions/discord/src/monitor/exec-approvals.ts +++ b/extensions/discord/src/monitor/exec-approvals.ts @@ -34,7 +34,10 @@ import { createDiscordApprovalCapability, shouldHandleDiscordApprovalRequest, } from "../approval-native.js"; -import { getDiscordExecApprovalApprovers } from "../exec-approvals.js"; +import { + getDiscordExecApprovalApprovers, + isDiscordExecApprovalClientEnabled, +} from "../exec-approvals.js"; import { createDiscordClient, stripUndefinedFields } from "../send.shared.js"; import { DiscordUiContainer } from "../ui.js"; @@ -484,7 +487,12 @@ export class DiscordExecApprovalHandler { gatewayUrl: this.opts.gatewayUrl, eventKinds: ["exec", "plugin"], nativeAdapter: createDiscordApprovalCapability(this.opts.config).native, - isConfigured: () => Boolean(this.opts.config.enabled && this.getApprovers().length > 0), + isConfigured: () => + isDiscordExecApprovalClientEnabled({ + cfg: this.opts.cfg, + accountId: this.opts.accountId, + configOverride: this.opts.config, + }), shouldHandle: (request) => this.shouldHandle(request), buildPendingContent: ({ request }) => { const actionRow = createApprovalActionRow(request); diff --git a/extensions/discord/src/monitor/provider.ts b/extensions/discord/src/monitor/provider.ts index eaa1b1e9f9a..8c855741500 100644 --- a/extensions/discord/src/monitor/provider.ts +++ b/extensions/discord/src/monitor/provider.ts @@ -20,12 +20,6 @@ import { } from "openclaw/plugin-sdk/config-runtime"; import type { OpenClawConfig, ReplyToMode } from "openclaw/plugin-sdk/config-runtime"; import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; -import { - GROUP_POLICY_BLOCKED_LABEL, - resolveOpenProviderRuntimeGroupPolicy, - resolveDefaultGroupPolicy, - warnMissingProviderGroupPolicyFallbackOnce, -} from "openclaw/plugin-sdk/runtime-group-policy"; import { createConnectedChannelStatusPatch } from "openclaw/plugin-sdk/gateway-runtime"; import { getPluginCommandSpecs } from "openclaw/plugin-sdk/plugin-runtime"; import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; @@ -38,9 +32,16 @@ import { } from "openclaw/plugin-sdk/runtime-env"; import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; import { createNonExitingRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import { + GROUP_POLICY_BLOCKED_LABEL, + resolveOpenProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, + warnMissingProviderGroupPolicyFallbackOnce, +} from "openclaw/plugin-sdk/runtime-group-policy"; import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime"; import { summarizeStringEntries } from "openclaw/plugin-sdk/text-runtime"; import { resolveDiscordAccount } from "../accounts.js"; +import { isDiscordExecApprovalClientEnabled } from "../exec-approvals.js"; import { fetchDiscordApplicationId } from "../probe.js"; import { normalizeDiscordToken } from "../token.js"; import { createDiscordVoiceCommand } from "../voice/command.js"; @@ -824,7 +825,11 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { // Initialize exec approvals handler if enabled const execApprovalsConfig = discordCfg.execApprovals ?? {}; - const execApprovalsHandler = execApprovalsConfig.enabled + const execApprovalsHandler = isDiscordExecApprovalClientEnabled({ + cfg, + accountId: account.accountId, + configOverride: execApprovalsConfig, + }) ? new DiscordExecApprovalHandler({ token, accountId: account.accountId, diff --git a/extensions/slack/src/config-ui-hints.ts b/extensions/slack/src/config-ui-hints.ts index ee5dfbb8ab9..d2aee4064b5 100644 --- a/extensions/slack/src/config-ui-hints.ts +++ b/extensions/slack/src/config-ui-hints.ts @@ -51,11 +51,11 @@ export const slackChannelConfigUiHints = { }, execApprovals: { label: "Slack Exec Approvals", - help: "Slack-native exec approval routing and approver authorization. Enable this only when Slack should act as an explicit exec-approval client for the selected workspace account.", + help: "Slack-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for this workspace account.", }, "execApprovals.enabled": { label: "Slack Exec Approvals Enabled", - help: "Enable Slack exec approvals for this account. When false or unset, Slack messages/buttons cannot approve exec requests.", + help: 'Controls Slack native exec approvals for this account: unset or "auto" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them.', }, "execApprovals.approvers": { label: "Slack Exec Approval Approvers", diff --git a/extensions/slack/src/exec-approvals.test.ts b/extensions/slack/src/exec-approvals.test.ts index e63f1f5ebe1..4829afc5aae 100644 --- a/extensions/slack/src/exec-approvals.test.ts +++ b/extensions/slack/src/exec-approvals.test.ts @@ -29,32 +29,36 @@ function buildConfig( } describe("slack exec approvals", () => { - it("requires enablement and explicit or owner approvers", () => { + it("auto-enables when owner approvers resolve and disables only when forced off", () => { expect(isSlackExecApprovalClientEnabled({ cfg: buildConfig() })).toBe(false); - expect(isSlackExecApprovalClientEnabled({ cfg: buildConfig({ enabled: true }) })).toBe(false); expect( isSlackExecApprovalClientEnabled({ - cfg: buildConfig({ enabled: true }, { allowFrom: ["U123"] }), + cfg: buildConfig({ enabled: true }), }), ).toBe(false); expect( isSlackExecApprovalClientEnabled({ - cfg: buildConfig({ enabled: true, approvers: ["U123"] }), + cfg: buildConfig({ approvers: ["U123"] }), }), ).toBe(true); expect( isSlackExecApprovalClientEnabled({ cfg: { - ...buildConfig({ enabled: true }), + ...buildConfig(), commands: { ownerAllowFrom: ["slack:U123OWNER"] }, } as OpenClawConfig, }), ).toBe(true); + expect( + isSlackExecApprovalClientEnabled({ + cfg: buildConfig({ enabled: false, approvers: ["U123"] }), + }), + ).toBe(false); }); it("prefers explicit approvers when configured", () => { const cfg = buildConfig( - { enabled: true, approvers: ["U456"] }, + { approvers: ["U456"] }, { allowFrom: ["U123"], defaultTo: "user:U789" }, ); diff --git a/extensions/slack/src/monitor/exec-approvals.ts b/extensions/slack/src/monitor/exec-approvals.ts index db3507ca9d6..9f0a5651f87 100644 --- a/extensions/slack/src/monitor/exec-approvals.ts +++ b/extensions/slack/src/monitor/exec-approvals.ts @@ -16,6 +16,7 @@ import { logError } from "openclaw/plugin-sdk/text-runtime"; import { slackNativeApprovalAdapter } from "../approval-native.js"; import { getSlackExecApprovalApprovers, + isSlackExecApprovalClientEnabled, normalizeSlackApproverId, shouldHandleSlackExecApprovalRequest, } from "../exec-approvals.js"; @@ -239,13 +240,10 @@ export class SlackExecApprovalHandler { gatewayUrl: opts.gatewayUrl, nativeAdapter: slackNativeApprovalAdapter.native, isConfigured: () => - Boolean( - opts.config.enabled && - getSlackExecApprovalApprovers({ - cfg: opts.cfg, - accountId: opts.accountId, - }).length > 0, - ), + isSlackExecApprovalClientEnabled({ + cfg: opts.cfg, + accountId: opts.accountId, + }), shouldHandle: (request) => this.shouldHandle(request), buildPendingContent: ({ request }) => ({ text: buildSlackPendingApprovalText(request), diff --git a/extensions/slack/src/monitor/provider.ts b/extensions/slack/src/monitor/provider.ts index 3c1ac90d55d..3c7a8906734 100644 --- a/extensions/slack/src/monitor/provider.ts +++ b/extensions/slack/src/monitor/provider.ts @@ -31,6 +31,7 @@ import { normalizeStringEntries } from "openclaw/plugin-sdk/text-runtime"; import { installRequestBodyLimitGuard } from "openclaw/plugin-sdk/webhook-request-guards"; import { resolveSlackAccount } from "../accounts.js"; import { resolveSlackWebClientOptions } from "../client.js"; +import { isSlackExecApprovalClientEnabled } from "../exec-approvals.js"; import { normalizeSlackWebhookPath, registerSlackHttpHandler } from "../http/index.js"; import { SLACK_TEXT_LIMIT } from "../limits.js"; import { resolveSlackChannelAllowlist, type SlackChannelResolution } from "../resolve-channels.js"; @@ -406,11 +407,14 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { : undefined; const handleSlackMessage = createSlackMessageHandler({ ctx, account, trackEvent }); - const execApprovalsHandler = slackCfg.execApprovals?.enabled + const execApprovalsHandler = isSlackExecApprovalClientEnabled({ + cfg, + accountId: account.accountId, + }) ? new SlackExecApprovalHandler({ app, accountId: account.accountId, - config: slackCfg.execApprovals, + config: slackCfg.execApprovals ?? {}, cfg, }) : null; diff --git a/extensions/telegram/src/config-ui-hints.ts b/extensions/telegram/src/config-ui-hints.ts index 7d7be2990b9..dee9c8e9490 100644 --- a/extensions/telegram/src/config-ui-hints.ts +++ b/extensions/telegram/src/config-ui-hints.ts @@ -83,11 +83,11 @@ export const telegramChannelConfigUiHints = { }, execApprovals: { label: "Telegram Exec Approvals", - help: "Telegram-native exec approval routing and approver authorization. Enable this only when Telegram should act as an explicit exec-approval client for the selected bot account.", + help: "Telegram-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for the selected bot account.", }, "execApprovals.enabled": { label: "Telegram Exec Approvals Enabled", - help: "Enable Telegram exec approvals for this account. When false or unset, Telegram messages/buttons cannot approve exec requests.", + help: 'Controls Telegram native exec approvals for this account: unset or "auto" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them.', }, "execApprovals.approvers": { label: "Telegram Exec Approval Approvers", diff --git a/extensions/telegram/src/exec-approvals-handler.ts b/extensions/telegram/src/exec-approvals-handler.ts index f33e81125e1..31dddd42a26 100644 --- a/extensions/telegram/src/exec-approvals-handler.ts +++ b/extensions/telegram/src/exec-approvals-handler.ts @@ -21,8 +21,7 @@ import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { telegramNativeApprovalAdapter } from "./approval-native.js"; import { resolveTelegramInlineButtons } from "./button-types.js"; import { - getTelegramExecApprovalApprovers, - resolveTelegramExecApprovalConfig, + isTelegramExecApprovalHandlerConfigured, shouldHandleTelegramExecApprovalRequest, } from "./exec-approvals.js"; import { editMessageReplyMarkupTelegram, sendMessageTelegram, sendTypingTelegram } from "./send.js"; @@ -56,19 +55,7 @@ export type TelegramExecApprovalHandlerDeps = { }; function isHandlerConfigured(params: { cfg: OpenClawConfig; accountId: string }): boolean { - const config = resolveTelegramExecApprovalConfig({ - cfg: params.cfg, - accountId: params.accountId, - }); - if (!config?.enabled) { - return false; - } - return ( - getTelegramExecApprovalApprovers({ - cfg: params.cfg, - accountId: params.accountId, - }).length > 0 - ); + return isTelegramExecApprovalHandlerConfigured(params); } export class TelegramExecApprovalHandler { diff --git a/extensions/telegram/src/exec-approvals.test.ts b/extensions/telegram/src/exec-approvals.test.ts index d807ab58f0c..a19a184c316 100644 --- a/extensions/telegram/src/exec-approvals.test.ts +++ b/extensions/telegram/src/exec-approvals.test.ts @@ -28,7 +28,7 @@ function buildConfig( } describe("telegram exec approvals", () => { - it("requires enablement and an explicit or inferred approver", () => { + it("auto-enables when approvers resolve and disables only when forced off", () => { expect(isTelegramExecApprovalClientEnabled({ cfg: buildConfig() })).toBe(false); expect( isTelegramExecApprovalClientEnabled({ @@ -37,18 +37,23 @@ describe("telegram exec approvals", () => { ).toBe(false); expect( isTelegramExecApprovalClientEnabled({ - cfg: buildConfig({ enabled: true }, { allowFrom: ["123"] }), + cfg: buildConfig(undefined, { allowFrom: ["123"] }), }), ).toBe(true); expect( isTelegramExecApprovalClientEnabled({ - cfg: buildConfig({ enabled: true, approvers: ["123"] }), + cfg: buildConfig({ approvers: ["123"] }), }), ).toBe(true); + expect( + isTelegramExecApprovalClientEnabled({ + cfg: buildConfig({ enabled: false, approvers: ["123"] }), + }), + ).toBe(false); }); it("matches approvers by normalized sender id", () => { - const cfg = buildConfig({ enabled: true, approvers: [123, "456"] }); + const cfg = buildConfig({ approvers: [123, "456"] }); expect(isTelegramExecApprovalApprover({ cfg, senderId: "123" })).toBe(true); expect(isTelegramExecApprovalApprover({ cfg, senderId: "456" })).toBe(true); expect(isTelegramExecApprovalApprover({ cfg, senderId: "789" })).toBe(false); diff --git a/extensions/telegram/src/exec-approvals.ts b/extensions/telegram/src/exec-approvals.ts index 9f814c26260..2be80c92f9d 100644 --- a/extensions/telegram/src/exec-approvals.ts +++ b/extensions/telegram/src/exec-approvals.ts @@ -1,5 +1,6 @@ import { createChannelExecApprovalProfile, + isChannelExecApprovalClientEnabledFromConfig, isChannelExecApprovalTargetRecipient, resolveApprovalRequestAccountId, resolveApprovalApprovers, @@ -138,3 +139,13 @@ export function shouldSuppressLocalTelegramExecApprovalPrompt(params: { }): boolean { return telegramExecApprovalProfile.shouldSuppressLocalPrompt(params); } + +export function isTelegramExecApprovalHandlerConfigured(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): boolean { + return isChannelExecApprovalClientEnabledFromConfig({ + enabled: resolveTelegramExecApprovalConfig(params)?.enabled, + approverCount: getTelegramExecApprovalApprovers(params).length, + }); +} diff --git a/extensions/telegram/src/monitor.ts b/extensions/telegram/src/monitor.ts index 183e715537d..140405b99e2 100644 --- a/extensions/telegram/src/monitor.ts +++ b/extensions/telegram/src/monitor.ts @@ -8,6 +8,7 @@ import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime"; import { resolveTelegramAccount } from "./accounts.js"; import { resolveTelegramAllowedUpdates } from "./allowed-updates.js"; +import { isTelegramExecApprovalHandlerConfigured } from "./exec-approvals.js"; import { resolveTelegramTransport } from "./fetch.js"; import { isRecoverableTelegramNetworkError, @@ -145,13 +146,15 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { if (opts.useWebhook) { const { TelegramExecApprovalHandler, startTelegramWebhook } = await loadTelegramMonitorWebhookRuntime(); - execApprovalsHandler = new TelegramExecApprovalHandler({ - token, - accountId: account.accountId, - cfg, - runtime: opts.runtime, - }); - await execApprovalsHandler.start(); + if (isTelegramExecApprovalHandlerConfigured({ cfg, accountId: account.accountId })) { + execApprovalsHandler = new TelegramExecApprovalHandler({ + token, + accountId: account.accountId, + cfg, + runtime: opts.runtime, + }); + await execApprovalsHandler.start(); + } await startTelegramWebhook({ token, accountId: account.accountId, @@ -177,13 +180,15 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { writeTelegramUpdateOffset, } = await loadTelegramMonitorPollingRuntime(); - execApprovalsHandler = new TelegramExecApprovalHandler({ - token, - accountId: account.accountId, - cfg, - runtime: opts.runtime, - }); - await execApprovalsHandler.start(); + if (isTelegramExecApprovalHandlerConfigured({ cfg, accountId: account.accountId })) { + execApprovalsHandler = new TelegramExecApprovalHandler({ + token, + accountId: account.accountId, + cfg, + runtime: opts.runtime, + }); + await execApprovalsHandler.start(); + } const persistedOffsetRaw = await readTelegramUpdateOffset({ accountId: account.accountId, diff --git a/src/config/bundled-channel-config-metadata.generated.ts b/src/config/bundled-channel-config-metadata.generated.ts index 8d2f1a324c2..a04ee4d9147 100644 --- a/src/config/bundled-channel-config-metadata.generated.ts +++ b/src/config/bundled-channel-config-metadata.generated.ts @@ -11566,11 +11566,11 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ }, execApprovals: { label: "Slack Exec Approvals", - help: "Slack-native exec approval routing and approver authorization. Enable this only when Slack should act as an explicit exec-approval client for the selected workspace account.", + help: "Slack-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for this workspace account.", }, "execApprovals.enabled": { label: "Slack Exec Approvals Enabled", - help: "Enable Slack exec approvals for this account. When false or unset, Slack messages/buttons cannot approve exec requests.", + help: 'Controls Slack native exec approvals for this account: unset or "auto" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them.', }, "execApprovals.approvers": { label: "Slack Exec Approval Approvers", @@ -13721,11 +13721,11 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ }, execApprovals: { label: "Telegram Exec Approvals", - help: "Telegram-native exec approval routing and approver authorization. Enable this only when Telegram should act as an explicit exec-approval client for the selected bot account.", + help: "Telegram-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for the selected bot account.", }, "execApprovals.enabled": { label: "Telegram Exec Approvals Enabled", - help: "Enable Telegram exec approvals for this account. When false or unset, Telegram messages/buttons cannot approve exec requests.", + help: 'Controls Telegram native exec approvals for this account: unset or "auto" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them.', }, "execApprovals.approvers": { label: "Telegram Exec Approval Approvers", diff --git a/src/config/types.approvals.ts b/src/config/types.approvals.ts index 1b174dc4fce..26a23e6a345 100644 --- a/src/config/types.approvals.ts +++ b/src/config/types.approvals.ts @@ -1,3 +1,5 @@ +export type NativeExecApprovalEnableMode = boolean | "auto"; + export type ExecApprovalForwardingMode = "session" | "targets" | "both"; export type ExecApprovalForwardTarget = { diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index cb03a1a069d..39be42606c0 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -140,8 +140,8 @@ export type DiscordVoiceConfig = { }; export type DiscordExecApprovalConfig = { - /** Enable exec approval forwarding to Discord DMs. Default: false. */ - enabled?: boolean; + /** Enable mode for Discord exec approvals on this account. Default: auto when approvers can be resolved; false disables. */ + enabled?: import("./types.approvals.js").NativeExecApprovalEnableMode; /** Discord user IDs to receive approval prompts. Optional: falls back to commands.ownerAllowFrom when possible. */ approvers?: string[]; /** Only forward approvals for these agent IDs. Omit = all agents. */ diff --git a/src/config/types.slack.ts b/src/config/types.slack.ts index 2fb6404b7d9..62b344d63cf 100644 --- a/src/config/types.slack.ts +++ b/src/config/types.slack.ts @@ -52,8 +52,8 @@ export type SlackStreamingMode = "off" | "partial" | "block" | "progress"; export type SlackLegacyStreamMode = "replace" | "status_final" | "append"; export type SlackExecApprovalTarget = "dm" | "channel" | "both"; export type SlackExecApprovalConfig = { - /** Enable Slack exec approvals for this account. Default: false. */ - enabled?: boolean; + /** Enable mode for Slack exec approvals on this account. Default: auto when approvers can be resolved; false disables. */ + enabled?: import("./types.approvals.js").NativeExecApprovalEnableMode; /** Slack user IDs allowed to approve exec requests. Optional: falls back to commands.ownerAllowFrom when possible. */ approvers?: Array; /** Only forward approvals for these agent IDs. Omit = all agents. */ diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index 166c7005efd..8e00357297a 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -59,8 +59,8 @@ export type TelegramStreamingMode = "off" | "partial" | "block" | "progress"; export type TelegramExecApprovalTarget = "dm" | "channel" | "both"; export type TelegramExecApprovalConfig = { - /** Enable Telegram exec approvals for this account. Default: false. */ - enabled?: boolean; + /** Enable mode for Telegram exec approvals on this account. Default: auto when approvers can be resolved; false disables. */ + enabled?: import("./types.approvals.js").NativeExecApprovalEnableMode; /** Telegram user IDs allowed to approve exec requests. Optional: falls back to numeric owner IDs inferred from allowFrom/defaultTo when possible. */ approvers?: Array; /** Only forward approvals for these agent IDs. Omit = all agents. */ diff --git a/src/infra/exec-approval-reply.test.ts b/src/infra/exec-approval-reply.test.ts index 2480094b865..5cbb7905b91 100644 --- a/src/infra/exec-approval-reply.test.ts +++ b/src/infra/exec-approval-reply.test.ts @@ -54,12 +54,12 @@ describe("exec approval reply helpers", () => { ); }); - it("mentions Slack in the fallback approval-client guidance", () => { + it("mentions native chat approval clients in the fallback guidance", () => { expect( buildExecApprovalUnavailableReplyPayload({ reason: "no-approval-route", }).text, - ).toContain("Discord, Slack, or Telegram exec approvals"); + ).toContain("native chat approval client such as Discord, Slack, or Telegram"); }); it.each(invalidReplyMetadataCases)( diff --git a/src/infra/exec-approval-reply.ts b/src/infra/exec-approval-reply.ts index 9ef7a831cbd..9583d420351 100644 --- a/src/infra/exec-approval-reply.ts +++ b/src/infra/exec-approval-reply.ts @@ -324,21 +324,21 @@ export function buildExecApprovalUnavailableReplyPayload( `Exec approval is required, but chat exec approvals are not enabled on ${params.channelLabel ?? "this platform"}.`, ); lines.push( - "Approve it from the Web UI or terminal UI, or enable Discord, Slack, or Telegram exec approvals. If those accounts already know your owner ID via allowFrom, OpenClaw can infer approvers automatically.", + "Approve it from the Web UI or terminal UI, or enable a native chat approval client such as Discord, Slack, or Telegram. If those accounts already know your owner ID via allowFrom or owner config, OpenClaw can often infer approvers automatically.", ); } else if (params.reason === "initiating-platform-unsupported") { lines.push( `Exec approval is required, but ${params.channelLabel ?? "this platform"} does not support chat exec approvals.`, ); lines.push( - "Approve it from the Web UI or terminal UI, or enable Discord, Slack, or Telegram exec approvals. If those accounts already know your owner ID via allowFrom, OpenClaw can infer approvers automatically.", + "Approve it from the Web UI or terminal UI, or enable a native chat approval client such as Discord, Slack, or Telegram. If those accounts already know your owner ID via allowFrom or owner config, OpenClaw can often infer approvers automatically.", ); } else { lines.push( "Exec approval is required, but no interactive approval client is currently available.", ); lines.push( - "Open the Web UI or terminal UI, or enable Discord, Slack, or Telegram exec approvals, then retry the command. If those accounts already know your owner ID via allowFrom, you can usually leave execApprovals.approvers unset.", + "Open the Web UI or terminal UI, or enable a native chat approval client such as Discord, Slack, or Telegram, then retry the command. If those accounts already know your owner ID via allowFrom or owner config, you can usually leave execApprovals.approvers unset.", ); } diff --git a/src/plugin-sdk/approval-client-helpers.test.ts b/src/plugin-sdk/approval-client-helpers.test.ts index 180de987a51..926aadf661a 100644 --- a/src/plugin-sdk/approval-client-helpers.test.ts +++ b/src/plugin-sdk/approval-client-helpers.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { createChannelExecApprovalProfile, + isChannelExecApprovalClientEnabledFromConfig, isChannelExecApprovalTargetRecipient, } from "./approval-client-helpers.js"; import type { OpenClawConfig } from "./config-runtime.js"; @@ -76,6 +77,37 @@ describe("createChannelExecApprovalProfile", () => { matchesRequestAccount: ({ accountId }) => accountId !== "other", }); + it("treats unset enabled as auto and false as disabled", () => { + expect( + isChannelExecApprovalClientEnabledFromConfig({ + approverCount: 1, + }), + ).toBe(true); + expect( + isChannelExecApprovalClientEnabledFromConfig({ + enabled: "auto", + approverCount: 1, + }), + ).toBe(true); + expect( + isChannelExecApprovalClientEnabledFromConfig({ + enabled: true, + approverCount: 1, + }), + ).toBe(true); + expect( + isChannelExecApprovalClientEnabledFromConfig({ + enabled: false, + approverCount: 1, + }), + ).toBe(false); + expect( + isChannelExecApprovalClientEnabledFromConfig({ + approverCount: 0, + }), + ).toBe(false); + }); + it("reuses shared client, auth, and request-filter logic", () => { expect(profile.isClientEnabled({ cfg: {} })).toBe(true); expect(profile.isApprover({ cfg: {}, senderId: "owner" })).toBe(true); diff --git a/src/plugin-sdk/approval-client-helpers.ts b/src/plugin-sdk/approval-client-helpers.ts index 3dec937723d..ab111597273 100644 --- a/src/plugin-sdk/approval-client-helpers.ts +++ b/src/plugin-sdk/approval-client-helpers.ts @@ -9,9 +9,10 @@ import { normalizeAccountId } from "./routing.js"; type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest; type ApprovalTarget = "dm" | "channel" | "both"; +type ChannelExecApprovalEnableMode = boolean | "auto"; type ChannelApprovalConfig = { - enabled?: boolean; + enabled?: ChannelExecApprovalEnableMode; target?: ApprovalTarget; agentFilter?: string[]; sessionFilter?: string[]; @@ -35,6 +36,16 @@ function isApprovalTargetsMode(cfg: OpenClawConfig): boolean { return execApprovals.mode === "targets" || execApprovals.mode === "both"; } +export function isChannelExecApprovalClientEnabledFromConfig(params: { + enabled?: ChannelExecApprovalEnableMode; + approverCount: number; +}): boolean { + if (params.approverCount <= 0) { + return false; + } + return params.enabled !== false; +} + export function isChannelExecApprovalTargetRecipient(params: { cfg: OpenClawConfig; senderId?: string | null; @@ -91,7 +102,10 @@ export function createChannelExecApprovalProfile(params: { const isClientEnabled = (input: ApprovalProfileParams): boolean => { const config = params.resolveConfig(input); - return Boolean(config?.enabled && params.resolveApprovers(input).length > 0); + return isChannelExecApprovalClientEnabledFromConfig({ + enabled: config?.enabled, + approverCount: params.resolveApprovers(input).length, + }); }; const isApprover = (input: ApprovalProfileParams & { senderId?: string | null }): boolean => { @@ -119,16 +133,19 @@ export function createChannelExecApprovalProfile(params: { return false; } const config = params.resolveConfig(input); - if (!config?.enabled) { - return false; - } - if (params.resolveApprovers(input).length === 0) { + const approverCount = params.resolveApprovers(input).length; + if ( + !isChannelExecApprovalClientEnabledFromConfig({ + enabled: config?.enabled, + approverCount, + }) + ) { return false; } return matchesApprovalRequestFilters({ request: input.request.request, - agentFilter: config.agentFilter, - sessionFilter: config.sessionFilter, + agentFilter: config?.agentFilter, + sessionFilter: config?.sessionFilter, fallbackAgentIdFromSessionKey: params.fallbackAgentIdFromSessionKey === true, }); }; diff --git a/src/plugin-sdk/approval-runtime.ts b/src/plugin-sdk/approval-runtime.ts index f6eb8adcfdc..82faf12dae9 100644 --- a/src/plugin-sdk/approval-runtime.ts +++ b/src/plugin-sdk/approval-runtime.ts @@ -40,6 +40,7 @@ export { export { createResolvedApproverActionAuthAdapter } from "./approval-auth-helpers.js"; export { createChannelExecApprovalProfile, + isChannelExecApprovalClientEnabledFromConfig, isChannelExecApprovalTargetRecipient, } from "./approval-client-helpers.js"; export {