feat(slack): add native exec approvals (#58155)

* feat(slack): add native exec approvals

* feat(slack): wire native exec approvals

* Update CHANGELOG.md

* fix(slack): gate native approvals by request filters

* fix(slack): keep local approval prompt path
This commit is contained in:
Vincent Koc
2026-03-31 16:20:57 +09:00
committed by GitHub
parent 2feb83babb
commit 5ec362fe0b
13 changed files with 861 additions and 34 deletions

View File

@@ -28,9 +28,9 @@ Docs: https://docs.openclaw.ai
- Diffs: skip unused viewer-versus-file SSR preload work so `diffs` view-only and file-only runs do less render work while keeping mode outputs aligned. (#57909) thanks @gumadeiras.
- Matrix/threads: add per-DM `threadReplies` overrides and keep thread session isolation aligned with the effective room or DM thread policy from the triggering message onward. (#57995) thanks @teconomix.
- TTS: Add structured provider diagnostics and fallback attempt analytics. (#57954) Thanks @joshavant.
- Slack/exec approvals: add native Slack approval routing and approver authorization so exec approval prompts can stay in Slack instead of falling back to the Web UI or terminal. Thanks @vincentkoc.
### Fixes
- Image generation/build: write stable runtime alias files into `dist/` and route provider-auth runtime lookups through those aliases so image-generation providers keep resolving auth/runtime modules after rebuilds instead of crashing on missing hashed chunk files.
- Config/runtime: pin the first successful config load in memory for the running process and refresh that snapshot on successful writes/reloads, so hot paths stop reparsing `openclaw.json` between watcher-driven swaps.
- Config/legacy cleanup: stop probing obsolete alternate legacy config names and service labels during local config/service detection, while keeping the active `~/.openclaw/openclaw.json` path canonical.

View File

@@ -3,12 +3,18 @@ import { slackApprovalAuth } from "./approval-auth.js";
describe("slackApprovalAuth", () => {
it("authorizes inferred Slack approvers by user id", () => {
const cfg = { channels: { slack: { allowFrom: ["U_OWNER"] } } };
const cfg = {
channels: {
slack: {
execApprovals: { enabled: true, approvers: ["user:U123OWNER"] },
},
},
};
expect(
slackApprovalAuth.authorizeActorAction({
cfg,
senderId: "U_OWNER",
senderId: "U123OWNER",
action: "approve",
approvalKind: "exec",
}),
@@ -17,7 +23,7 @@ describe("slackApprovalAuth", () => {
expect(
slackApprovalAuth.authorizeActorAction({
cfg,
senderId: "U_ATTACKER",
senderId: "U999ATTACKER",
action: "approve",
approvalKind: "exec",
}),

View File

@@ -1,33 +1,13 @@
import {
createResolvedApproverActionAuthAdapter,
resolveApprovalApprovers,
} from "openclaw/plugin-sdk/approval-runtime";
import { resolveSlackAccount } from "./accounts.js";
import { parseSlackTarget } from "./targets.js";
function normalizeSlackApproverId(value: string | number): string | undefined {
const trimmed = String(value).trim();
if (!trimmed) {
return undefined;
}
try {
const target = parseSlackTarget(trimmed, { defaultKind: "user" });
return target?.kind === "user" ? target.id : undefined;
} catch {
return /^[A-Z0-9]+$/i.test(trimmed) ? trimmed : undefined;
}
}
import {
getSlackExecApprovalApprovers,
normalizeSlackApproverId,
} from "./exec-approvals.js";
export const slackApprovalAuth = createResolvedApproverActionAuthAdapter({
channelLabel: "Slack",
resolveApprovers: ({ cfg, accountId }) => {
const account = resolveSlackAccount({ cfg, accountId }).config;
return resolveApprovalApprovers({
allowFrom: account.allowFrom,
extraAllowFrom: account.dm?.allowFrom,
defaultTo: account.defaultTo,
normalizeApprover: normalizeSlackApproverId,
});
},
resolveApprovers: ({ cfg, accountId }) => getSlackExecApprovalApprovers({ cfg, accountId }),
normalizeSenderId: (value) => normalizeSlackApproverId(value),
});

View File

@@ -0,0 +1,203 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { describe, expect, it } from "vitest";
import { slackNativeApprovalAdapter } from "./approval-native.js";
function buildConfig(
overrides?: Partial<NonNullable<NonNullable<OpenClawConfig["channels"]>["slack"]>>,
): OpenClawConfig {
return {
channels: {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
execApprovals: {
enabled: true,
approvers: ["U123APPROVER"],
target: "both",
},
...overrides,
},
},
} as OpenClawConfig;
}
describe("slack native approval adapter", () => {
it("describes native slack approval delivery capabilities", () => {
const capabilities = slackNativeApprovalAdapter.native?.describeDeliveryCapabilities({
cfg: buildConfig(),
accountId: "default",
approvalKind: "exec",
request: {
id: "req-1",
request: {
command: "echo hi",
turnSourceChannel: "slack",
turnSourceTo: "channel:C123",
turnSourceAccountId: "default",
sessionKey: "agent:main:slack:channel:c123",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
});
expect(capabilities).toEqual({
enabled: true,
preferredSurface: "both",
supportsOriginSurface: true,
supportsApproverDmSurface: true,
notifyOriginWhenDmOnly: true,
});
});
it("resolves origin targets from slack turn source", async () => {
const target = await slackNativeApprovalAdapter.native?.resolveOriginTarget?.({
cfg: buildConfig(),
accountId: "default",
approvalKind: "exec",
request: {
id: "req-1",
request: {
command: "echo hi",
turnSourceChannel: "slack",
turnSourceTo: "channel:C123",
turnSourceAccountId: "default",
turnSourceThreadId: "1712345678.123456",
sessionKey: "agent:main:slack:channel:c123:thread:1712345678.123456",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
});
expect(target).toEqual({
to: "channel:C123",
threadId: "1712345678.123456",
});
});
it("keeps origin delivery when session and turn source thread ids differ only by Slack timestamp precision", async () => {
const target = await slackNativeApprovalAdapter.native?.resolveOriginTarget?.({
cfg: buildConfig(),
accountId: "default",
approvalKind: "exec",
request: {
id: "req-1",
request: {
command: "echo hi",
turnSourceChannel: "slack",
turnSourceTo: "channel:C123",
turnSourceAccountId: "default",
turnSourceThreadId: "1712345678.123456",
sessionKey: "agent:main:slack:channel:c123:thread:1712345678.123456",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
});
expect(target).toEqual({
to: "channel:C123",
threadId: "1712345678.123456",
});
});
it("resolves approver dm targets", async () => {
const targets = await slackNativeApprovalAdapter.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:U123APPROVER" }]);
});
it("skips native delivery when agent filters do not match", async () => {
const cfg = buildConfig({
execApprovals: {
enabled: true,
approvers: ["U123APPROVER"],
target: "both",
agentFilter: ["ops-agent"],
},
});
const originTarget = await slackNativeApprovalAdapter.native?.resolveOriginTarget?.({
cfg,
accountId: "default",
approvalKind: "exec",
request: {
id: "req-1",
request: {
command: "echo hi",
agentId: "other-agent",
turnSourceChannel: "slack",
turnSourceTo: "channel:C123",
turnSourceAccountId: "default",
sessionKey: "agent:other-agent:slack:channel:c123",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
});
const dmTargets = await slackNativeApprovalAdapter.native?.resolveApproverDmTargets?.({
cfg,
accountId: "default",
approvalKind: "exec",
request: {
id: "req-1",
request: {
command: "echo hi",
agentId: "other-agent",
sessionKey: "agent:other-agent:slack:channel:c123",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
});
expect(originTarget).toBeNull();
expect(dmTargets).toEqual([]);
});
it("suppresses generic slack fallback only for slack-originated approvals", () => {
const shouldSuppress = slackNativeApprovalAdapter.delivery.shouldSuppressForwardingFallback;
if (!shouldSuppress) {
throw new Error("slack native delivery suppression unavailable");
}
expect(
shouldSuppress({
cfg: buildConfig(),
target: { channel: "slack", accountId: "default" },
request: {
request: {
turnSourceChannel: "slack",
turnSourceAccountId: "default",
},
},
}),
).toBe(true);
expect(
shouldSuppress({
cfg: buildConfig(),
target: { channel: "slack", accountId: "default" },
request: {
request: {
turnSourceChannel: "discord",
turnSourceAccountId: "default",
},
},
}),
).toBe(false);
});
});

View File

@@ -0,0 +1,213 @@
import {
createApproverRestrictedNativeApprovalAdapter,
resolveExecApprovalSessionTarget,
} from "openclaw/plugin-sdk/approval-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type {
ExecApprovalRequest,
ExecApprovalSessionTarget,
PluginApprovalRequest,
} from "openclaw/plugin-sdk/infra-runtime";
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
import { listSlackAccountIds } from "./accounts.js";
import {
getSlackExecApprovalApprovers,
isSlackExecApprovalApprover,
isSlackExecApprovalAuthorizedSender,
isSlackExecApprovalClientEnabled,
resolveSlackExecApprovalTarget,
shouldHandleSlackExecApprovalRequest,
} from "./exec-approvals.js";
import { parseSlackTarget } from "./targets.js";
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
type SlackOriginTarget = { to: string; threadId?: string; accountId?: string };
function isExecApprovalRequest(request: ApprovalRequest): request is ExecApprovalRequest {
return "command" in request.request;
}
function toExecLikeRequest(request: ApprovalRequest): ExecApprovalRequest {
if (isExecApprovalRequest(request)) {
return request;
}
return {
id: request.id,
request: {
command: request.request.title,
sessionKey: request.request.sessionKey ?? undefined,
turnSourceChannel: request.request.turnSourceChannel ?? undefined,
turnSourceTo: request.request.turnSourceTo ?? undefined,
turnSourceAccountId: request.request.turnSourceAccountId ?? undefined,
turnSourceThreadId: request.request.turnSourceThreadId ?? undefined,
},
createdAtMs: request.createdAtMs,
expiresAtMs: request.expiresAtMs,
};
}
function extractSlackSessionKind(sessionKey?: string | null): "direct" | "channel" | "group" | null {
if (!sessionKey) {
return null;
}
const match = sessionKey.match(/slack:(direct|channel|group):/i);
return match?.[1] ? (match[1].toLowerCase() as "direct" | "channel" | "group") : null;
}
function normalizeComparableTarget(value: string): string {
return value.trim().toLowerCase();
}
function normalizeSlackThreadMatchKey(threadId?: string): string {
const trimmed = threadId?.trim();
if (!trimmed) {
return "";
}
const leadingEpoch = trimmed.match(/^\d+/)?.[0];
return leadingEpoch ?? trimmed;
}
function resolveRequestSessionTarget(params: {
cfg: OpenClawConfig;
request: ApprovalRequest;
}): ExecApprovalSessionTarget | null {
const execLikeRequest = toExecLikeRequest(params.request);
return resolveExecApprovalSessionTarget({
cfg: params.cfg,
request: execLikeRequest,
turnSourceChannel: execLikeRequest.request.turnSourceChannel ?? undefined,
turnSourceTo: execLikeRequest.request.turnSourceTo ?? undefined,
turnSourceAccountId: execLikeRequest.request.turnSourceAccountId ?? undefined,
turnSourceThreadId: execLikeRequest.request.turnSourceThreadId ?? undefined,
});
}
function resolveTurnSourceSlackOriginTarget(params: {
accountId: string;
request: ApprovalRequest;
}): SlackOriginTarget | null {
const turnSourceChannel = params.request.request.turnSourceChannel?.trim().toLowerCase() || "";
const turnSourceTo = params.request.request.turnSourceTo?.trim() || "";
const turnSourceAccountId = params.request.request.turnSourceAccountId?.trim() || "";
if (turnSourceChannel !== "slack" || !turnSourceTo) {
return null;
}
if (
turnSourceAccountId &&
normalizeAccountId(turnSourceAccountId) !== normalizeAccountId(params.accountId)
) {
return null;
}
const sessionKind = extractSlackSessionKind(params.request.request.sessionKey ?? undefined);
const parsed = parseSlackTarget(turnSourceTo, {
defaultKind: sessionKind === "direct" ? "user" : "channel",
});
if (!parsed) {
return null;
}
const threadId =
typeof params.request.request.turnSourceThreadId === "string"
? params.request.request.turnSourceThreadId.trim() || undefined
: typeof params.request.request.turnSourceThreadId === "number"
? String(params.request.request.turnSourceThreadId)
: undefined;
return {
to: `${parsed.kind}:${parsed.id}`,
threadId,
accountId: turnSourceAccountId || undefined,
};
}
function resolveSessionSlackOriginTarget(params: {
cfg: OpenClawConfig;
accountId: string;
request: ApprovalRequest;
}): SlackOriginTarget | null {
const sessionTarget = resolveRequestSessionTarget(params);
if (!sessionTarget || sessionTarget.channel !== "slack") {
return null;
}
if (
sessionTarget.accountId &&
normalizeAccountId(sessionTarget.accountId) !== normalizeAccountId(params.accountId)
) {
return null;
}
return {
to: sessionTarget.to,
threadId:
typeof sessionTarget.threadId === "string"
? sessionTarget.threadId
: typeof sessionTarget.threadId === "number"
? String(sessionTarget.threadId)
: undefined,
accountId: sessionTarget.accountId ?? undefined,
};
}
function slackTargetsMatch(a: SlackOriginTarget, b: SlackOriginTarget): boolean {
const accountMatches =
!a.accountId ||
!b.accountId ||
normalizeAccountId(a.accountId) === normalizeAccountId(b.accountId);
return (
normalizeComparableTarget(a.to) === normalizeComparableTarget(b.to) &&
normalizeSlackThreadMatchKey(a.threadId) === normalizeSlackThreadMatchKey(b.threadId) &&
accountMatches
);
}
function resolveSlackOriginTarget(params: {
cfg: OpenClawConfig;
accountId: string;
request: ApprovalRequest;
}) {
if (!shouldHandleSlackExecApprovalRequest(params)) {
return null;
}
const turnSourceTarget = resolveTurnSourceSlackOriginTarget(params);
const sessionTarget = resolveSessionSlackOriginTarget(params);
if (turnSourceTarget && sessionTarget && !slackTargetsMatch(turnSourceTarget, sessionTarget)) {
return null;
}
const target = turnSourceTarget ?? sessionTarget;
return target ? { to: target.to, threadId: target.threadId } : null;
}
function resolveSlackApproverDmTargets(params: {
cfg: OpenClawConfig;
accountId?: string | null;
request: ApprovalRequest;
}) {
if (!shouldHandleSlackExecApprovalRequest(params)) {
return [];
}
return getSlackExecApprovalApprovers({
cfg: params.cfg,
accountId: params.accountId,
}).map((approver) => ({ to: `user:${approver}` }));
}
export const slackNativeApprovalAdapter = createApproverRestrictedNativeApprovalAdapter({
channel: "slack",
channelLabel: "Slack",
listAccountIds: listSlackAccountIds,
hasApprovers: ({ cfg, accountId }) =>
getSlackExecApprovalApprovers({ cfg, accountId }).length > 0,
isExecAuthorizedSender: ({ cfg, accountId, senderId }) =>
isSlackExecApprovalAuthorizedSender({ cfg, accountId, senderId }),
isPluginAuthorizedSender: ({ cfg, accountId, senderId }) =>
isSlackExecApprovalApprover({ cfg, accountId, senderId }),
isNativeDeliveryEnabled: ({ cfg, accountId }) =>
isSlackExecApprovalClientEnabled({ cfg, accountId }),
resolveNativeDeliveryMode: ({ cfg, accountId }) =>
resolveSlackExecApprovalTarget({ cfg, accountId }),
requireMatchingTurnSourceChannel: true,
resolveSuppressionAccountId: ({ target, request }) =>
target.accountId?.trim() || request.request.turnSourceAccountId?.trim() || undefined,
resolveOriginTarget: ({ cfg, accountId, request }) =>
accountId ? resolveSlackOriginTarget({ cfg, accountId, request }) : null,
resolveApproverDmTargets: ({ cfg, accountId, request }) =>
resolveSlackApproverDmTargets({ cfg, accountId, request }),
notifyOriginWhenDmOnly: true,
});

View File

@@ -38,7 +38,7 @@ import {
} from "./accounts.js";
import type { SlackActionContext } from "./action-runtime.js";
import { resolveSlackAutoThreadId } from "./action-threading.js";
import { slackApprovalAuth } from "./approval-auth.js";
import { slackNativeApprovalAdapter } from "./approval-native.js";
import { createSlackActions } from "./channel-actions.js";
import { resolveSlackChannelType } from "./channel-type.js";
import {
@@ -282,7 +282,11 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
}),
resolveNames: resolveSlackAllowlistNames,
},
auth: slackApprovalAuth,
auth: slackNativeApprovalAdapter.auth,
approvals: {
delivery: slackNativeApprovalAdapter.delivery,
native: slackNativeApprovalAdapter.native,
},
groups: {
resolveRequireMention: resolveSlackGroupRequireMention,
resolveToolPolicy: resolveSlackGroupToolPolicy,

View File

@@ -49,6 +49,30 @@ export const slackChannelConfigUiHints = {
label: "Slack Interactive Replies",
help: "Enable agent-authored Slack interactive reply directives (`[[slack_buttons: ...]]`, `[[slack_select: ...]]`). Default: false.",
},
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.",
},
"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.",
},
"execApprovals.approvers": {
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 owner IDs inferred from channels.slack.allowFrom, channels.slack.dm.allowFrom, and defaultTo when possible.",
},
"execApprovals.agentFilter": {
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.',
},
"execApprovals.sessionFilter": {
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.",
},
"execApprovals.target": {
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.',
},
streaming: {
label: "Slack Streaming Mode",
help: 'Unified Slack stream preview mode: "off" | "partial" | "block" | "progress". Legacy boolean/streamMode keys are auto-mapped.',

View File

@@ -0,0 +1,198 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { describe, expect, it } from "vitest";
import {
getSlackExecApprovalApprovers,
isSlackExecApprovalApprover,
isSlackExecApprovalAuthorizedSender,
isSlackExecApprovalClientEnabled,
isSlackExecApprovalTargetRecipient,
normalizeSlackApproverId,
resolveSlackExecApprovalTarget,
shouldHandleSlackExecApprovalRequest,
shouldSuppressLocalSlackExecApprovalPrompt,
} from "./exec-approvals.js";
function buildConfig(
execApprovals?: NonNullable<NonNullable<OpenClawConfig["channels"]>["slack"]>["execApprovals"],
channelOverrides?: Partial<NonNullable<NonNullable<OpenClawConfig["channels"]>["slack"]>>,
): OpenClawConfig {
return {
channels: {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
...channelOverrides,
execApprovals,
},
},
} as OpenClawConfig;
}
describe("slack exec approvals", () => {
it("requires enablement and an explicit or inferred approver", () => {
expect(isSlackExecApprovalClientEnabled({ cfg: buildConfig() })).toBe(false);
expect(isSlackExecApprovalClientEnabled({ cfg: buildConfig({ enabled: true }) })).toBe(false);
expect(
isSlackExecApprovalClientEnabled({
cfg: buildConfig({ enabled: true }, { allowFrom: ["U123"] }),
}),
).toBe(true);
expect(
isSlackExecApprovalClientEnabled({
cfg: buildConfig({ enabled: true, approvers: ["U123"] }),
}),
).toBe(true);
});
it("prefers explicit approvers when configured", () => {
const cfg = buildConfig(
{ enabled: true, approvers: ["U456"] },
{ allowFrom: ["U123"], defaultTo: "user:U789" },
);
expect(getSlackExecApprovalApprovers({ cfg })).toEqual(["U456"]);
expect(isSlackExecApprovalApprover({ cfg, senderId: "U456" })).toBe(true);
expect(isSlackExecApprovalApprover({ cfg, senderId: "U123" })).toBe(false);
});
it("infers approvers from allowFrom, dm.allowFrom, and DM defaultTo", () => {
const cfg = buildConfig(
{ enabled: true },
{
allowFrom: ["slack:U123"],
dm: { allowFrom: ["<@U456>"] },
defaultTo: "user:U789",
},
);
expect(getSlackExecApprovalApprovers({ cfg })).toEqual(["U123", "U456", "U789"]);
expect(isSlackExecApprovalApprover({ cfg, senderId: "U789" })).toBe(true);
});
it("ignores non-user default targets when inferring approvers", () => {
const cfg = buildConfig(
{ enabled: true },
{
defaultTo: "channel:C123",
},
);
expect(getSlackExecApprovalApprovers({ cfg })).toEqual([]);
});
it("defaults target to dm", () => {
expect(resolveSlackExecApprovalTarget({ cfg: buildConfig({ enabled: true, approvers: ["U1"] }) })).toBe("dm");
});
it("matches slack target recipients from generic approval forwarding targets", () => {
const cfg = {
channels: {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
},
},
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [
{ channel: "slack", to: "user:U123TARGET" },
{ channel: "slack", to: "channel:C123" },
],
},
},
} as OpenClawConfig;
expect(isSlackExecApprovalTargetRecipient({ cfg, senderId: "U123TARGET" })).toBe(true);
expect(isSlackExecApprovalTargetRecipient({ cfg, senderId: "U999OTHER" })).toBe(false);
expect(isSlackExecApprovalAuthorizedSender({ cfg, senderId: "U123TARGET" })).toBe(true);
});
it("keeps the local Slack approval prompt path active", () => {
const payload = {
channelData: {
execApproval: {
approvalId: "req-1",
approvalSlug: "req-1",
},
},
};
expect(
shouldSuppressLocalSlackExecApprovalPrompt({
cfg: buildConfig({ enabled: true, approvers: ["U123"] }),
payload,
}),
).toBe(false);
expect(
shouldSuppressLocalSlackExecApprovalPrompt({
cfg: buildConfig(),
payload,
}),
).toBe(false);
});
it("normalizes wrapped sender ids", () => {
expect(normalizeSlackApproverId("user:U123OWNER")).toBe("U123OWNER");
expect(normalizeSlackApproverId("<@U123OWNER>")).toBe("U123OWNER");
});
it("applies agent and session filters to request handling", () => {
const cfg = buildConfig({
enabled: true,
approvers: ["U123"],
agentFilter: ["ops-agent"],
sessionFilter: ["slack:direct:", "tail$"],
});
expect(
shouldHandleSlackExecApprovalRequest({
cfg,
request: {
id: "req-1",
request: {
command: "echo hi",
agentId: "ops-agent",
sessionKey: "agent:ops-agent:slack:direct:U123:tail",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
}),
).toBe(true);
expect(
shouldHandleSlackExecApprovalRequest({
cfg,
request: {
id: "req-2",
request: {
command: "echo hi",
agentId: "other-agent",
sessionKey: "agent:other-agent:slack:direct:U123:tail",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
}),
).toBe(false);
expect(
shouldHandleSlackExecApprovalRequest({
cfg,
request: {
id: "req-3",
request: {
command: "echo hi",
agentId: "ops-agent",
sessionKey: "agent:ops-agent:discord:channel:123",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
}),
).toBe(false);
});
});

View File

@@ -0,0 +1,166 @@
import {
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 type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
import { resolveSlackAccount } from "./accounts.js";
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
export function normalizeSlackApproverId(value: string | number): string | undefined {
const trimmed = String(value).trim();
if (!trimmed) {
return undefined;
}
const prefixed = trimmed.match(/^(?:slack|user):([A-Z0-9]+)$/i);
if (prefixed?.[1]) {
return prefixed[1];
}
const mention = trimmed.match(/^<@([A-Z0-9]+)>$/i);
if (mention?.[1]) {
return mention[1];
}
return /^[UW][A-Z0-9]+$/i.test(trimmed) ? trimmed : undefined;
}
function matchesSlackApprovalSessionFilter(sessionKey: string, patterns: string[]): boolean {
const boundedSessionKey = sessionKey.slice(0, 2048);
return patterns.some((pattern) => {
if (boundedSessionKey.includes(pattern)) {
return true;
}
try {
return new RegExp(pattern).test(boundedSessionKey);
} catch {
return false;
}
});
}
export function shouldHandleSlackExecApprovalRequest(params: {
cfg: OpenClawConfig;
accountId?: string | null;
request: ApprovalRequest;
}): boolean {
const config = resolveSlackAccount(params).config.execApprovals;
if (!config?.enabled) {
return false;
}
if (getSlackExecApprovalApprovers(params).length === 0) {
return false;
}
if (config.agentFilter?.length) {
const agentId = params.request.request.agentId?.trim();
if (!agentId || !config.agentFilter.includes(agentId)) {
return false;
}
}
if (config.sessionFilter?.length) {
const sessionKey = params.request.request.sessionKey?.trim();
if (!sessionKey || !matchesSlackApprovalSessionFilter(sessionKey, config.sessionFilter)) {
return false;
}
}
return true;
}
export function getSlackExecApprovalApprovers(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): string[] {
const account = resolveSlackAccount(params).config;
return resolveApprovalApprovers({
explicit: account.execApprovals?.approvers,
allowFrom: account.allowFrom,
extraAllowFrom: account.dm?.allowFrom,
defaultTo: account.defaultTo,
normalizeApprover: normalizeSlackApproverId,
normalizeDefaultTo: normalizeSlackApproverId,
});
}
export function isSlackExecApprovalClientEnabled(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): boolean {
const config = resolveSlackAccount(params).config.execApprovals;
return Boolean(config?.enabled && getSlackExecApprovalApprovers(params).length > 0);
}
export function isSlackExecApprovalApprover(params: {
cfg: OpenClawConfig;
accountId?: string | null;
senderId?: string | null;
}): boolean {
const senderId = params.senderId ? normalizeSlackApproverId(params.senderId) : undefined;
if (!senderId) {
return false;
}
return getSlackExecApprovalApprovers(params).includes(senderId);
}
function isSlackExecApprovalTargetsMode(cfg: OpenClawConfig): boolean {
const execApprovals = cfg.approvals?.exec;
if (!execApprovals?.enabled) {
return false;
}
return execApprovals.mode === "targets" || execApprovals.mode === "both";
}
export function isSlackExecApprovalTargetRecipient(params: {
cfg: OpenClawConfig;
senderId?: string | null;
accountId?: string | null;
}): boolean {
const senderId = params.senderId ? normalizeSlackApproverId(params.senderId) : undefined;
if (!senderId || !isSlackExecApprovalTargetsMode(params.cfg)) {
return false;
}
const targets = params.cfg.approvals?.exec?.targets;
if (!targets) {
return false;
}
const accountId = params.accountId ? normalizeAccountId(params.accountId) : undefined;
return targets.some((target) => {
if (target.channel?.trim().toLowerCase() !== "slack") {
return false;
}
if (accountId && target.accountId && normalizeAccountId(target.accountId) !== accountId) {
return false;
}
return normalizeSlackApproverId(target.to) === senderId;
});
}
export function isSlackExecApprovalAuthorizedSender(params: {
cfg: OpenClawConfig;
accountId?: string | null;
senderId?: string | null;
}): boolean {
return isSlackExecApprovalApprover(params) || isSlackExecApprovalTargetRecipient(params);
}
export function resolveSlackExecApprovalTarget(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): "dm" | "channel" | "both" {
return resolveSlackAccount(params).config.execApprovals?.target ?? "dm";
}
export function shouldSuppressLocalSlackExecApprovalPrompt(params: {
cfg: OpenClawConfig;
accountId?: string | null;
payload: ReplyPayload;
}): boolean {
void params;
// Slack still uses the generic local pending-reply path. Unlike Discord and
// Telegram, there is no Slack runtime handler that sends a replacement native
// approval prompt via resolveChannelNativeApprovalDeliveryPlan, so suppressing
// the local payload can hide the only visible approval prompt.
return false;
}

View File

@@ -50,6 +50,19 @@ export type SlackChannelConfig = {
export type SlackReactionNotificationMode = "off" | "own" | "all" | "allowlist";
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;
/** Slack user IDs allowed to approve exec requests. Optional: falls back to owner IDs inferred from allowFrom/defaultTo when possible. */
approvers?: Array<string | number>;
/** Only forward approvals for these agent IDs. Omit = all agents. */
agentFilter?: string[];
/** Only forward approvals matching these session key patterns (substring or regex). */
sessionFilter?: string[];
/** Where to send approval prompts. Default: "dm". */
target?: SlackExecApprovalTarget;
};
export type SlackCapabilitiesConfig =
| string[]
| {
@@ -98,6 +111,8 @@ export type SlackAccountConfig = {
webhookPath?: string;
/** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: SlackCapabilitiesConfig;
/** Slack-native exec approval delivery + approver authorization. */
execApprovals?: SlackExecApprovalConfig;
/** Markdown formatting overrides (tables). */
markdown?: MarkdownConfig;
/** Override native command registration for Slack (bool or "auto"). */

View File

@@ -866,6 +866,16 @@ export const SlackAccountSchema = z
signingSecret: SecretInputSchema.optional().register(sensitive),
webhookPath: z.string().optional(),
capabilities: SlackCapabilitiesSchema.optional(),
execApprovals: z
.object({
enabled: z.boolean().optional(),
approvers: z.array(z.union([z.string(), z.number()])).optional(),
agentFilter: z.array(z.string()).optional(),
sessionFilter: z.array(z.string()).optional(),
target: z.enum(["dm", "channel", "both"]).optional(),
})
.strict()
.optional(),
markdown: MarkdownConfigSchema,
enabled: z.boolean().optional(),
commands: ProviderCommandsSchema,

View File

@@ -54,6 +54,14 @@ describe("exec approval reply helpers", () => {
);
});
it("mentions Slack in the fallback approval-client guidance", () => {
expect(
buildExecApprovalUnavailableReplyPayload({
reason: "no-approval-route",
}).text,
).toContain("Discord, Slack, or Telegram exec approvals");
});
it.each(invalidReplyMetadataCases)(
"returns null for invalid reply metadata payload: $name",
({ payload }) => {

View File

@@ -284,21 +284,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 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 Discord, Slack, or Telegram exec approvals. If those accounts already know your owner ID via allowFrom, OpenClaw can 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 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 Discord, Slack, or Telegram exec approvals. If those accounts already know your owner ID via allowFrom, OpenClaw can 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 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 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.",
);
}