OpenClawBot / src /discord /monitor /exec-approvals.ts
darkfire514's picture
Upload 2526 files
fb4d8fe verified
import { Button, type ButtonInteraction, type ComponentData } from "@buape/carbon";
import { ButtonStyle, Routes } from "discord-api-types/v10";
import type { OpenClawConfig } from "../../config/config.js";
import type { DiscordExecApprovalConfig } from "../../config/types.discord.js";
import type { EventFrame } from "../../gateway/protocol/index.js";
import type { ExecApprovalDecision } from "../../infra/exec-approvals.js";
import type { RuntimeEnv } from "../../runtime.js";
import { GatewayClient } from "../../gateway/client.js";
import { logDebug, logError } from "../../logger.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js";
import { createDiscordClient } from "../send.shared.js";
const EXEC_APPROVAL_KEY = "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 = {
discordMessageId: string;
discordChannelId: string;
timeoutId: NodeJS.Timeout;
};
function encodeCustomIdValue(value: string): string {
return encodeURIComponent(value);
}
function decodeCustomIdValue(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
export function buildExecApprovalCustomId(
approvalId: string,
action: ExecApprovalDecision,
): string {
return [`${EXEC_APPROVAL_KEY}:id=${encodeCustomIdValue(approvalId)}`, `action=${action}`].join(
";",
);
}
export function parseExecApprovalData(
data: ComponentData,
): { approvalId: string; action: ExecApprovalDecision } | null {
if (!data || typeof data !== "object") {
return null;
}
const coerce = (value: unknown) =>
typeof value === "string" || typeof value === "number" ? String(value) : "";
const rawId = coerce(data.id);
const rawAction = coerce(data.action);
if (!rawId || !rawAction) {
return null;
}
const action = rawAction as ExecApprovalDecision;
if (action !== "allow-once" && action !== "allow-always" && action !== "deny") {
return null;
}
return {
approvalId: decodeCustomIdValue(rawId),
action,
};
}
function formatExecApprovalEmbed(request: ExecApprovalRequest) {
const commandText = request.request.command;
const commandPreview =
commandText.length > 1000 ? `${commandText.slice(0, 1000)}...` : commandText;
const expiresIn = Math.max(0, Math.round((request.expiresAtMs - Date.now()) / 1000));
const fields: Array<{ name: string; value: string; inline: boolean }> = [
{
name: "Command",
value: `\`\`\`\n${commandPreview}\n\`\`\``,
inline: false,
},
];
if (request.request.cwd) {
fields.push({
name: "Working Directory",
value: request.request.cwd,
inline: true,
});
}
if (request.request.host) {
fields.push({
name: "Host",
value: request.request.host,
inline: true,
});
}
if (request.request.agentId) {
fields.push({
name: "Agent",
value: request.request.agentId,
inline: true,
});
}
return {
title: "Exec Approval Required",
description: "A command needs your approval.",
color: 0xffa500, // Orange
fields,
footer: { text: `Expires in ${expiresIn}s | ID: ${request.id}` },
timestamp: new Date().toISOString(),
};
}
function formatResolvedEmbed(
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 color = decision === "deny" ? 0xed4245 : decision === "allow-always" ? 0x5865f2 : 0x57f287;
return {
title: `Exec Approval: ${decisionLabel}`,
description: resolvedBy ? `Resolved by ${resolvedBy}` : "Resolved",
color,
fields: [
{
name: "Command",
value: `\`\`\`\n${commandPreview}\n\`\`\``,
inline: false,
},
],
footer: { text: `ID: ${request.id}` },
timestamp: new Date().toISOString(),
};
}
function formatExpiredEmbed(request: ExecApprovalRequest) {
const commandText = request.request.command;
const commandPreview = commandText.length > 500 ? `${commandText.slice(0, 500)}...` : commandText;
return {
title: "Exec Approval: Expired",
description: "This approval request has expired.",
color: 0x99aab5, // Gray
fields: [
{
name: "Command",
value: `\`\`\`\n${commandPreview}\n\`\`\``,
inline: false,
},
],
footer: { text: `ID: ${request.id}` },
timestamp: new Date().toISOString(),
};
}
export type DiscordExecApprovalHandlerOpts = {
token: string;
accountId: string;
config: DiscordExecApprovalConfig;
gatewayUrl?: string;
cfg: OpenClawConfig;
runtime?: RuntimeEnv;
onResolve?: (id: string, decision: ExecApprovalDecision) => Promise<void>;
};
export class DiscordExecApprovalHandler {
private gatewayClient: GatewayClient | null = null;
private pending = new Map<string, PendingApproval>();
private requestCache = new Map<string, ExecApprovalRequest>();
private opts: DiscordExecApprovalHandlerOpts;
private started = false;
constructor(opts: DiscordExecApprovalHandlerOpts) {
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("discord exec approvals: disabled");
return;
}
if (!config.approvers || config.approvers.length === 0) {
logDebug("discord exec approvals: no approvers configured");
return;
}
logDebug("discord 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: "Discord Exec Approvals",
mode: GATEWAY_CLIENT_MODES.BACKEND,
scopes: ["operator.approvals"],
onEvent: (evt) => this.handleGatewayEvent(evt),
onHelloOk: () => {
logDebug("discord exec approvals: connected to gateway");
},
onConnectError: (err) => {
logError(`discord exec approvals: connect error: ${err.message}`);
},
onClose: (code, reason) => {
logDebug(`discord 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("discord 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(`discord exec approvals: received request ${request.id}`);
this.requestCache.set(request.id, request);
const { rest, request: discordRequest } = createDiscordClient(
{ token: this.opts.token, accountId: this.opts.accountId },
this.opts.cfg,
);
const embed = formatExecApprovalEmbed(request);
// Build action rows with buttons
const components = [
{
type: 1, // ACTION_ROW
components: [
{
type: 2, // BUTTON
style: ButtonStyle.Success,
label: "Allow once",
custom_id: buildExecApprovalCustomId(request.id, "allow-once"),
},
{
type: 2, // BUTTON
style: ButtonStyle.Primary,
label: "Always allow",
custom_id: buildExecApprovalCustomId(request.id, "allow-always"),
},
{
type: 2, // BUTTON
style: ButtonStyle.Danger,
label: "Deny",
custom_id: buildExecApprovalCustomId(request.id, "deny"),
},
],
},
];
const approvers = this.opts.config.approvers ?? [];
for (const approver of approvers) {
const userId = String(approver);
try {
// Create DM channel
const dmChannel = (await discordRequest(
() =>
rest.post(Routes.userChannels(), {
body: { recipient_id: userId },
}) as Promise<{ id: string }>,
"dm-channel",
)) as { id: string };
if (!dmChannel?.id) {
logError(`discord exec approvals: failed to create DM for user ${userId}`);
continue;
}
// Send message with embed and buttons
const message = (await discordRequest(
() =>
rest.post(Routes.channelMessages(dmChannel.id), {
body: {
embeds: [embed],
components,
},
}) as Promise<{ id: string; channel_id: string }>,
"send-approval",
)) as { id: string; channel_id: string };
if (!message?.id) {
logError(`discord 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, {
discordMessageId: message.id,
discordChannelId: dmChannel.id,
timeoutId,
});
logDebug(`discord exec approvals: sent approval ${request.id} to user ${userId}`);
} catch (err) {
logError(`discord 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(`discord exec approvals: resolved ${resolved.id} with ${resolved.decision}`);
await this.updateMessage(
pending.discordChannelId,
pending.discordMessageId,
formatResolvedEmbed(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(`discord exec approvals: timeout for ${approvalId}`);
await this.updateMessage(
pending.discordChannelId,
pending.discordMessageId,
formatExpiredEmbed(request),
);
}
private async updateMessage(
channelId: string,
messageId: string,
embed: ReturnType<typeof formatExpiredEmbed>,
): Promise<void> {
try {
const { rest, request: discordRequest } = createDiscordClient(
{ token: this.opts.token, accountId: this.opts.accountId },
this.opts.cfg,
);
await discordRequest(
() =>
rest.patch(Routes.channelMessage(channelId, messageId), {
body: {
embeds: [embed],
components: [], // Remove buttons
},
}),
"update-approval",
);
} catch (err) {
logError(`discord exec approvals: failed to update message: ${String(err)}`);
}
}
async resolveApproval(approvalId: string, decision: ExecApprovalDecision): Promise<boolean> {
if (!this.gatewayClient) {
logError("discord exec approvals: gateway client not connected");
return false;
}
logDebug(`discord exec approvals: resolving ${approvalId} with ${decision}`);
try {
await this.gatewayClient.request("exec.approval.resolve", {
id: approvalId,
decision,
});
logDebug(`discord exec approvals: resolved ${approvalId} successfully`);
return true;
} catch (err) {
logError(`discord exec approvals: resolve failed: ${String(err)}`);
return false;
}
}
}
export type ExecApprovalButtonContext = {
handler: DiscordExecApprovalHandler;
};
export class ExecApprovalButton extends Button {
label = "execapproval";
customId = `${EXEC_APPROVAL_KEY}:seed=1`;
style = ButtonStyle.Primary;
private ctx: ExecApprovalButtonContext;
constructor(ctx: ExecApprovalButtonContext) {
super();
this.ctx = ctx;
}
async run(interaction: ButtonInteraction, data: ComponentData): Promise<void> {
const parsed = parseExecApprovalData(data);
if (!parsed) {
try {
await interaction.update({
content: "This approval is no longer valid.",
components: [],
});
} catch {
// Interaction may have expired
}
return;
}
const decisionLabel =
parsed.action === "allow-once"
? "Allowed (once)"
: parsed.action === "allow-always"
? "Allowed (always)"
: "Denied";
// Update the message immediately to show the decision
try {
await interaction.update({
content: `Submitting decision: **${decisionLabel}**...`,
components: [], // Remove buttons
});
} catch {
// Interaction may have expired, try to continue anyway
}
const ok = await this.ctx.handler.resolveApproval(parsed.approvalId, parsed.action);
if (!ok) {
try {
await interaction.followUp({
content:
"Failed to submit approval decision. The request may have expired or already been resolved.",
ephemeral: true,
});
} catch {
// Interaction may have expired
}
}
// On success, the handleApprovalResolved event will update the message with the final result
}
}
export function createExecApprovalButton(ctx: ExecApprovalButtonContext): Button {
return new ExecApprovalButton(ctx);
}