* 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>
138 lines
4.5 KiB
TypeScript
138 lines
4.5 KiB
TypeScript
import type { ExecApprovalDecision } from "../../infra/exec-approvals.js";
|
|
import type { ExecApprovalForwarder } from "../../infra/exec-approval-forwarder.js";
|
|
import type { ExecApprovalManager } from "../exec-approval-manager.js";
|
|
import {
|
|
ErrorCodes,
|
|
errorShape,
|
|
formatValidationErrors,
|
|
validateExecApprovalRequestParams,
|
|
validateExecApprovalResolveParams,
|
|
} from "../protocol/index.js";
|
|
import type { GatewayRequestHandlers } from "./types.js";
|
|
|
|
export function createExecApprovalHandlers(
|
|
manager: ExecApprovalManager,
|
|
opts?: { forwarder?: ExecApprovalForwarder },
|
|
): GatewayRequestHandlers {
|
|
return {
|
|
"exec.approval.request": async ({ params, respond, context }) => {
|
|
if (!validateExecApprovalRequestParams(params)) {
|
|
respond(
|
|
false,
|
|
undefined,
|
|
errorShape(
|
|
ErrorCodes.INVALID_REQUEST,
|
|
`invalid exec.approval.request params: ${formatValidationErrors(
|
|
validateExecApprovalRequestParams.errors,
|
|
)}`,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
const p = params as {
|
|
id?: string;
|
|
command: string;
|
|
cwd?: string;
|
|
host?: string;
|
|
security?: string;
|
|
ask?: string;
|
|
agentId?: string;
|
|
resolvedPath?: string;
|
|
sessionKey?: string;
|
|
timeoutMs?: number;
|
|
};
|
|
const timeoutMs = typeof p.timeoutMs === "number" ? p.timeoutMs : 120_000;
|
|
const explicitId = typeof p.id === "string" && p.id.trim().length > 0 ? p.id.trim() : null;
|
|
if (explicitId && manager.getSnapshot(explicitId)) {
|
|
respond(
|
|
false,
|
|
undefined,
|
|
errorShape(ErrorCodes.INVALID_REQUEST, "approval id already pending"),
|
|
);
|
|
return;
|
|
}
|
|
const request = {
|
|
command: p.command,
|
|
cwd: p.cwd ?? null,
|
|
host: p.host ?? null,
|
|
security: p.security ?? null,
|
|
ask: p.ask ?? null,
|
|
agentId: p.agentId ?? null,
|
|
resolvedPath: p.resolvedPath ?? null,
|
|
sessionKey: p.sessionKey ?? null,
|
|
};
|
|
const record = manager.create(request, timeoutMs, explicitId);
|
|
const decisionPromise = manager.waitForDecision(record, timeoutMs);
|
|
context.broadcast(
|
|
"exec.approval.requested",
|
|
{
|
|
id: record.id,
|
|
request: record.request,
|
|
createdAtMs: record.createdAtMs,
|
|
expiresAtMs: record.expiresAtMs,
|
|
},
|
|
{ dropIfSlow: true },
|
|
);
|
|
void opts?.forwarder
|
|
?.handleRequested({
|
|
id: record.id,
|
|
request: record.request,
|
|
createdAtMs: record.createdAtMs,
|
|
expiresAtMs: record.expiresAtMs,
|
|
})
|
|
.catch((err) => {
|
|
context.logGateway?.error?.(`exec approvals: forward request failed: ${String(err)}`);
|
|
});
|
|
const decision = await decisionPromise;
|
|
respond(
|
|
true,
|
|
{
|
|
id: record.id,
|
|
decision,
|
|
createdAtMs: record.createdAtMs,
|
|
expiresAtMs: record.expiresAtMs,
|
|
},
|
|
undefined,
|
|
);
|
|
},
|
|
"exec.approval.resolve": async ({ params, respond, client, context }) => {
|
|
if (!validateExecApprovalResolveParams(params)) {
|
|
respond(
|
|
false,
|
|
undefined,
|
|
errorShape(
|
|
ErrorCodes.INVALID_REQUEST,
|
|
`invalid exec.approval.resolve params: ${formatValidationErrors(
|
|
validateExecApprovalResolveParams.errors,
|
|
)}`,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
const p = params as { id: string; decision: string };
|
|
const decision = p.decision as ExecApprovalDecision;
|
|
if (decision !== "allow-once" && decision !== "allow-always" && decision !== "deny") {
|
|
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "invalid decision"));
|
|
return;
|
|
}
|
|
const resolvedBy = client?.connect?.client?.displayName ?? client?.connect?.client?.id;
|
|
const ok = manager.resolve(p.id, decision, resolvedBy ?? null);
|
|
if (!ok) {
|
|
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown approval id"));
|
|
return;
|
|
}
|
|
context.broadcast(
|
|
"exec.approval.resolved",
|
|
{ id: p.id, decision, resolvedBy, ts: Date.now() },
|
|
{ dropIfSlow: true },
|
|
);
|
|
void opts?.forwarder
|
|
?.handleResolved({ id: p.id, decision, resolvedBy, ts: Date.now() })
|
|
.catch((err) => {
|
|
context.logGateway?.error?.(`exec approvals: forward resolve failed: ${String(err)}`);
|
|
});
|
|
respond(true, { ok: true }, undefined);
|
|
},
|
|
};
|
|
}
|