feat(discord): add exec approval forwarding to DMs (#1621)

* feat(discord): add exec approval forwarding to DMs

Add support for forwarding exec approval requests to Discord DMs,
allowing users to approve/deny command execution via interactive buttons.

Features:
- New DiscordExecApprovalHandler that connects to gateway and listens
  for exec.approval.requested/resolved events
- Sends DMs with embeds showing command details and 3 buttons:
  Allow once, Always allow, Deny
- Configurable via channels.discord.execApprovals with:
  - enabled: boolean
  - approvers: Discord user IDs to notify
  - agentFilter: only forward for specific agents
  - sessionFilter: only forward for matching session patterns
- Updates message embed when approval is resolved or expires

Also fixes exec completion routing: when async exec completes after
approval, the heartbeat now uses a specialized prompt to ensure the
model relays the result to the user instead of responding HEARTBEAT_OK.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: generic exec approvals forwarding (#1621) (thanks @czekaj)

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Lucas Czekaj
2026-01-24 12:56:40 -08:00
committed by GitHub
parent fe7436a1f6
commit 483fba41b9
22 changed files with 1511 additions and 14 deletions

View File

@@ -0,0 +1,77 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { createExecApprovalForwarder } from "./exec-approval-forwarder.js";
const baseRequest = {
id: "req-1",
request: {
command: "echo hello",
agentId: "main",
sessionKey: "agent:main:main",
},
createdAtMs: 1000,
expiresAtMs: 6000,
};
afterEach(() => {
vi.useRealTimers();
});
describe("exec approval forwarder", () => {
it("forwards to session target and resolves", async () => {
vi.useFakeTimers();
const deliver = vi.fn().mockResolvedValue([]);
const cfg = {
approvals: { exec: { enabled: true, mode: "session" } },
} as ClawdbotConfig;
const forwarder = createExecApprovalForwarder({
getConfig: () => cfg,
deliver,
nowMs: () => 1000,
resolveSessionTarget: () => ({ channel: "slack", to: "U1" }),
});
await forwarder.handleRequested(baseRequest);
expect(deliver).toHaveBeenCalledTimes(1);
await forwarder.handleResolved({
id: baseRequest.id,
decision: "allow-once",
resolvedBy: "slack:U1",
ts: 2000,
});
expect(deliver).toHaveBeenCalledTimes(2);
await vi.runAllTimersAsync();
expect(deliver).toHaveBeenCalledTimes(2);
});
it("forwards to explicit targets and expires", async () => {
vi.useFakeTimers();
const deliver = vi.fn().mockResolvedValue([]);
const cfg = {
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [{ channel: "telegram", to: "123" }],
},
},
} as ClawdbotConfig;
const forwarder = createExecApprovalForwarder({
getConfig: () => cfg,
deliver,
nowMs: () => 1000,
resolveSessionTarget: () => null,
});
await forwarder.handleRequested(baseRequest);
expect(deliver).toHaveBeenCalledTimes(1);
await vi.runAllTimersAsync();
expect(deliver).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,282 @@
import type { ClawdbotConfig } from "../config/config.js";
import { loadConfig } from "../config/config.js";
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
import type {
ExecApprovalForwardingConfig,
ExecApprovalForwardTarget,
} from "../config/types.approvals.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { parseAgentSessionKey } from "../routing/session-key.js";
import { isDeliverableMessageChannel, normalizeMessageChannel } from "../utils/message-channel.js";
import type { ExecApprovalDecision } from "./exec-approvals.js";
import { deliverOutboundPayloads } from "./outbound/deliver.js";
import { resolveSessionDeliveryTarget } from "./outbound/targets.js";
const log = createSubsystemLogger("gateway/exec-approvals");
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 ForwardTarget = ExecApprovalForwardTarget & { source: "session" | "target" };
type PendingApproval = {
request: ExecApprovalRequest;
targets: ForwardTarget[];
timeoutId: NodeJS.Timeout | null;
};
export type ExecApprovalForwarder = {
handleRequested: (request: ExecApprovalRequest) => Promise<void>;
handleResolved: (resolved: ExecApprovalResolved) => Promise<void>;
stop: () => void;
};
export type ExecApprovalForwarderDeps = {
getConfig?: () => ClawdbotConfig;
deliver?: typeof deliverOutboundPayloads;
nowMs?: () => number;
resolveSessionTarget?: (params: {
cfg: ClawdbotConfig;
request: ExecApprovalRequest;
}) => ExecApprovalForwardTarget | null;
};
const DEFAULT_MODE = "session" as const;
function normalizeMode(mode?: ExecApprovalForwardingConfig["mode"]) {
return mode ?? DEFAULT_MODE;
}
function matchSessionFilter(sessionKey: string, patterns: string[]): boolean {
return patterns.some((pattern) => {
try {
return sessionKey.includes(pattern) || new RegExp(pattern).test(sessionKey);
} catch {
return sessionKey.includes(pattern);
}
});
}
function shouldForward(params: {
config?: ExecApprovalForwardingConfig;
request: ExecApprovalRequest;
}): boolean {
const config = params.config;
if (!config?.enabled) return false;
if (config.agentFilter?.length) {
const agentId =
params.request.request.agentId ??
parseAgentSessionKey(params.request.request.sessionKey)?.agentId;
if (!agentId) return false;
if (!config.agentFilter.includes(agentId)) return false;
}
if (config.sessionFilter?.length) {
const sessionKey = params.request.request.sessionKey;
if (!sessionKey) return false;
if (!matchSessionFilter(sessionKey, config.sessionFilter)) return false;
}
return true;
}
function buildTargetKey(target: ExecApprovalForwardTarget): string {
const channel = normalizeMessageChannel(target.channel) ?? target.channel;
const accountId = target.accountId ?? "";
const threadId = target.threadId ?? "";
return [channel, target.to, accountId, threadId].join(":");
}
function buildRequestMessage(request: ExecApprovalRequest, nowMs: number) {
const lines: string[] = ["🔒 Exec approval required", `ID: ${request.id}`];
lines.push(`Command: ${request.request.command}`);
if (request.request.cwd) lines.push(`CWD: ${request.request.cwd}`);
if (request.request.host) lines.push(`Host: ${request.request.host}`);
if (request.request.agentId) lines.push(`Agent: ${request.request.agentId}`);
if (request.request.security) lines.push(`Security: ${request.request.security}`);
if (request.request.ask) lines.push(`Ask: ${request.request.ask}`);
const expiresIn = Math.max(0, Math.round((request.expiresAtMs - nowMs) / 1000));
lines.push(`Expires in: ${expiresIn}s`);
lines.push("Reply with: /approve <id> allow-once|allow-always|deny");
return lines.join("\n");
}
function decisionLabel(decision: ExecApprovalDecision): string {
if (decision === "allow-once") return "allowed once";
if (decision === "allow-always") return "allowed always";
return "denied";
}
function buildResolvedMessage(resolved: ExecApprovalResolved) {
const base = `✅ Exec approval ${decisionLabel(resolved.decision)}.`;
const by = resolved.resolvedBy ? ` Resolved by ${resolved.resolvedBy}.` : "";
return `${base}${by} ID: ${resolved.id}`;
}
function buildExpiredMessage(request: ExecApprovalRequest) {
return `⏱️ Exec approval expired. ID: ${request.id}`;
}
function defaultResolveSessionTarget(params: {
cfg: ClawdbotConfig;
request: ExecApprovalRequest;
}): ExecApprovalForwardTarget | null {
const sessionKey = params.request.request.sessionKey?.trim();
if (!sessionKey) return null;
const parsed = parseAgentSessionKey(sessionKey);
const agentId = parsed?.agentId ?? params.request.request.agentId ?? "main";
const storePath = resolveStorePath(params.cfg.session?.store, { agentId });
const store = loadSessionStore(storePath);
const entry = store[sessionKey];
if (!entry) return null;
const target = resolveSessionDeliveryTarget({ entry, requestedChannel: "last" });
if (!target.channel || !target.to) return null;
if (!isDeliverableMessageChannel(target.channel)) return null;
return {
channel: target.channel,
to: target.to,
accountId: target.accountId,
threadId: target.threadId,
};
}
async function deliverToTargets(params: {
cfg: ClawdbotConfig;
targets: ForwardTarget[];
text: string;
deliver: typeof deliverOutboundPayloads;
shouldSend?: () => boolean;
}) {
const deliveries = params.targets.map(async (target) => {
if (params.shouldSend && !params.shouldSend()) return;
const channel = normalizeMessageChannel(target.channel) ?? target.channel;
if (!isDeliverableMessageChannel(channel)) return;
try {
await params.deliver({
cfg: params.cfg,
channel,
to: target.to,
accountId: target.accountId,
threadId: target.threadId,
payloads: [{ text: params.text }],
});
} catch (err) {
log.error(`exec approvals: failed to deliver to ${channel}:${target.to}: ${String(err)}`);
}
});
await Promise.allSettled(deliveries);
}
export function createExecApprovalForwarder(
deps: ExecApprovalForwarderDeps = {},
): ExecApprovalForwarder {
const getConfig = deps.getConfig ?? loadConfig;
const deliver = deps.deliver ?? deliverOutboundPayloads;
const nowMs = deps.nowMs ?? Date.now;
const resolveSessionTarget = deps.resolveSessionTarget ?? defaultResolveSessionTarget;
const pending = new Map<string, PendingApproval>();
const handleRequested = async (request: ExecApprovalRequest) => {
const cfg = getConfig();
const config = cfg.approvals?.exec;
if (!shouldForward({ config, request })) return;
const mode = normalizeMode(config?.mode);
const targets: ForwardTarget[] = [];
const seen = new Set<string>();
if (mode === "session" || mode === "both") {
const sessionTarget = resolveSessionTarget({ cfg, request });
if (sessionTarget) {
const key = buildTargetKey(sessionTarget);
if (!seen.has(key)) {
seen.add(key);
targets.push({ ...sessionTarget, source: "session" });
}
}
}
if (mode === "targets" || mode === "both") {
const explicitTargets = config?.targets ?? [];
for (const target of explicitTargets) {
const key = buildTargetKey(target);
if (seen.has(key)) continue;
seen.add(key);
targets.push({ ...target, source: "target" });
}
}
if (targets.length === 0) return;
const expiresInMs = Math.max(0, request.expiresAtMs - nowMs());
const timeoutId = setTimeout(() => {
void (async () => {
const entry = pending.get(request.id);
if (!entry) return;
pending.delete(request.id);
const expiredText = buildExpiredMessage(request);
await deliverToTargets({ cfg, targets: entry.targets, text: expiredText, deliver });
})();
}, expiresInMs);
timeoutId.unref?.();
const pendingEntry: PendingApproval = { request, targets, timeoutId };
pending.set(request.id, pendingEntry);
if (pending.get(request.id) !== pendingEntry) return;
const text = buildRequestMessage(request, nowMs());
await deliverToTargets({
cfg,
targets,
text,
deliver,
shouldSend: () => pending.get(request.id) === pendingEntry,
});
};
const handleResolved = async (resolved: ExecApprovalResolved) => {
const entry = pending.get(resolved.id);
if (!entry) return;
if (entry.timeoutId) clearTimeout(entry.timeoutId);
pending.delete(resolved.id);
const cfg = getConfig();
const text = buildResolvedMessage(resolved);
await deliverToTargets({ cfg, targets: entry.targets, text, deliver });
};
const stop = () => {
for (const entry of pending.values()) {
if (entry.timeoutId) clearTimeout(entry.timeoutId);
}
pending.clear();
};
return { handleRequested, handleResolved, stop };
}
export function shouldForwardExecApproval(params: {
config?: ExecApprovalForwardingConfig;
request: ExecApprovalRequest;
}): boolean {
return shouldForward(params);
}

View File

@@ -35,6 +35,7 @@ import {
} from "../config/sessions.js";
import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js";
import { formatErrorMessage } from "../infra/errors.js";
import { peekSystemEvents } from "../infra/system-events.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { getQueueSize } from "../process/command-queue.js";
import { CommandLane } from "../process/lanes.js";
@@ -88,6 +89,14 @@ export type HeartbeatSummary = {
const DEFAULT_HEARTBEAT_TARGET = "last";
const ACTIVE_HOURS_TIME_PATTERN = /^([01]\d|2[0-3]|24):([0-5]\d)$/;
// Prompt used when an async exec has completed and the result should be relayed to the user.
// This overrides the standard heartbeat prompt to ensure the model responds with the exec result
// instead of just "HEARTBEAT_OK".
const EXEC_EVENT_PROMPT =
"An async command you ran earlier has completed. The result is shown in the system messages above. " +
"Please relay the command output to the user in a helpful way. If the command succeeded, share the relevant output. " +
"If it failed, explain what went wrong.";
function resolveActiveHoursTimezone(cfg: ClawdbotConfig, raw?: string): string {
const trimmed = raw?.trim();
if (!trimmed || trimmed === "user") {
@@ -453,11 +462,13 @@ export async function runHeartbeatOnce(opts: {
// Skip heartbeat if HEARTBEAT.md exists but has no actionable content.
// This saves API calls/costs when the file is effectively empty (only comments/headers).
// EXCEPTION: Don't skip for exec events - they have pending system events to process.
const isExecEventReason = opts.reason === "exec-event";
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const heartbeatFilePath = path.join(workspaceDir, DEFAULT_HEARTBEAT_FILENAME);
try {
const heartbeatFileContent = await fs.readFile(heartbeatFilePath, "utf-8");
if (isHeartbeatContentEffectivelyEmpty(heartbeatFileContent)) {
if (isHeartbeatContentEffectivelyEmpty(heartbeatFileContent) && !isExecEventReason) {
emitHeartbeatEvent({
status: "skipped",
reason: "empty-heartbeat-file",
@@ -483,12 +494,20 @@ export async function runHeartbeatOnce(opts: {
: { showOk: false, showAlerts: true, useIndicator: true };
const { sender } = resolveHeartbeatSenderContext({ cfg, entry, delivery });
const responsePrefix = resolveEffectiveMessagesConfig(cfg, agentId).responsePrefix;
const prompt = resolveHeartbeatPrompt(cfg, heartbeat);
// Check if this is an exec event with pending exec completion system events.
// If so, use a specialized prompt that instructs the model to relay the result
// instead of the standard heartbeat prompt with "reply HEARTBEAT_OK".
const isExecEvent = opts.reason === "exec-event";
const pendingEvents = isExecEvent ? peekSystemEvents(sessionKey) : [];
const hasExecCompletion = pendingEvents.some((evt) => evt.includes("Exec finished"));
const prompt = hasExecCompletion ? EXEC_EVENT_PROMPT : resolveHeartbeatPrompt(cfg, heartbeat);
const ctx = {
Body: prompt,
From: sender,
To: sender,
Provider: "heartbeat",
Provider: hasExecCompletion ? "exec-event" : "heartbeat",
SessionKey: sessionKey,
};
if (!visibility.showAlerts && !visibility.showOk && !visibility.useIndicator) {
@@ -558,7 +577,19 @@ export async function runHeartbeatOnce(opts: {
const ackMaxChars = resolveHeartbeatAckMaxChars(cfg, heartbeat);
const normalized = normalizeHeartbeatReply(replyPayload, responsePrefix, ackMaxChars);
const shouldSkipMain = normalized.shouldSkip && !normalized.hasMedia;
// For exec completion events, don't skip even if the response looks like HEARTBEAT_OK.
// The model should be responding with exec results, not ack tokens.
// Also, if normalized.text is empty due to token stripping but we have exec completion,
// fall back to the original reply text.
const execFallbackText =
hasExecCompletion && !normalized.text.trim() && replyPayload.text?.trim()
? replyPayload.text.trim()
: null;
if (execFallbackText) {
normalized.text = execFallbackText;
normalized.shouldSkip = false;
}
const shouldSkipMain = normalized.shouldSkip && !normalized.hasMedia && !hasExecCompletion;
if (shouldSkipMain && reasoningPayloads.length === 0) {
await restoreHeartbeatUpdatedAt({
storePath,