When exec approval requests are forwarded to Telegram, the message now includes inline keyboard buttons for Allow once, Always allow, and Deny actions instead of requiring users to type the /approve command manually. The callback handler parses button clicks and resolves approvals via the gateway, then updates the message with the result.
338 lines
11 KiB
TypeScript
338 lines
11 KiB
TypeScript
import type { MoltbotConfig } 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");
|
|
|
|
const EXEC_APPROVAL_KEY = "execapproval";
|
|
|
|
export function buildTelegramExecApprovalCallbackData(
|
|
approvalId: string,
|
|
action: ExecApprovalDecision,
|
|
): string {
|
|
return `${EXEC_APPROVAL_KEY}:${approvalId}:${action}`;
|
|
}
|
|
|
|
export function parseTelegramExecApprovalCallbackData(
|
|
data: string,
|
|
): { approvalId: string; action: ExecApprovalDecision } | null {
|
|
const parts = data.split(":");
|
|
if (parts.length < 3) return null;
|
|
if (parts[0] !== EXEC_APPROVAL_KEY) return null;
|
|
const action = parts[2] as ExecApprovalDecision;
|
|
if (action !== "allow-once" && action !== "allow-always" && action !== "deny") {
|
|
return null;
|
|
}
|
|
return { approvalId: parts[1], action };
|
|
}
|
|
|
|
function buildTelegramApprovalButtons(
|
|
approvalId: string,
|
|
): Array<Array<{ text: string; callback_data: string }>> {
|
|
return [
|
|
[
|
|
{
|
|
text: "✅ Allow once",
|
|
callback_data: buildTelegramExecApprovalCallbackData(approvalId, "allow-once"),
|
|
},
|
|
{
|
|
text: "♾️ Always allow",
|
|
callback_data: buildTelegramExecApprovalCallbackData(approvalId, "allow-always"),
|
|
},
|
|
{
|
|
text: "❌ Deny",
|
|
callback_data: buildTelegramExecApprovalCallbackData(approvalId, "deny"),
|
|
},
|
|
],
|
|
];
|
|
}
|
|
|
|
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?: () => MoltbotConfig;
|
|
deliver?: typeof deliverOutboundPayloads;
|
|
nowMs?: () => number;
|
|
resolveSessionTarget?: (params: {
|
|
cfg: MoltbotConfig;
|
|
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(":");
|
|
}
|
|
|
|
type RequestMessage = {
|
|
text: string;
|
|
buttons: Array<Array<{ text: string; callback_data: string }>>;
|
|
};
|
|
|
|
function buildRequestMessage(request: ExecApprovalRequest, nowMs: number): RequestMessage {
|
|
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`);
|
|
return {
|
|
text: lines.join("\n"),
|
|
buttons: buildTelegramApprovalButtons(request.id),
|
|
};
|
|
}
|
|
|
|
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: MoltbotConfig;
|
|
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: MoltbotConfig;
|
|
targets: ForwardTarget[];
|
|
text: string;
|
|
buttons?: Array<Array<{ text: string; callback_data: 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 {
|
|
const isTelegram = channel === "telegram";
|
|
const channelData =
|
|
isTelegram && params.buttons ? { telegram: { buttons: params.buttons } } : undefined;
|
|
await params.deliver({
|
|
cfg: params.cfg,
|
|
channel,
|
|
to: target.to,
|
|
accountId: target.accountId,
|
|
threadId: target.threadId,
|
|
payloads: [{ text: params.text, channelData }],
|
|
});
|
|
} 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 message = buildRequestMessage(request, nowMs());
|
|
await deliverToTargets({
|
|
cfg,
|
|
targets,
|
|
text: message.text,
|
|
buttons: message.buttons,
|
|
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);
|
|
}
|