Merge 044b60bf2d into 09be5d45d5
This commit is contained in:
commit
b80fc5e041
@ -187,6 +187,40 @@ Reply in chat:
|
|||||||
/approve <id> deny
|
/approve <id> deny
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Inline approval buttons
|
||||||
|
|
||||||
|
For Discord and Slack, you can enable inline buttons instead of text commands:
|
||||||
|
|
||||||
|
**Discord:**
|
||||||
|
```yaml
|
||||||
|
channels:
|
||||||
|
discord:
|
||||||
|
execApprovals:
|
||||||
|
enabled: true
|
||||||
|
approvers: ["123456789012345678"] # Discord user IDs
|
||||||
|
agentFilter: ["main"] # optional
|
||||||
|
sessionFilter: ["discord"] # optional
|
||||||
|
```
|
||||||
|
|
||||||
|
**Slack:**
|
||||||
|
```yaml
|
||||||
|
channels:
|
||||||
|
slack:
|
||||||
|
execApprovals:
|
||||||
|
enabled: true
|
||||||
|
approvers: ["U12345678"] # Slack user IDs
|
||||||
|
agentFilter: ["main"] # optional
|
||||||
|
sessionFilter: ["slack"] # optional
|
||||||
|
```
|
||||||
|
|
||||||
|
When enabled, approval requests show Block Kit (Slack) or component (Discord) buttons:
|
||||||
|
- **Allow once** → run now
|
||||||
|
- **Always allow** → add to allowlist + run
|
||||||
|
- **Deny** → block
|
||||||
|
|
||||||
|
Messages update in-place when resolved or expired. The text forwarder is automatically
|
||||||
|
skipped for channels with inline buttons enabled.
|
||||||
|
|
||||||
### macOS IPC flow
|
### macOS IPC flow
|
||||||
```
|
```
|
||||||
Gateway -> Node Service (WS)
|
Gateway -> Node Service (WS)
|
||||||
|
|||||||
@ -75,6 +75,17 @@ export type SlackThreadConfig = {
|
|||||||
inheritParent?: boolean;
|
inheritParent?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SlackExecApprovalConfig = {
|
||||||
|
/** Enable exec approval forwarding to Slack DMs with inline buttons. Default: false. */
|
||||||
|
enabled?: boolean;
|
||||||
|
/** Slack user IDs to receive approval prompts. Required if enabled. */
|
||||||
|
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[];
|
||||||
|
};
|
||||||
|
|
||||||
export type SlackAccountConfig = {
|
export type SlackAccountConfig = {
|
||||||
/** Optional display name for this account (used in CLI/UI lists). */
|
/** Optional display name for this account (used in CLI/UI lists). */
|
||||||
name?: string;
|
name?: string;
|
||||||
@ -142,6 +153,8 @@ export type SlackAccountConfig = {
|
|||||||
channels?: Record<string, SlackChannelConfig>;
|
channels?: Record<string, SlackChannelConfig>;
|
||||||
/** Heartbeat visibility settings for this channel. */
|
/** Heartbeat visibility settings for this channel. */
|
||||||
heartbeat?: ChannelHeartbeatVisibilityConfig;
|
heartbeat?: ChannelHeartbeatVisibilityConfig;
|
||||||
|
/** Exec approval forwarding with inline buttons. */
|
||||||
|
execApprovals?: SlackExecApprovalConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SlackConfig = {
|
export type SlackConfig = {
|
||||||
|
|||||||
@ -459,6 +459,15 @@ export const SlackAccountSchema = z
|
|||||||
dm: SlackDmSchema.optional(),
|
dm: SlackDmSchema.optional(),
|
||||||
channels: z.record(z.string(), SlackChannelSchema.optional()).optional(),
|
channels: z.record(z.string(), SlackChannelSchema.optional()).optional(),
|
||||||
heartbeat: ChannelHeartbeatVisibilitySchema,
|
heartbeat: ChannelHeartbeatVisibilitySchema,
|
||||||
|
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(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
|||||||
@ -158,6 +158,30 @@ function defaultResolveSessionTarget(params: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSlackExecApprovalsEnabled(cfg: ClawdbotConfig, accountId?: string | null): boolean {
|
||||||
|
const slackCfg = cfg.channels?.slack as
|
||||||
|
| {
|
||||||
|
execApprovals?: { enabled?: boolean; approvers?: Array<string | number> };
|
||||||
|
accounts?: Record<
|
||||||
|
string,
|
||||||
|
{ execApprovals?: { enabled?: boolean; approvers?: Array<string | number> } }
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
if (!slackCfg) return false;
|
||||||
|
// Check account-specific config first
|
||||||
|
if (accountId && slackCfg.accounts?.[accountId]?.execApprovals?.enabled) {
|
||||||
|
const approvers = slackCfg.accounts[accountId].execApprovals?.approvers;
|
||||||
|
return Array.isArray(approvers) && approvers.length > 0;
|
||||||
|
}
|
||||||
|
// Fall back to top-level config
|
||||||
|
if (slackCfg.execApprovals?.enabled) {
|
||||||
|
const approvers = slackCfg.execApprovals?.approvers;
|
||||||
|
return Array.isArray(approvers) && approvers.length > 0;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
async function deliverToTargets(params: {
|
async function deliverToTargets(params: {
|
||||||
cfg: OpenClawConfig;
|
cfg: OpenClawConfig;
|
||||||
targets: ForwardTarget[];
|
targets: ForwardTarget[];
|
||||||
@ -169,6 +193,10 @@ async function deliverToTargets(params: {
|
|||||||
if (params.shouldSend && !params.shouldSend()) return;
|
if (params.shouldSend && !params.shouldSend()) return;
|
||||||
const channel = normalizeMessageChannel(target.channel) ?? target.channel;
|
const channel = normalizeMessageChannel(target.channel) ?? target.channel;
|
||||||
if (!isDeliverableMessageChannel(channel)) return;
|
if (!isDeliverableMessageChannel(channel)) return;
|
||||||
|
// Skip Slack text messages when Slack exec approval buttons are enabled
|
||||||
|
if (channel === "slack" && isSlackExecApprovalsEnabled(params.cfg, target.accountId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await params.deliver({
|
await params.deliver({
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
|
|||||||
195
src/slack/monitor/exec-approvals.test.ts
Normal file
195
src/slack/monitor/exec-approvals.test.ts
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
parseApprovalValue,
|
||||||
|
getExecApprovalActionIdPrefix,
|
||||||
|
SlackExecApprovalHandler,
|
||||||
|
type ExecApprovalRequest,
|
||||||
|
} from "./exec-approvals.js";
|
||||||
|
import type { SlackExecApprovalConfig } from "../../config/types.slack.js";
|
||||||
|
|
||||||
|
// Helper to encode approval values (mirrors internal implementation)
|
||||||
|
function encodeApprovalValue(
|
||||||
|
approvalId: string,
|
||||||
|
action: "allow-once" | "allow-always" | "deny",
|
||||||
|
): string {
|
||||||
|
return ["execapproval", encodeURIComponent(approvalId), action].join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("getExecApprovalActionIdPrefix", () => {
|
||||||
|
it("returns the action ID prefix", () => {
|
||||||
|
expect(getExecApprovalActionIdPrefix()).toBe("clawdbot_execapproval");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseApprovalValue", () => {
|
||||||
|
it("parses valid value", () => {
|
||||||
|
const value = encodeApprovalValue("abc-123", "allow-once");
|
||||||
|
const result = parseApprovalValue(value);
|
||||||
|
expect(result).toEqual({ approvalId: "abc-123", action: "allow-once" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses encoded approval id", () => {
|
||||||
|
const value = encodeApprovalValue("abc|123", "allow-always");
|
||||||
|
const result = parseApprovalValue(value);
|
||||||
|
expect(result).toEqual({ approvalId: "abc|123", action: "allow-always" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles special characters", () => {
|
||||||
|
const value = encodeApprovalValue("test=approval&id", "deny");
|
||||||
|
const result = parseApprovalValue(value);
|
||||||
|
expect(result).toEqual({ approvalId: "test=approval&id", action: "deny" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid action", () => {
|
||||||
|
const value = "execapproval|abc-123|invalid";
|
||||||
|
const result = parseApprovalValue(value);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing parts", () => {
|
||||||
|
expect(parseApprovalValue("execapproval|abc-123")).toBeNull();
|
||||||
|
expect(parseApprovalValue("execapproval")).toBeNull();
|
||||||
|
expect(parseApprovalValue("")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects undefined input", () => {
|
||||||
|
expect(parseApprovalValue(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects wrong prefix", () => {
|
||||||
|
const value = "wrongprefix|abc-123|allow-once";
|
||||||
|
const result = parseApprovalValue(value);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts all valid actions", () => {
|
||||||
|
expect(parseApprovalValue(encodeApprovalValue("x", "allow-once"))?.action).toBe("allow-once");
|
||||||
|
expect(parseApprovalValue(encodeApprovalValue("x", "allow-always"))?.action).toBe(
|
||||||
|
"allow-always",
|
||||||
|
);
|
||||||
|
expect(parseApprovalValue(encodeApprovalValue("x", "deny"))?.action).toBe("deny");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("roundtrip encoding", () => {
|
||||||
|
it("encodes and decodes correctly", () => {
|
||||||
|
const approvalId = "test-approval-with|special&chars";
|
||||||
|
const action = "allow-always" as const;
|
||||||
|
const encoded = encodeApprovalValue(approvalId, action);
|
||||||
|
const result = parseApprovalValue(encoded);
|
||||||
|
expect(result).toEqual({ approvalId, action });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SlackExecApprovalHandler.shouldHandle", () => {
|
||||||
|
function createHandler(config: SlackExecApprovalConfig) {
|
||||||
|
// Create a minimal mock WebClient
|
||||||
|
const mockClient = {} as any;
|
||||||
|
return new SlackExecApprovalHandler({
|
||||||
|
client: mockClient,
|
||||||
|
accountId: "default",
|
||||||
|
config,
|
||||||
|
cfg: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRequest(
|
||||||
|
overrides: Partial<ExecApprovalRequest["request"]> = {},
|
||||||
|
): ExecApprovalRequest {
|
||||||
|
return {
|
||||||
|
id: "test-id",
|
||||||
|
request: {
|
||||||
|
command: "echo hello",
|
||||||
|
cwd: "/home/user",
|
||||||
|
host: "gateway",
|
||||||
|
agentId: "test-agent",
|
||||||
|
sessionKey: "agent:test-agent:slack:123",
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
createdAtMs: Date.now(),
|
||||||
|
expiresAtMs: Date.now() + 60000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("returns false when disabled", () => {
|
||||||
|
const handler = createHandler({ enabled: false, approvers: ["U123"] });
|
||||||
|
expect(handler.shouldHandle(createRequest())).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when no approvers", () => {
|
||||||
|
const handler = createHandler({ enabled: true, approvers: [] });
|
||||||
|
expect(handler.shouldHandle(createRequest())).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true with minimal config", () => {
|
||||||
|
const handler = createHandler({ enabled: true, approvers: ["U123"] });
|
||||||
|
expect(handler.shouldHandle(createRequest())).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by agent ID", () => {
|
||||||
|
const handler = createHandler({
|
||||||
|
enabled: true,
|
||||||
|
approvers: ["U123"],
|
||||||
|
agentFilter: ["allowed-agent"],
|
||||||
|
});
|
||||||
|
expect(handler.shouldHandle(createRequest({ agentId: "allowed-agent" }))).toBe(true);
|
||||||
|
expect(handler.shouldHandle(createRequest({ agentId: "other-agent" }))).toBe(false);
|
||||||
|
expect(handler.shouldHandle(createRequest({ agentId: null }))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by session key substring", () => {
|
||||||
|
const handler = createHandler({
|
||||||
|
enabled: true,
|
||||||
|
approvers: ["U123"],
|
||||||
|
sessionFilter: ["slack"],
|
||||||
|
});
|
||||||
|
expect(handler.shouldHandle(createRequest({ sessionKey: "agent:test:slack:123" }))).toBe(true);
|
||||||
|
expect(handler.shouldHandle(createRequest({ sessionKey: "agent:test:telegram:123" }))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(handler.shouldHandle(createRequest({ sessionKey: null }))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by session key regex", () => {
|
||||||
|
const handler = createHandler({
|
||||||
|
enabled: true,
|
||||||
|
approvers: ["U123"],
|
||||||
|
sessionFilter: ["^agent:.*:slack:"],
|
||||||
|
});
|
||||||
|
expect(handler.shouldHandle(createRequest({ sessionKey: "agent:test:slack:123" }))).toBe(true);
|
||||||
|
expect(handler.shouldHandle(createRequest({ sessionKey: "other:test:slack:123" }))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("combines agent and session filters", () => {
|
||||||
|
const handler = createHandler({
|
||||||
|
enabled: true,
|
||||||
|
approvers: ["U123"],
|
||||||
|
agentFilter: ["my-agent"],
|
||||||
|
sessionFilter: ["slack"],
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
handler.shouldHandle(
|
||||||
|
createRequest({
|
||||||
|
agentId: "my-agent",
|
||||||
|
sessionKey: "agent:my-agent:slack:123",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
handler.shouldHandle(
|
||||||
|
createRequest({
|
||||||
|
agentId: "other-agent",
|
||||||
|
sessionKey: "agent:other:slack:123",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
handler.shouldHandle(
|
||||||
|
createRequest({
|
||||||
|
agentId: "my-agent",
|
||||||
|
sessionKey: "agent:my-agent:telegram:123",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
463
src/slack/monitor/exec-approvals.ts
Normal file
463
src/slack/monitor/exec-approvals.ts
Normal file
@ -0,0 +1,463 @@
|
|||||||
|
import type { WebClient } from "@slack/web-api";
|
||||||
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
|
import type { SlackExecApprovalConfig } from "../../config/types.slack.js";
|
||||||
|
import { GatewayClient } from "../../gateway/client.js";
|
||||||
|
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js";
|
||||||
|
import type { EventFrame } from "../../gateway/protocol/index.js";
|
||||||
|
import type { ExecApprovalDecision } from "../../infra/exec-approvals.js";
|
||||||
|
import { logDebug, logError } from "../../logger.js";
|
||||||
|
import type { RuntimeEnv } from "../../runtime.js";
|
||||||
|
|
||||||
|
const EXEC_APPROVAL_ACTION_ID_PREFIX = "clawdbot_execapproval";
|
||||||
|
const EXEC_APPROVAL_VALUE_PREFIX = "execapproval";
|
||||||
|
|
||||||
|
export type ExecApprovalRequest = {
|
||||||
|
id: string;
|
||||||
|
request: {
|
||||||
|
command: string;
|
||||||
|
cwd?: string | null;
|
||||||
|
host?: string | null;
|
||||||
|
security?: string | null;
|
||||||
|
ask?: string | null;
|
||||||
|
agentId?: string | null;
|
||||||
|
resolvedPath?: string | null;
|
||||||
|
sessionKey?: string | null;
|
||||||
|
};
|
||||||
|
createdAtMs: number;
|
||||||
|
expiresAtMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExecApprovalResolved = {
|
||||||
|
id: string;
|
||||||
|
decision: ExecApprovalDecision;
|
||||||
|
resolvedBy?: string | null;
|
||||||
|
ts: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PendingApproval = {
|
||||||
|
slackMessageTs: string;
|
||||||
|
slackChannelId: string;
|
||||||
|
timeoutId: NodeJS.Timeout;
|
||||||
|
};
|
||||||
|
|
||||||
|
function encodeApprovalValue(approvalId: string, action: ExecApprovalDecision): string {
|
||||||
|
return [EXEC_APPROVAL_VALUE_PREFIX, encodeURIComponent(approvalId), action].join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseApprovalValue(
|
||||||
|
value: string | undefined,
|
||||||
|
): { approvalId: string; action: ExecApprovalDecision } | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const parts = value.split("|");
|
||||||
|
if (parts.length !== 3 || parts[0] !== EXEC_APPROVAL_VALUE_PREFIX) return null;
|
||||||
|
const [, encodedId, action] = parts;
|
||||||
|
if (!encodedId || !action) return null;
|
||||||
|
if (action !== "allow-once" && action !== "allow-always" && action !== "deny") return null;
|
||||||
|
try {
|
||||||
|
return { approvalId: decodeURIComponent(encodedId), action };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getExecApprovalActionIdPrefix(): string {
|
||||||
|
return EXEC_APPROVAL_ACTION_ID_PREFIX;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchesExecApprovalActionId(actionId: string): boolean {
|
||||||
|
return actionId.startsWith(EXEC_APPROVAL_ACTION_ID_PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatApprovalBlocks(request: ExecApprovalRequest) {
|
||||||
|
const commandText = request.request.command;
|
||||||
|
const commandPreview =
|
||||||
|
commandText.length > 2000 ? `${commandText.slice(0, 2000)}...` : commandText;
|
||||||
|
const expiresAtUnix = Math.floor(request.expiresAtMs / 1000);
|
||||||
|
const expiresIn = Math.max(0, Math.round((request.expiresAtMs - Date.now()) / 1000));
|
||||||
|
const fallbackTime = new Date(request.expiresAtMs).toISOString();
|
||||||
|
|
||||||
|
const contextParts: string[] = [];
|
||||||
|
if (request.request.cwd) contextParts.push(`*CWD:* ${request.request.cwd}`);
|
||||||
|
if (request.request.host) contextParts.push(`*Host:* ${request.request.host}`);
|
||||||
|
if (request.request.agentId) contextParts.push(`*Agent:* ${request.request.agentId}`);
|
||||||
|
|
||||||
|
const blocks: Array<{
|
||||||
|
type: string;
|
||||||
|
text?: { type: string; text: string };
|
||||||
|
elements?: Array<
|
||||||
|
| { type: string; text: string } // context element (mrkdwn/plain_text)
|
||||||
|
| {
|
||||||
|
// button element
|
||||||
|
type: string;
|
||||||
|
text?: { type: string; text: string; emoji?: boolean };
|
||||||
|
action_id?: string;
|
||||||
|
value?: string;
|
||||||
|
style?: string;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
type: "header",
|
||||||
|
text: { type: "plain_text", text: "🔒 Exec Approval Required" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "section",
|
||||||
|
text: {
|
||||||
|
type: "mrkdwn",
|
||||||
|
text: `\`\`\`\n${commandPreview}\n\`\`\``,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (contextParts.length > 0) {
|
||||||
|
blocks.push({
|
||||||
|
type: "context",
|
||||||
|
elements: [{ type: "mrkdwn", text: contextParts.join(" | ") }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
blocks.push({
|
||||||
|
type: "context",
|
||||||
|
elements: [
|
||||||
|
{
|
||||||
|
type: "mrkdwn",
|
||||||
|
text: `Expires <!date^${expiresAtUnix}^{time}|${fallbackTime}> (${expiresIn}s) | ID: \`${request.id}\``,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
blocks.push({
|
||||||
|
type: "actions",
|
||||||
|
elements: [
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
text: { type: "plain_text", text: "✓ Allow once", emoji: true },
|
||||||
|
action_id: `${EXEC_APPROVAL_ACTION_ID_PREFIX}_allow_once`,
|
||||||
|
value: encodeApprovalValue(request.id, "allow-once"),
|
||||||
|
style: "primary",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
text: { type: "plain_text", text: "✓✓ Always allow", emoji: true },
|
||||||
|
action_id: `${EXEC_APPROVAL_ACTION_ID_PREFIX}_allow_always`,
|
||||||
|
value: encodeApprovalValue(request.id, "allow-always"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
text: { type: "plain_text", text: "✗ Deny", emoji: true },
|
||||||
|
action_id: `${EXEC_APPROVAL_ACTION_ID_PREFIX}_deny`,
|
||||||
|
value: encodeApprovalValue(request.id, "deny"),
|
||||||
|
style: "danger",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatResolvedBlocks(
|
||||||
|
request: ExecApprovalRequest,
|
||||||
|
decision: ExecApprovalDecision,
|
||||||
|
resolvedBy?: string | null,
|
||||||
|
) {
|
||||||
|
const commandText = request.request.command;
|
||||||
|
const commandPreview = commandText.length > 500 ? `${commandText.slice(0, 500)}...` : commandText;
|
||||||
|
|
||||||
|
const decisionLabel =
|
||||||
|
decision === "allow-once"
|
||||||
|
? "✅ Allowed (once)"
|
||||||
|
: decision === "allow-always"
|
||||||
|
? "✅ Allowed (always)"
|
||||||
|
: "❌ Denied";
|
||||||
|
|
||||||
|
const resolvedByText = resolvedBy ? ` by ${resolvedBy}` : "";
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: "header",
|
||||||
|
text: { type: "plain_text", text: `Exec Approval: ${decisionLabel}` },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "section",
|
||||||
|
text: {
|
||||||
|
type: "mrkdwn",
|
||||||
|
text: `\`\`\`\n${commandPreview}\n\`\`\``,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "context",
|
||||||
|
elements: [{ type: "mrkdwn", text: `Resolved${resolvedByText} | ID: \`${request.id}\`` }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatExpiredBlocks(request: ExecApprovalRequest) {
|
||||||
|
const commandText = request.request.command;
|
||||||
|
const commandPreview = commandText.length > 500 ? `${commandText.slice(0, 500)}...` : commandText;
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: "header",
|
||||||
|
text: { type: "plain_text", text: "⏱️ Exec Approval: Expired" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "section",
|
||||||
|
text: {
|
||||||
|
type: "mrkdwn",
|
||||||
|
text: `\`\`\`\n${commandPreview}\n\`\`\``,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "context",
|
||||||
|
elements: [{ type: "mrkdwn", text: `ID: \`${request.id}\`` }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SlackExecApprovalHandlerOpts = {
|
||||||
|
client: WebClient;
|
||||||
|
accountId: string;
|
||||||
|
config: SlackExecApprovalConfig;
|
||||||
|
gatewayUrl?: string;
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
runtime?: RuntimeEnv;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class SlackExecApprovalHandler {
|
||||||
|
private gatewayClient: GatewayClient | null = null;
|
||||||
|
private pending = new Map<string, PendingApproval>();
|
||||||
|
private requestCache = new Map<string, ExecApprovalRequest>();
|
||||||
|
private opts: SlackExecApprovalHandlerOpts;
|
||||||
|
private started = false;
|
||||||
|
|
||||||
|
constructor(opts: SlackExecApprovalHandlerOpts) {
|
||||||
|
this.opts = opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldHandle(request: ExecApprovalRequest): boolean {
|
||||||
|
const config = this.opts.config;
|
||||||
|
if (!config.enabled) return false;
|
||||||
|
if (!config.approvers || config.approvers.length === 0) return false;
|
||||||
|
|
||||||
|
// Check agent filter
|
||||||
|
if (config.agentFilter?.length) {
|
||||||
|
if (!request.request.agentId) return false;
|
||||||
|
if (!config.agentFilter.includes(request.request.agentId)) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check session filter (substring match)
|
||||||
|
if (config.sessionFilter?.length) {
|
||||||
|
const session = request.request.sessionKey;
|
||||||
|
if (!session) return false;
|
||||||
|
const matches = config.sessionFilter.some((p) => {
|
||||||
|
try {
|
||||||
|
return session.includes(p) || new RegExp(p).test(session);
|
||||||
|
} catch {
|
||||||
|
return session.includes(p);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!matches) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
if (this.started) return;
|
||||||
|
this.started = true;
|
||||||
|
|
||||||
|
const config = this.opts.config;
|
||||||
|
if (!config.enabled) {
|
||||||
|
logDebug("slack exec approvals: disabled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.approvers || config.approvers.length === 0) {
|
||||||
|
logDebug("slack exec approvals: no approvers configured");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logDebug("slack exec approvals: starting handler");
|
||||||
|
|
||||||
|
this.gatewayClient = new GatewayClient({
|
||||||
|
url: this.opts.gatewayUrl ?? "ws://127.0.0.1:18789",
|
||||||
|
clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
|
||||||
|
clientDisplayName: "Slack Exec Approvals",
|
||||||
|
mode: GATEWAY_CLIENT_MODES.BACKEND,
|
||||||
|
scopes: ["operator.approvals"],
|
||||||
|
onEvent: (evt) => this.handleGatewayEvent(evt),
|
||||||
|
onHelloOk: () => {
|
||||||
|
logDebug("slack exec approvals: connected to gateway");
|
||||||
|
},
|
||||||
|
onConnectError: (err) => {
|
||||||
|
logError(`slack exec approvals: connect error: ${err.message}`);
|
||||||
|
},
|
||||||
|
onClose: (code, reason) => {
|
||||||
|
logDebug(`slack exec approvals: gateway closed: ${code} ${reason}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.gatewayClient.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
if (!this.started) return;
|
||||||
|
this.started = false;
|
||||||
|
|
||||||
|
// Clear all pending timeouts
|
||||||
|
for (const pending of this.pending.values()) {
|
||||||
|
clearTimeout(pending.timeoutId);
|
||||||
|
}
|
||||||
|
this.pending.clear();
|
||||||
|
this.requestCache.clear();
|
||||||
|
|
||||||
|
this.gatewayClient?.stop();
|
||||||
|
this.gatewayClient = null;
|
||||||
|
|
||||||
|
logDebug("slack exec approvals: stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleGatewayEvent(evt: EventFrame): void {
|
||||||
|
if (evt.event === "exec.approval.requested") {
|
||||||
|
const request = evt.payload as ExecApprovalRequest;
|
||||||
|
void this.handleApprovalRequested(request);
|
||||||
|
} else if (evt.event === "exec.approval.resolved") {
|
||||||
|
const resolved = evt.payload as ExecApprovalResolved;
|
||||||
|
void this.handleApprovalResolved(resolved);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleApprovalRequested(request: ExecApprovalRequest): Promise<void> {
|
||||||
|
if (!this.shouldHandle(request)) return;
|
||||||
|
|
||||||
|
logDebug(`slack exec approvals: received request ${request.id}`);
|
||||||
|
|
||||||
|
this.requestCache.set(request.id, request);
|
||||||
|
|
||||||
|
const client = this.opts.client;
|
||||||
|
const blocks = formatApprovalBlocks(request);
|
||||||
|
const approvers = this.opts.config.approvers ?? [];
|
||||||
|
|
||||||
|
for (const approver of approvers) {
|
||||||
|
const userId = String(approver);
|
||||||
|
try {
|
||||||
|
// Open DM channel
|
||||||
|
const dmResponse = await client.conversations.open({ users: userId });
|
||||||
|
const channelId = dmResponse.channel?.id;
|
||||||
|
|
||||||
|
if (!channelId) {
|
||||||
|
logError(`slack exec approvals: failed to open DM for user ${userId}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send message with blocks
|
||||||
|
const msgResponse = await client.chat.postMessage({
|
||||||
|
channel: channelId,
|
||||||
|
text: `🔒 Exec approval required for: ${request.request.command.slice(0, 100)}...`,
|
||||||
|
blocks,
|
||||||
|
});
|
||||||
|
|
||||||
|
const messageTs = msgResponse.ts;
|
||||||
|
if (!messageTs) {
|
||||||
|
logError(`slack exec approvals: failed to send message to user ${userId}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up timeout
|
||||||
|
const timeoutMs = Math.max(0, request.expiresAtMs - Date.now());
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
void this.handleApprovalTimeout(request.id);
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
this.pending.set(request.id, {
|
||||||
|
slackMessageTs: messageTs,
|
||||||
|
slackChannelId: channelId,
|
||||||
|
timeoutId,
|
||||||
|
});
|
||||||
|
|
||||||
|
logDebug(`slack exec approvals: sent approval ${request.id} to user ${userId}`);
|
||||||
|
} catch (err) {
|
||||||
|
logError(`slack exec approvals: failed to notify user ${userId}: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleApprovalResolved(resolved: ExecApprovalResolved): Promise<void> {
|
||||||
|
const pending = this.pending.get(resolved.id);
|
||||||
|
if (!pending) return;
|
||||||
|
|
||||||
|
clearTimeout(pending.timeoutId);
|
||||||
|
this.pending.delete(resolved.id);
|
||||||
|
|
||||||
|
const request = this.requestCache.get(resolved.id);
|
||||||
|
this.requestCache.delete(resolved.id);
|
||||||
|
|
||||||
|
if (!request) return;
|
||||||
|
|
||||||
|
logDebug(`slack exec approvals: resolved ${resolved.id} with ${resolved.decision}`);
|
||||||
|
|
||||||
|
await this.updateMessage(
|
||||||
|
pending.slackChannelId,
|
||||||
|
pending.slackMessageTs,
|
||||||
|
formatResolvedBlocks(request, resolved.decision, resolved.resolvedBy),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleApprovalTimeout(approvalId: string): Promise<void> {
|
||||||
|
const pending = this.pending.get(approvalId);
|
||||||
|
if (!pending) return;
|
||||||
|
|
||||||
|
this.pending.delete(approvalId);
|
||||||
|
|
||||||
|
const request = this.requestCache.get(approvalId);
|
||||||
|
this.requestCache.delete(approvalId);
|
||||||
|
|
||||||
|
if (!request) return;
|
||||||
|
|
||||||
|
logDebug(`slack exec approvals: timeout for ${approvalId}`);
|
||||||
|
|
||||||
|
await this.updateMessage(
|
||||||
|
pending.slackChannelId,
|
||||||
|
pending.slackMessageTs,
|
||||||
|
formatExpiredBlocks(request),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateMessage(
|
||||||
|
channelId: string,
|
||||||
|
messageTs: string,
|
||||||
|
blocks: ReturnType<typeof formatExpiredBlocks>,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.opts.client.chat.update({
|
||||||
|
channel: channelId,
|
||||||
|
ts: messageTs,
|
||||||
|
text: "Exec approval resolved",
|
||||||
|
blocks,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logError(`slack exec approvals: failed to update message: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveApproval(approvalId: string, decision: ExecApprovalDecision): Promise<boolean> {
|
||||||
|
if (!this.gatewayClient) {
|
||||||
|
logError("slack exec approvals: gateway client not connected");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
logDebug(`slack exec approvals: resolving ${approvalId} with ${decision}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.gatewayClient.request("exec.approval.resolve", {
|
||||||
|
id: approvalId,
|
||||||
|
decision,
|
||||||
|
});
|
||||||
|
logDebug(`slack exec approvals: resolved ${approvalId} successfully`);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
logError(`slack exec approvals: resolve failed: ${String(err)}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -26,6 +26,11 @@ import { registerSlackMonitorSlashCommands } from "./slash.js";
|
|||||||
import { normalizeAllowList } from "./allow-list.js";
|
import { normalizeAllowList } from "./allow-list.js";
|
||||||
|
|
||||||
import type { MonitorSlackOpts } from "./types.js";
|
import type { MonitorSlackOpts } from "./types.js";
|
||||||
|
import {
|
||||||
|
SlackExecApprovalHandler,
|
||||||
|
getExecApprovalActionIdPrefix,
|
||||||
|
parseApprovalValue,
|
||||||
|
} from "./exec-approvals.js";
|
||||||
|
|
||||||
const slackBoltModule = SlackBolt as typeof import("@slack/bolt") & {
|
const slackBoltModule = SlackBolt as typeof import("@slack/bolt") & {
|
||||||
default?: typeof import("@slack/bolt");
|
default?: typeof import("@slack/bolt");
|
||||||
@ -210,6 +215,67 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
|||||||
|
|
||||||
registerSlackMonitorEvents({ ctx, account, handleSlackMessage });
|
registerSlackMonitorEvents({ ctx, account, handleSlackMessage });
|
||||||
registerSlackMonitorSlashCommands({ ctx, account });
|
registerSlackMonitorSlashCommands({ ctx, account });
|
||||||
|
|
||||||
|
// Exec approvals with inline buttons
|
||||||
|
let execApprovalHandler: SlackExecApprovalHandler | null = null;
|
||||||
|
const execApprovalConfig = slackCfg.execApprovals;
|
||||||
|
if (execApprovalConfig?.enabled && execApprovalConfig.approvers?.length) {
|
||||||
|
execApprovalHandler = new SlackExecApprovalHandler({
|
||||||
|
client: app.client,
|
||||||
|
accountId: account.accountId,
|
||||||
|
config: execApprovalConfig,
|
||||||
|
cfg,
|
||||||
|
runtime,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register action handler for approval buttons
|
||||||
|
const supportsActions = typeof (app as { action?: unknown }).action === "function";
|
||||||
|
if (supportsActions) {
|
||||||
|
(
|
||||||
|
app as unknown as {
|
||||||
|
action: (
|
||||||
|
id: string | RegExp,
|
||||||
|
handler: (args: {
|
||||||
|
ack: () => Promise<void>;
|
||||||
|
action: { action_id?: string; value?: string };
|
||||||
|
respond: (payload: { text: string; response_type?: string }) => Promise<void>;
|
||||||
|
}) => Promise<void>,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
).action(
|
||||||
|
new RegExp(`^${getExecApprovalActionIdPrefix()}_`),
|
||||||
|
async ({ ack, action, respond }) => {
|
||||||
|
await ack();
|
||||||
|
const parsed = parseApprovalValue(action?.value);
|
||||||
|
if (!parsed) {
|
||||||
|
await respond({
|
||||||
|
text: "This approval button is no longer valid.",
|
||||||
|
response_type: "ephemeral",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const decisionLabel =
|
||||||
|
parsed.action === "allow-once"
|
||||||
|
? "Allowed (once)"
|
||||||
|
: parsed.action === "allow-always"
|
||||||
|
? "Allowed (always)"
|
||||||
|
: "Denied";
|
||||||
|
await respond({
|
||||||
|
text: `Submitting decision: **${decisionLabel}**...`,
|
||||||
|
response_type: "ephemeral",
|
||||||
|
});
|
||||||
|
const ok = await execApprovalHandler!.resolveApproval(parsed.approvalId, parsed.action);
|
||||||
|
if (!ok) {
|
||||||
|
await respond({
|
||||||
|
text: "Failed to submit approval. The request may have expired or already been resolved.",
|
||||||
|
response_type: "ephemeral",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (slackMode === "http" && slackHttpHandler) {
|
if (slackMode === "http" && slackHttpHandler) {
|
||||||
unregisterHttpHandler = registerSlackHttpHandler({
|
unregisterHttpHandler = registerSlackHttpHandler({
|
||||||
path: slackWebhookPath,
|
path: slackWebhookPath,
|
||||||
@ -349,6 +415,10 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
|||||||
} else {
|
} else {
|
||||||
runtime.log?.(`slack http mode listening at ${slackWebhookPath}`);
|
runtime.log?.(`slack http mode listening at ${slackWebhookPath}`);
|
||||||
}
|
}
|
||||||
|
// Start exec approval handler after app is running
|
||||||
|
if (execApprovalHandler) {
|
||||||
|
await execApprovalHandler.start();
|
||||||
|
}
|
||||||
if (opts.abortSignal?.aborted) return;
|
if (opts.abortSignal?.aborted) return;
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
opts.abortSignal?.addEventListener("abort", () => resolve(), {
|
opts.abortSignal?.addEventListener("abort", () => resolve(), {
|
||||||
@ -358,6 +428,10 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
|||||||
} finally {
|
} finally {
|
||||||
opts.abortSignal?.removeEventListener("abort", stopOnAbort);
|
opts.abortSignal?.removeEventListener("abort", stopOnAbort);
|
||||||
unregisterHttpHandler?.();
|
unregisterHttpHandler?.();
|
||||||
|
// Stop exec approval handler before app stops
|
||||||
|
if (execApprovalHandler) {
|
||||||
|
await execApprovalHandler.stop();
|
||||||
|
}
|
||||||
await app.stop().catch(() => undefined);
|
await app.stop().catch(() => undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user