import type { ReplyPayload } from "../auto-reply/types.js"; import type { OpenClawConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js"; import type { ExecApprovalForwardingConfig, ExecApprovalForwardTarget, } from "../config/types.approvals.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { normalizeAccountId, parseAgentSessionKey } from "../routing/session-key.js"; import { compileSafeRegex, testRegexWithBoundedInput } from "../security/safe-regex.js"; import { buildTelegramExecApprovalButtons } from "../telegram/approval-buttons.js"; import { sendTypingTelegram } from "../telegram/send.js"; import { isDeliverableMessageChannel, normalizeMessageChannel, type DeliverableMessageChannel, } from "../utils/message-channel.js"; import { resolveExecApprovalCommandDisplay } from "./exec-approval-command-display.js"; import { buildExecApprovalPendingReplyPayload } from "./exec-approval-reply.js"; import { resolveExecApprovalSessionTarget } from "./exec-approval-session-target.js"; import type { ExecApprovalDecision, ExecApprovalRequest, ExecApprovalResolved, } from "./exec-approvals.js"; import { deliverOutboundPayloads } from "./outbound/deliver.js"; const log = createSubsystemLogger("gateway/exec-approvals"); export type { ExecApprovalRequest, ExecApprovalResolved }; type ForwardTarget = ExecApprovalForwardTarget & { source: "session" | "target" }; type PendingApproval = { request: ExecApprovalRequest; targets: ForwardTarget[]; timeoutId: NodeJS.Timeout | null; }; export type ExecApprovalForwarder = { handleRequested: (request: ExecApprovalRequest) => Promise; handleResolved: (resolved: ExecApprovalResolved) => Promise; stop: () => void; }; export type ExecApprovalForwarderDeps = { getConfig?: () => OpenClawConfig; deliver?: typeof deliverOutboundPayloads; nowMs?: () => number; resolveSessionTarget?: (params: { cfg: OpenClawConfig; 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) => { if (sessionKey.includes(pattern)) { return true; } const regex = compileSafeRegex(pattern); return regex ? testRegexWithBoundedInput(regex, sessionKey) : false; }); } function shouldForward(params: { config?: { enabled?: boolean; agentFilter?: string[]; sessionFilter?: string[]; }; 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 resolveChannelAccountConfig( accounts: Record | undefined, accountId?: string, ): T | undefined { if (!accounts || !accountId?.trim()) { return undefined; } const normalized = normalizeAccountId(accountId); const direct = accounts[normalized]; if (direct) { return direct; } const fallbackKey = Object.keys(accounts).find( (key) => key.toLowerCase() === normalized.toLowerCase(), ); return fallbackKey ? accounts[fallbackKey] : undefined; } // Discord has component-based exec approvals; skip text fallback only when the // Discord-specific handler is enabled for the same target account. function shouldSkipDiscordForwarding( target: ExecApprovalForwardTarget, cfg: OpenClawConfig, ): boolean { const channel = normalizeMessageChannel(target.channel) ?? target.channel; if (channel !== "discord") { return false; } const discord = cfg.channels?.discord as | { execApprovals?: { enabled?: boolean; approvers?: Array }; accounts?: Record< string, { execApprovals?: { enabled?: boolean; approvers?: Array } } >; } | undefined; if (!discord) { return false; } const account = resolveChannelAccountConfig(discord.accounts, target.accountId); const execApprovals = account?.execApprovals ?? discord.execApprovals; return Boolean(execApprovals?.enabled && (execApprovals.approvers?.length ?? 0) > 0); } function shouldSkipTelegramForwarding(params: { target: ExecApprovalForwardTarget; cfg: OpenClawConfig; request: ExecApprovalRequest; }): boolean { const channel = normalizeMessageChannel(params.target.channel) ?? params.target.channel; if (channel !== "telegram") { return false; } const requestChannel = normalizeMessageChannel(params.request.request.turnSourceChannel ?? ""); if (requestChannel !== "telegram") { return false; } const telegram = params.cfg.channels?.telegram; if (!telegram) { return false; } const telegramConfig = telegram as | { execApprovals?: { enabled?: boolean; approvers?: Array }; accounts?: Record< string, { execApprovals?: { enabled?: boolean; approvers?: Array } } >; } | undefined; if (!telegramConfig) { return false; } const accountId = params.target.accountId?.trim() || params.request.request.turnSourceAccountId?.trim(); const account = accountId ? (resolveChannelAccountConfig<{ execApprovals?: { enabled?: boolean; approvers?: Array }; }>(telegramConfig.accounts, accountId) as | { execApprovals?: { enabled?: boolean; approvers?: Array } } | undefined) : undefined; const execApprovals = account?.execApprovals ?? telegramConfig.execApprovals; return Boolean(execApprovals?.enabled && (execApprovals.approvers?.length ?? 0) > 0); } function formatApprovalCommand(command: string): { inline: boolean; text: string } { if (!command.includes("\n") && !command.includes("`")) { return { inline: true, text: `\`${command}\`` }; } let fence = "```"; while (command.includes(fence)) { fence += "`"; } return { inline: false, text: `${fence}\n${command}\n${fence}` }; } function buildRequestMessage(request: ExecApprovalRequest, nowMs: number) { const lines: string[] = ["🔒 Exec approval required", `ID: ${request.id}`]; const command = formatApprovalCommand( resolveExecApprovalCommandDisplay(request.request).commandText, ); if (command.inline) { lines.push(`Command: ${command.text}`); } else { lines.push("Command:"); lines.push(command.text); } if (request.request.cwd) { lines.push(`CWD: ${request.request.cwd}`); } if (request.request.nodeId) { lines.push(`Node: ${request.request.nodeId}`); } if (Array.isArray(request.request.envKeys) && request.request.envKeys.length > 0) { lines.push(`Env overrides: ${request.request.envKeys.join(", ")}`); } 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("Mode: foreground (interactive approvals available in this chat)."); lines.push( "Background mode note: non-interactive runs cannot wait for chat approvals; use pre-approved policy (allow-always or ask=off).", ); lines.push("Reply with: /approve 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 normalizeTurnSourceChannel(value?: string | null): DeliverableMessageChannel | undefined { const normalized = value ? normalizeMessageChannel(value) : undefined; return normalized && isDeliverableMessageChannel(normalized) ? normalized : undefined; } function defaultResolveSessionTarget(params: { cfg: OpenClawConfig; request: ExecApprovalRequest; }): ExecApprovalForwardTarget | null { const resolvedTarget = resolveExecApprovalSessionTarget({ cfg: params.cfg, request: params.request, turnSourceChannel: normalizeTurnSourceChannel(params.request.request.turnSourceChannel), turnSourceTo: params.request.request.turnSourceTo?.trim() || undefined, turnSourceAccountId: params.request.request.turnSourceAccountId?.trim() || undefined, turnSourceThreadId: params.request.request.turnSourceThreadId ?? undefined, }); if (!resolvedTarget?.channel || !resolvedTarget.to) { return null; } const channel = resolvedTarget.channel; if (!isDeliverableMessageChannel(channel)) { return null; } return { channel, to: resolvedTarget.to, accountId: resolvedTarget.accountId, threadId: resolvedTarget.threadId, }; } async function deliverToTargets(params: { cfg: OpenClawConfig; targets: ForwardTarget[]; buildPayload: (target: ForwardTarget) => ReplyPayload; 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 payload = params.buildPayload(target); if ( channel === "telegram" && payload.channelData && typeof payload.channelData === "object" && !Array.isArray(payload.channelData) && payload.channelData.execApproval ) { const threadId = typeof target.threadId === "number" ? target.threadId : typeof target.threadId === "string" ? Number.parseInt(target.threadId, 10) : undefined; await sendTypingTelegram(target.to, { cfg: params.cfg, accountId: target.accountId, ...(Number.isFinite(threadId) ? { messageThreadId: threadId } : {}), }).catch(() => {}); } await params.deliver({ cfg: params.cfg, channel, to: target.to, accountId: target.accountId, threadId: target.threadId, payloads: [payload], }); } catch (err) { log.error(`exec approvals: failed to deliver to ${channel}:${target.to}: ${String(err)}`); } }); await Promise.allSettled(deliveries); } function buildRequestPayloadForTarget( _cfg: OpenClawConfig, request: ExecApprovalRequest, nowMsValue: number, target: ForwardTarget, ): ReplyPayload { const channel = normalizeMessageChannel(target.channel) ?? target.channel; if (channel === "telegram") { const payload = buildExecApprovalPendingReplyPayload({ approvalId: request.id, approvalSlug: request.id.slice(0, 8), approvalCommandId: request.id, command: resolveExecApprovalCommandDisplay(request.request).commandText, cwd: request.request.cwd ?? undefined, host: request.request.host === "node" ? "node" : "gateway", nodeId: request.request.nodeId ?? undefined, expiresAtMs: request.expiresAtMs, nowMs: nowMsValue, }); const buttons = buildTelegramExecApprovalButtons(request.id); if (!buttons) { return payload; } return { ...payload, channelData: { ...payload.channelData, telegram: { buttons, }, }, }; } return { text: buildRequestMessage(request, nowMsValue) }; } function resolveForwardTargets(params: { cfg: OpenClawConfig; config?: ExecApprovalForwardingConfig; request: ExecApprovalRequest; resolveSessionTarget: (params: { cfg: OpenClawConfig; request: ExecApprovalRequest; }) => ExecApprovalForwardTarget | null; }): ForwardTarget[] { const mode = normalizeMode(params.config?.mode); const targets: ForwardTarget[] = []; const seen = new Set(); if (mode === "session" || mode === "both") { const sessionTarget = params.resolveSessionTarget({ cfg: params.cfg, request: params.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 = params.config?.targets ?? []; for (const target of explicitTargets) { const key = buildTargetKey(target); if (seen.has(key)) { continue; } seen.add(key); targets.push({ ...target, source: "target" }); } } return targets; } 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(); const handleRequested = async (request: ExecApprovalRequest): Promise => { const cfg = getConfig(); const config = cfg.approvals?.exec; const filteredTargets = [ ...(shouldForward({ config, request }) ? resolveForwardTargets({ cfg, config, request, resolveSessionTarget, }) : []), ].filter( (target) => !shouldSkipDiscordForwarding(target, cfg) && !shouldSkipTelegramForwarding({ target, cfg, request }), ); if (filteredTargets.length === 0) { return false; } 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, buildPayload: () => ({ text: expiredText }), deliver, }); })(); }, expiresInMs); timeoutId.unref?.(); const pendingEntry: PendingApproval = { request, targets: filteredTargets, timeoutId }; pending.set(request.id, pendingEntry); if (pending.get(request.id) !== pendingEntry) { return false; } void deliverToTargets({ cfg, targets: filteredTargets, buildPayload: (target) => buildRequestPayloadForTarget(cfg, request, nowMs(), target), deliver, shouldSend: () => pending.get(request.id) === pendingEntry, }).catch((err) => { log.error(`exec approvals: failed to deliver request ${request.id}: ${String(err)}`); }); return true; }; const handleResolved = async (resolved: ExecApprovalResolved) => { const entry = pending.get(resolved.id); if (entry) { if (entry.timeoutId) { clearTimeout(entry.timeoutId); } pending.delete(resolved.id); } const cfg = getConfig(); let targets = entry?.targets; if (!targets && resolved.request) { const request: ExecApprovalRequest = { id: resolved.id, request: resolved.request, createdAtMs: resolved.ts, expiresAtMs: resolved.ts, }; const config = cfg.approvals?.exec; targets = [ ...(shouldForward({ config, request }) ? resolveForwardTargets({ cfg, config, request, resolveSessionTarget, }) : []), ].filter( (target) => !shouldSkipDiscordForwarding(target, cfg) && !shouldSkipTelegramForwarding({ target, cfg, request }), ); } if (!targets || targets.length === 0) { return; } const text = buildResolvedMessage(resolved); await deliverToTargets({ cfg, targets, buildPayload: () => ({ 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); }